/* 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", "done", "confidence", "answers", "note"] }, 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;