Files
Hankan/shared/sync-protocol.mjs
MechaCat02 57fd6e5087 feat(learn): the reference screens as the reworked artifact has them
읽기 연습. Each mode opens with what the round is and a Start button; the
round is timed, and a finished one reports accuracy and time, keeps the
best per mode and says when it was beaten. In sound mode the written
spelling is among the options — reading it as written is the habit the
drill exists to break, so it is the distractor that matters. Answers stay
marked 450ms when right, 900ms when a rule is shown, 1400ms when wrong.
The best rounds sync (trainer.drill), merged mode by mode: the higher
accuracy, then the faster time.

문법. An All chip, a count on every category, 반말 selected first — it is
what manhwa speech is made of — and each row names its category, so the
list still reads under All.

활용 연습. Words come in random order, not the pool's; the running score
comes back after a reload; a wrong answer shows what was typed beside what
it should have been, then the rule. The pool adds the roadmap's own verbs
and adjectives to the deck's. A form lib/conjugation.js cannot build no
longer leaves the Check button stuck.

문장. The artifact's lesson on the ending word and the 서술어 legend, a count
on each level, and every sentence's place in review — as far along as its
least-known chunk, since the chunks are the cards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:12:05 +02:00

149 lines
5.7 KiB
JavaScript

/* 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",
"trainer.drill",
]);
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.<scope>`,
* 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);
}