Files
FootPrint/sync-server/src/sync.mjs
T
root 42ca57abfd feat: 足迹 Footprints 个人留痕系统
桌面应用(Tauri+React+SQLite):时间线/记录/学年学科成绩梯度/统计/类型与字段模板/附件内容寻址/详情视图/PWA

同步服务端(sync-server):多用户+邀请码/LWW推拉/seq游标/附件去重/Docker部署

多端同步地基:uuid v7/HLC混合逻辑时钟/软删除墓碑/sync_oplog,Node与Rust双端一致并通过自动化测试
2026-10-16 13:53:15 +08:00

90 lines
3.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 同步核心:push(LWW 仲裁)+ pull(seq 游标增量)
import { getDb, SYNC_TABLES } from './db.mjs';
function parseHlc(h) {
const m = /^([0-9]+):([0-9]+):(.+)$/.exec(String(h || ''));
if (!m) return null;
return { ms: Number(m[1]), cnt: Number(m[2]), dev: m[3] };
}
// a 是否严格新于 b:比较 (ms, cnt, dev)
function isNewer(a, b) {
if (a.ms !== b.ms) return a.ms > b.ms;
if (a.cnt !== b.cnt) return a.cnt > b.cnt;
return a.dev > b.dev;
}
export function push(user_id, changes) {
const d = getDb();
const stats = { accepted: 0, stale: 0, invalid: 0 };
if (!Array.isArray(changes)) throw new Error('changes 必须为数组');
if (changes.length > 5000) throw new Error('单次推送不得超过 5000 条');
for (const c of changes) {
const table = c.table;
const row_uuid = String(c.row_uuid || '');
const op = c.op;
const parsed = parseHlc(c.hlc);
if (!SYNC_TABLES.has(table) || !parsed || !['upsert', 'delete'].includes(op)) {
stats.invalid++;
continue;
}
if (op === 'upsert' && (c.data == null || typeof c.data !== 'object')) {
stats.invalid++;
continue;
}
const dataJson = op === 'upsert' ? JSON.stringify(c.data) : null;
const existing = d.prepare(
'SELECT ms, cnt, dev FROM current WHERE user_id=? AND table_name=? AND row_uuid=?'
).get(user_id, table, row_uuid);
if (existing) {
const e = { ms: existing.ms, cnt: existing.cnt, dev: existing.dev };
if (parsed.ms === e.ms && parsed.cnt === e.cnt && parsed.dev === e.dev) {
stats.accepted++; // 同一变更重放,幂等
continue;
}
if (!isNewer(parsed, e)) { stats.stale++; continue; }
d.prepare(`UPDATE current SET op=?, hlc=?, ms=?, cnt=?, dev=?, data=?,
updated_seq=(SELECT COALESCE(MAX(seq),0)+1 FROM changes) WHERE user_id=? AND table_name=? AND row_uuid=?`)
.run(op, c.hlc, parsed.ms, parsed.cnt, parsed.dev, dataJson, user_id, table, row_uuid);
d.prepare('INSERT INTO changes (user_id, table_name, row_uuid, op, hlc, data) VALUES (?,?,?,?,?,?)')
.run(user_id, table, row_uuid, op, c.hlc, dataJson);
stats.accepted++;
} else {
d.prepare(`INSERT INTO current (user_id, table_name, row_uuid, op, hlc, ms, cnt, dev, data,
updated_seq) VALUES (?,?,?,?,?,?,?,?,?,(SELECT COALESCE(MAX(seq),0)+1 FROM changes))`)
.run(user_id, table, row_uuid, op, c.hlc, parsed.ms, parsed.cnt, parsed.dev, dataJson);
d.prepare('INSERT INTO changes (user_id, table_name, row_uuid, op, hlc, data) VALUES (?,?,?,?,?,?)')
.run(user_id, table, row_uuid, op, c.hlc, dataJson);
stats.accepted++;
}
}
return stats;
}
export function pull(user_id, cursorRaw, limitRaw) {
const d = getDb();
const cursor = Math.max(0, Number(cursorRaw) || 0);
const limit = Math.min(2000, Math.max(1, Number(limitRaw) || 1000));
const rows = d.prepare(
'SELECT updated_seq AS seq, table_name, row_uuid, op, hlc, data FROM current WHERE user_id=? AND updated_seq > ? ORDER BY seq LIMIT ?'
).all(user_id, cursor, limit);
const changes = rows.map(r => ({
seq: r.seq,
table: r.table_name,
row_uuid: r.row_uuid,
op: r.op,
hlc: r.hlc,
data: r.data ? JSON.parse(r.data) : null
}));
const maxSeqRow = d.prepare('SELECT COALESCE(MAX(updated_seq),0) AS m FROM current WHERE user_id=?').get(user_id);
const hasMore = rows.length === limit;
return {
changes,
cursor: hasMore ? rows[rows.length - 1].seq : Math.max(cursor, maxSeqRow.m),
has_more: hasMore
};
}