/* The sync wire format, defined once and imported by both sides. Protocol 2. Row-level and cursor-based on a server-assigned change_seq, as PORT.md specifies — and ordered by that counter, never by a clock. ════ THE THREE GATES ════════════════════════════════════════════════ The artifact lost a week of a real user's work without these, and the port's first sync broke all three. In short (lib/sync.js has the long version, applied there to whole documents): 1. HYDRATION. A device pulls everything the server holds before it may push anything. A laptop with a week-old copy used to boot, re-stamp that copy through a migration, and push it over the phone's week. 2. A COUNTER, NOT A CLOCK. Every row remembers the change_seq it last agreed with the server on (base_seq). The server applies a write only if that is still the row's current change_seq — compare-and-swap — and otherwise hands the current row back as a conflict. A phone clock four minutes fast used to decide which of two grades survived. 3. NO SILENT SHRINKING. A conflict is settled by what each copy HOLDS, not by which is newer (see app/src/sync/resolve.ts). Emptying things on purpose is explicit: a reset or a cleared lesson writes a marker every other device obeys, so the shrink is declared, never raced. Seeded and defaulted rows are never dirty, so they never leave the device: a fresh install cannot claim its empty defaults are news. */ export const PROTOCOL = 2; /** Header the client sends; the server refuses any other protocol. */ export const PROTOCOL_HEADER = "x-hankan-protocol"; /** Maximum rows in one push or pull page. */ export const PAGE_SIZE = 500; /** Tables that sync: their primary-key columns and the columns that travel. */ 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"] }, /* Counters, kept per device and summed on read. Two devices each adding a review to the same day would otherwise both write "reviews = n+1", and whichever landed second would erase the other's review. A device only ever writes its own row, so these never conflict at all. */ study_log: { pk: ["day", "device"], cols: ["day", "device", "reviews", "correct", "drills"] }, peek: { pk: ["form", "device"], cols: ["form", "device", "count"] }, custom_word: { pk: ["headword", "pos"], cols: ["headword", "pos", "gloss"] }, evidence: { pk: ["word"], cols: [ "word", "ok", "wrong", "lookups", "streak", "first_round", "last_round", "last_seen", "rounds", "first_ok_round", "last_ok_round", ], }, confusion: { pk: ["word"], cols: ["word", "mistook"] }, phase_ledger: { pk: ["phase", "kind", "item"], cols: ["phase", "kind", "item", "confirmed"] }, }; 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; /** * A row's key on the wire: its primary-key values as a JSON array. * * Protocol 1 joined them with a space and split them back on one, so a key * containing a space — the curriculum's 몇 명 and 신경 쓰다 can be peek * forms — came back as two values and the statement binding them threw. * The pull stopped at that row, on every device, permanently. */ export const encodePk = (tbl, row) => JSON.stringify(SYNC_TABLES[tbl].pk.map((c) => row[c])); export const decodePk = (pk) => JSON.parse(pk); /** * `meta` mixes the learner's data with bookkeeping that describes one * install. Only the former may cross the wire. * * `dict.*` is the dangerous one: replicating the loaded-band record would * tell a phone it holds rows it never downloaded. `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, and `sync.*` * is this device's own position in the conversation with the server. */ const SYNCABLE_META_EXACT = new Set(["grammar.learned", "grammar.notes", "trainer.conjugation"]); const SYNCABLE_META_PREFIX = ["prefs.", "road.", "learner.", "reset."]; export function isSyncableMetaKey(key) { if (SYNCABLE_META_EXACT.has(key)) return true; return SYNCABLE_META_PREFIX.some((p) => key.startsWith(p)); } /** * What each deliberate shrink empties. A marker meta key, `reset.`, * holds a counter; a device that sees it rise empties the same things * locally — including its own unsynced edits, because a reset it had not * yet heard of still wins over work done on the old data. */ export const RESET_SCOPES = { chat: { tables: ["chat"], meta: [] }, roadmap: { tables: ["progress", "chat", "phase_ledger"], meta: ["road."] }, everything: { tables: [ "progress", "chat", "phase_ledger", "card", "study_log", "peek", "custom_word", "evidence", "confusion", ], meta: ["road.", "prefs.", "grammar.", "trainer.", "learner."], }, }; /** Does a reset of this scope empty this row? */ export function resetCovers(scope, tbl, row) { const s = RESET_SCOPES[scope]; if (!s) return false; if (tbl === "meta") { const k = String(row.k ?? ""); return !k.startsWith("reset.") && s.meta.some((p) => k.startsWith(p)); } return s.tables.includes(tbl); }