feat(sync): tombstones, the wire protocol, and the client loop

Row-level last-write-wins on updated_at, cursor-based on a server-assigned
change_seq. The schema was built for this in step 1, so the work here is
the three things it did not yet have.

Tombstones (migration 4). Row-level sync cannot express a delete: with the
row gone there is nothing to compare timestamps against, so the other
device pushes its still-live copy back and the row silently returns. Every
delete path now writes a tombstone inside the same transaction.

The wire format lives in shared/sync-protocol.mjs and is imported by both
sides, so there is one definition rather than two that drift. It carries
the syncable-meta allowlist, which is the load-bearing part: meta mixes the
learner's preferences with bookkeeping that describes one install, and
replicating dict.loadedBands would tell a phone that had loaded bands 0-2
it holds every row the desktop has — the word rail would then fail to find
words it believes are present.

The sync loop pushes first, then pages the pull. Two details it would be
easy to get wrong, both commented at their site:

- The pull cursor advances only as rows are applied, never from the push
  response. The server's newest change_seq includes rows this device has
  not seen; adopting it skips them permanently, and nothing ever asks for
  that range again.
- Pulled rows advance sync.pushedAt too, bounded by the instant the sync
  started. Otherwise they look like local edits and get pushed straight
  back, and an edit made during the sync is not swept up with them.

Seeded rows carry updated_at = 0, so a fresh device is never dirty and can
never win a conflict — the artifact's clobbering bug stays unrepresentable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-08 19:57:32 +02:00
parent f49ed388f4
commit f447f881c0
8 changed files with 643 additions and 8 deletions

69
shared/sync-protocol.mjs Normal file
View File

@@ -0,0 +1,69 @@
/* The sync wire format, defined once and imported by both sides.
Row-level, last-write-wins on `updated_at`, cursor-based on a
server-assigned `change_seq`. One user, so the loser of a conflict is at
worst one SRS grade — LWW is adequate, and a merge policy would be
over-engineering.
THE RULE THAT MATTERS: seeded and defaulted rows carry updated_at = 0.
The artifact's sync bug was a fresh device stamping its own empty state
as newer than the server's real history and clobbering it. Here a seed
row can never be dirty (0 is never greater than any watermark) and can
never win a conflict (0 is never greater than any timestamp). The bug is
not avoided, it is unrepresentable. */
/** Tables that sync, and the columns forming each primary key. */
export const SYNC_TABLES = {
card: {
pk: ["lemma_id"],
cols: ["lemma_id", "state", "ease", "interval", "due", "reps", "lapses"],
},
progress: { pk: ["unit_id"], cols: ["unit_id", "state", "confidence"] },
chat: { pk: ["id"], cols: ["id", "role", "body", "created_at"] },
meta: { pk: ["k"], cols: ["k", "v"] },
study_log: { pk: ["day"], cols: ["day", "reviews", "correct", "drills"] },
peek: { pk: ["form"], cols: ["form", "count"] },
};
export const SYNC_TABLE_NAMES = Object.keys(SYNC_TABLES);
/** Primary-key columns for a table, or null if we do not sync it. */
export const pkFor = (tbl) =>
Object.prototype.hasOwnProperty.call(SYNC_TABLES, tbl) ? SYNC_TABLES[tbl].pk : null;
/**
* `meta` mixes the learner's data with bookkeeping that describes one
* install. Only the former may cross the wire.
*
* `dict.loadedBands` is the dangerous one: replicating it would tell a
* phone that had loaded bands 0-2 that it holds every row the desktop has,
* and the word rail would then fail to find words it believes are present.
* `schema_version` would be worse — a device could be told it has run a
* migration it has not. `server.*` holds this device's endpoint and bearer
* token: syncing a token through the endpoint it authenticates would be
* circular, and a base URL is network-specific.
*/
const SYNCABLE_META_EXACT = new Set(["grammar.learned", "grammar.notes", "trainer.conjugation"]);
const SYNCABLE_META_PREFIX = ["prefs."];
export function isSyncableMetaKey(key) {
if (SYNCABLE_META_EXACT.has(key)) return true;
return SYNCABLE_META_PREFIX.some((p) => key.startsWith(p));
}
/** Stable string key for a row, used to match it across devices. */
export const rowKey = (table, row) => SYNC_TABLES[table].pk.map((c) => String(row[c])).join(" ");
/** Rows a device may send: dirty since its last successful push. */
export const isDirty = (row, pushedAt) => Number(row.updated_at) > Number(pushedAt);
/**
* Last-write-wins. Strictly greater, so equal timestamps leave the local row
* alone — a tie means both sides already agree, or the clocks are close
* enough that flapping would be worse than either outcome.
*/
export const incomingWins = (incomingUpdatedAt, localUpdatedAt) =>
Number(incomingUpdatedAt) > Number(localUpdatedAt ?? -1);
/** Maximum rows in one push or pull page. */
export const PAGE_SIZE = 500;