feat: 足迹 Footprints 个人留痕系统

桌面应用(Tauri+React+SQLite):时间线/记录/学年学科成绩梯度/统计/类型与字段模板/附件内容寻址/详情视图/PWA

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

多端同步地基:uuid v7/HLC混合逻辑时钟/软删除墓碑/sync_oplog,Node与Rust双端一致并通过自动化测试
This commit is contained in:
root
2026-10-16 13:53:15 +08:00
commit 42ca57abfd
107 changed files with 234676 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
// 同步核心: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
};
}