Three storage changes the reworked app needs, as migration 8. Progress. "Which unit is current" was a 'now' state on each unit's row. Two devices that advanced could leave two of them, and leaving a finished unit through the roadmap panel wrote it back to 'todo' — goToUnit un-finished work. Where he is now lives in one place, meta road.unit; a unit's row records only what is true of that unit: done, confidence, and room for the answer count and the tutor's note that earned progress needs. The migration carries the most recently written 'now' row across with its own stamp. Chat. Turn ids were INTEGER PRIMARY KEY — max+1 on whichever device wrote them, restarting at 1 after a clear — so two devices continuing a lesson both wrote turn 201 and sync treated two different turns as one row. Ids are UUIDv7 now, and the transcript is ordered by (created_at, id). Existing turns become legacy:<device>:<n>, zero-padded so turns sharing a timestamp keep the order they were written in; their tombstones are renamed with them. The learner model gets its tables: evidence (lib/srs.js's record, plus the rounds of the first and last CORRECT answer, which PORT.md measures and lib does not), confusion, and phase_ledger for the 다지기 checklist, with a confirmed flag so putting an item back is an edit rather than a delete. A roadmap reset now clears the ledger and road.*; a full wipe also clears evidence, confusions and learner.* — the artifact's wipe left its round counter and confusion list behind. What a learner knows about words survives a roadmap-only reset, as it should. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
70 lines
3.1 KiB
JavaScript
70 lines
3.1 KiB
JavaScript
/* 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;
|