읽기 연습. 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>
149 lines
5.7 KiB
JavaScript
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);
|
|
}
|