diff --git a/app/.wire-dbg/footprints.db b/app/.wire-dbg/footprints.db new file mode 100644 index 0000000..c198797 Binary files /dev/null and b/app/.wire-dbg/footprints.db differ diff --git a/app/.wire-test/footprints.db b/app/.wire-test/footprints.db new file mode 100644 index 0000000..4ca5319 Binary files /dev/null and b/app/.wire-test/footprints.db differ diff --git a/app/server/db.mjs b/app/server/db.mjs index 5807e07..1604cdf 100644 --- a/app/server/db.mjs +++ b/app/server/db.mjs @@ -80,6 +80,35 @@ export function buildSearchText(title, description, result, tags, extras) { return [title, description || '', result || '', (tags || []).join(' '), ...extraFlat] .filter(Boolean).join('\n'); } + +// ---------- Wire 协议(各端互通:uuid 引用 FK,LWW 仲裁) ---------- + +function parseHlc(h) { + const m = /^([0-9]+):([0-9]+):(.+)$/.exec(String(h || '')); + return m ? { ms: Number(m[1]), cnt: Number(m[2]), dev: m[3] } : null; +} + +function hlcNewer(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; +} + +// 表 → [列, FK引用目标表] +const WIRE_TABLES = { + footprint: { fks: { type_id: 'footprint_type', parent_id: 'footprint' } }, + footprint_type: { fks: {} }, + taxonomy: { fks: {} }, + dimension: { fks: {} }, + grade_band: { fks: {} }, + metric_def: { fks: {} }, + semester: { fks: {} }, + course_grade: { fks: { semester_id: 'semester' } }, + saved_view: { fks: {} }, + attachment: { fks: { owner_id: 'footprint' } }, + footprint_dimension: { fks: { footprint_id: 'footprint', dimension_id: 'dimension' } } +}; + // 老库迁移:补列 + 回填 uuid/hlc/origin(幂等) const SYNC_TABLES = ['footprint', 'footprint_type', 'taxonomy', 'dimension', 'attachment', 'semester', 'course_grade', 'metric_def', 'saved_view', 'grade_band']; @@ -977,13 +1006,168 @@ function listFootprints(d, query) { return { ok: true }; } + case 'sync_apply_wire': + return applyWireBatch(payload.changes || []); + + case 'sync_pull_wire': + return pullWire(payload.cursor, payload.limit); + default: throw new Error('未知命令:' + cmd); } } // 工厂闭合 - return { handleCommand, getDb }; + // ---------- Wire 协议:出站序列化 / 入站应用 ---------- + + const SKIP_COLS = ['id', 'uuid', 'hlc', 'origin', 'created_at', 'updated_at', 'search_text']; + + function uuidOf(table, id) { + if (id == null) return null; + const r = db.prepare('SELECT uuid FROM ' + table + ' WHERE id=?').get(id); + return r ? r.uuid : null; + } + + function idOfUuid(table, uuid, mayCreate) { + if (uuid == null) return null; + const r = db.prepare('SELECT id FROM ' + table + ' WHERE uuid=?').get(uuid); + if (r) return r.id; + if (!mayCreate) return null; + const h2 = hlcNext(db); + if (table === 'footprint_type') { + const rr = db.prepare("INSERT INTO footprint_type (name, icon, color, sort, form_template, is_builtin, uuid, hlc, origin) VALUES (?,?,?,?,?,0,?,?,?)") + .run('同步类型 ' + uuid.slice(0, 8), 'tag', '#64748b', 500, '[]', uuid, h2, 'sync'); + return Number(rr.lastInsertRowid); + } + if (table === 'dimension') { + const rr = db.prepare("INSERT INTO dimension (name, color, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?)") + .run('同步维度 ' + uuid.slice(0, 8), '#64748b', 500, uuid, h2, 'sync'); + return Number(rr.lastInsertRowid); + } + if (table === 'grade_band') { + const rr = db.prepare("INSERT INTO grade_band (label, color, min, max, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)") + .run('同步梯度 ' + uuid.slice(0, 8), '#64748b', 60, 70, 500, uuid, h2, 'sync'); + return Number(rr.lastInsertRowid); + } + if (table === 'metric_def') { + const rr = db.prepare("INSERT INTO metric_def (key, label, unit, agg, section, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)") + .run('sync_' + uuid.slice(0, 8), '同步指标 ' + uuid.slice(0, 8), null, 'sum', null, uuid, h2, 'sync'); + return Number(rr.lastInsertRowid); + } + if (table === 'taxonomy') { + const rr = db.prepare("INSERT INTO taxonomy (kind, value, color, sort, uuid, hlc, origin) VALUES (?,?,?,0,?,?,?,?)") + .run('level', '同步条目 ' + uuid.slice(0, 8), '#64748b', uuid, h2, 'sync'); + return Number(rr.lastInsertRowid); + } + return null; + } + + function serializeRowOf(table) { + const spec = WIRE_TABLES[table]; + return (row) => { + const out = { uuid: row.uuid, hlc: row.hlc, deleted_at: row.deleted_at || null }; + for (const k of Object.keys(row)) { + if (SKIP_COLS.includes(k)) continue; + if (spec.fks[k] !== undefined) { out[k.replace('_id', '_uuid')] = uuidOf(spec.fks[k], row[k]); continue; } + out[k] = row[k]; + } + return out; + }; + } + + function softDeleteById(table, id) { + db.prepare('UPDATE ' + table + " SET deleted_at=datetime('now','localtime') WHERE id=?").run(id); + } + + function applyWire(change) { + const spec = WIRE_TABLES[change.table]; + if (!spec) return { accepted: 0, stale: 0, invalid: 1 }; + const rowUuid = String(change.row_uuid || ''); + const incoming = parseHlc(change.hlc); + if (!incoming || (change.op !== 'upsert' && change.op !== 'delete')) return { accepted: 0, stale: 0, invalid: 1 }; + const existing = db.prepare('SELECT id, hlc FROM ' + change.table + ' WHERE uuid=?').get(rowUuid); + if (existing) { + const e = parseHlc(existing.hlc); + if (e && !hlcNewer(incoming, e)) return { accepted: 0, stale: 1, invalid: 0 }; + } + let res; + try { + res = withTx(db, () => { + if (change.op === 'delete') { + if (existing) softDeleteById(change.table, existing.id); + touchOplog(db, change.table, rowUuid, 'delete', change.hlc); + return 1; + } + const data = change.data || {}; + const fv = {}; + for (const fk of Object.keys(spec.fks)) { + const uuidVal = data[fk.replace('_id', '_uuid')]; + fv[fk] = idOfUuid(spec.fks[fk], uuidVal, true); + } + const cols = []; + const vals = []; + for (const k of Object.keys(data)) { + if (['uuid', 'hlc', 'deleted_at'].includes(k)) continue; + if (k.endsWith('_uuid') && spec.fks[k.replace('_uuid', '_id')] !== undefined) continue; + cols.push(k); + vals.push(data[k]); + } + for (const fk of Object.keys(fv)) { cols.push(fk); vals.push(fv[fk]); } + cols.push('uuid', 'hlc', 'origin'); + vals.push(rowUuid, change.hlc, 'sync'); + if (change.table === 'footprint') { + cols.push('search_text'); + vals.push(buildSearchText(String(data.title || ''), String(data.description || ''), String(data.result || ''), data.tags || [], data.extras || {})); + } + if (existing) { + const setSql = cols.map(c => c + '=?').join(', '); + db.prepare('UPDATE ' + change.table + ' SET ' + setSql + ' WHERE id=?').run(...vals, existing.id); + } else { + db.prepare('INSERT INTO ' + change.table + ' (' + cols.join(',') + ') VALUES (' + cols.map(() => '?').join(',') + ')').run(...vals); + } + touchOplog(db, change.table, rowUuid, 'upsert', change.hlc); + return 1; + }); + } catch (ex) { + throw ex; + } + return res === 1 ? { accepted: 1, stale: 0, invalid: 0 } : { accepted: 0, stale: 0, invalid: 0 }; + } + + function applyWireBatch(changes) { + let accepted = 0; let stale = 0; let invalid = 0; + for (const ch of (changes || [])) { + const r = applyWire(ch); + accepted += r.accepted; stale += r.stale; invalid += r.invalid; + } + return { accepted, stale, invalid }; + } + + function pullWire(cursorRaw, limitRaw) { + const cursor = Math.max(0, Number(cursorRaw) || 0); + const limit = Math.min(500, Math.max(1, Number(limitRaw) || 200)); + const opRows = db.prepare('SELECT id, table_name, row_uuid, op, hlc FROM sync_oplog WHERE id>? ORDER BY id LIMIT ?').all(cursor, limit); + const changes = []; + for (const op of opRows) { + if (!WIRE_TABLES[op.table_name]) continue; + if (op.op === 'delete') { + changes.push({ seq: op.id, table: op.table_name, row_uuid: op.row_uuid, op: 'delete', hlc: op.hlc, data: null }); + continue; + } + const row = db.prepare('SELECT * FROM ' + op.table_name + ' WHERE uuid=?').get(op.row_uuid); + if (!row) continue; + changes.push({ seq: op.id, table: op.table_name, row_uuid: op.row_uuid, op: 'upsert', hlc: op.hlc, data: serializeRowOf(op.table_name)(row) }); + } + const maxSeq = db.prepare('SELECT COALESCE(MAX(id),0) AS m FROM sync_oplog').get().m; + const hasMore = opRows.length === limit; + return { + changes, + cursor: hasMore && opRows.length ? opRows[opRows.length - 1].id : Math.max(cursor, maxSeq), + has_more: hasMore + }; + } + + return { handleCommand, getDb, pullWire, applyWireBatch }; } // ---------- 默认单例(开发桥接 / seed 兼容) ---------- @@ -999,4 +1183,4 @@ export function getDb() { export function handleCommand(cmd, payload) { return getDefault().handleCommand(cmd, payload); -} +} \ No newline at end of file diff --git a/sync-server/src/index.mjs b/sync-server/src/index.mjs index 9a074dc..f4184d1 100644 --- a/sync-server/src/index.mjs +++ b/sync-server/src/index.mjs @@ -126,10 +126,11 @@ const server = http.createServer(async (req, res) => { if (req.method === 'POST' && route === '/sync/push') { const body = await readJson(req); - return send(200, true, push(auth.user_id, body.changes)); + // wire 变更直接应用到该用户档案库(网页版立即可见);generate_changes 由档案库 oplog 驱动 + return send(200, true, commandsFor(auth.user_id).applyWireBatch(body.changes)); } if (req.method === 'GET' && route === '/sync/pull') { - return send(200, true, pull(auth.user_id, url.searchParams.get('cursor'), url.searchParams.get('limit'))); + return send(200, true, commandsFor(auth.user_id).pullWire(url.searchParams.get('cursor'), url.searchParams.get('limit'))); } if (req.method === 'POST' && route === '/sync/attachment') { const body = await readJson(req);