Row-level last-write-wins on updated_at, cursor-based on a server-assigned change_seq. The schema was built for this in step 1, so the work here is the three things it did not yet have. Tombstones (migration 4). Row-level sync cannot express a delete: with the row gone there is nothing to compare timestamps against, so the other device pushes its still-live copy back and the row silently returns. Every delete path now writes a tombstone inside the same transaction. The wire format lives in shared/sync-protocol.mjs and is imported by both sides, so there is one definition rather than two that drift. It carries the syncable-meta allowlist, which is the load-bearing part: meta mixes the learner's preferences with bookkeeping that describes one install, and replicating dict.loadedBands would tell a phone that had loaded bands 0-2 it holds every row the desktop has — the word rail would then fail to find words it believes are present. The sync loop pushes first, then pages the pull. Two details it would be easy to get wrong, both commented at their site: - The pull cursor advances only as rows are applied, never from the push response. The server's newest change_seq includes rows this device has not seen; adopting it skips them permanently, and nothing ever asks for that range again. - Pulled rows advance sync.pushedAt too, bounded by the instant the sync started. Otherwise they look like local edits and get pushed straight back, and an edit made during the sync is not swept up with them. Seeded rows carry updated_at = 0, so a fresh device is never dirty and can never win a conflict — the artifact's clobbering bug stays unrepresentable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
440 lines
18 KiB
TypeScript
440 lines
18 KiB
TypeScript
/* Every mutation of user data goes through here, in one of two families.
|
|
|
|
════ THE TIMESTAMP RULE ════
|
|
|
|
The artifact had a sync bug: a fresh device stamped its own empty default
|
|
state with the current time, which made it look NEWER than the server's
|
|
real history, and last-write-wins duly clobbered months of progress with
|
|
an empty seed.
|
|
|
|
The rule that prevents it: SEEDED OR DEFAULTED STATE NEVER CARRIES A WRITE
|
|
TIMESTAMP. Only a genuine user edit stamps the clock.
|
|
|
|
Three layers enforce it:
|
|
|
|
1. the DDL — `updated_at INTEGER NOT NULL DEFAULT 0` on every syncable
|
|
table, so forgetting the column is the safe failure, not the unsafe
|
|
one (see migrations.ts);
|
|
2. this file — seedX() helpers never mention updated_at; editX() helpers
|
|
always set it from now();
|
|
3. lint + test — Date.now() is banned everywhere under src/db/ except
|
|
here, and test/db/conformance.ts asserts a freshly seeded database
|
|
has no non-zero updated_at anywhere.
|
|
|
|
Sync is out of scope for this pass. The point is that when it lands, the
|
|
schema already cannot express the bug. */
|
|
|
|
import type { Db, Params } from "./types.js";
|
|
import type { Card, Grade } from "@lib/srs.js";
|
|
|
|
/* ─────────────────────────────────────────────────────────────────────
|
|
The clock. This is the ONLY place in src/db/ that reads it.
|
|
───────────────────────────────────────────────────────────────────── */
|
|
|
|
/** Wall-clock milliseconds, for stamping a genuine user edit. */
|
|
export const now = (): number => Date.now();
|
|
|
|
/**
|
|
* Record that a row was deleted.
|
|
*
|
|
* A delete leaves nothing for last-write-wins to compare against, so without
|
|
* this the other device would push its still-live copy back and the row
|
|
* would silently return. Always written inside the same transaction as the
|
|
* delete it describes.
|
|
*/
|
|
async function tombstone(db: Db, tbl: string, pk: string | number): Promise<void> {
|
|
await db.run(
|
|
`INSERT INTO tombstone (tbl, pk, updated_at) VALUES (?, ?, ?)
|
|
ON CONFLICT(tbl, pk) DO UPDATE SET updated_at = excluded.updated_at`,
|
|
[tbl, String(pk), now()],
|
|
);
|
|
}
|
|
|
|
/* ═════════════════════════════════════════════════════════════════════
|
|
SEED WRITES — defaults, first-run state, anything the user did not do.
|
|
None of these may set updated_at; the column default of 0 is the point.
|
|
═════════════════════════════════════════════════════════════════════ */
|
|
|
|
/** First-run roadmap position. Unit 1.1 is where everyone starts. */
|
|
export async function seedProgress(db: Db, unitId: string): Promise<void> {
|
|
await db.run(
|
|
"INSERT OR IGNORE INTO progress (unit_id, state, confidence) VALUES (?, 'now', 0)",
|
|
[unitId],
|
|
);
|
|
}
|
|
|
|
/** A default preference or bookkeeping value. Does not overwrite a real one. */
|
|
export async function seedMeta(db: Db, k: string, v: string): Promise<void> {
|
|
await db.run("INSERT OR IGNORE INTO meta (k, v) VALUES (?, ?)", [k, v]);
|
|
}
|
|
|
|
/**
|
|
* A card the learner is assumed to already know, or a pre-scheduled one from
|
|
* the seed list. Deliberately unstamped: it is not something he did.
|
|
*/
|
|
export async function seedCard(db: Db, lemmaId: number, card: Card): Promise<void> {
|
|
await db.run(
|
|
`INSERT OR IGNORE INTO card (lemma_id, state, ease, interval, due, reps, lapses)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
[lemmaId, card.state, card.ease, card.interval, card.due, card.reps, card.lapses],
|
|
);
|
|
}
|
|
|
|
/** The tutor's opening turn, which the app supplies rather than the model. */
|
|
export async function seedChatTurn(db: Db, role: string, body: string, at: number): Promise<void> {
|
|
await db.run("INSERT INTO chat (role, body, created_at) VALUES (?, ?, ?)", [role, body, at]);
|
|
}
|
|
|
|
/* ═════════════════════════════════════════════════════════════════════
|
|
EDIT WRITES — a real action by the learner. Every one stamps the clock.
|
|
═════════════════════════════════════════════════════════════════════ */
|
|
|
|
/** Answering a card in the review overlay. */
|
|
export async function editCard(db: Db, lemmaId: number, card: Card): Promise<void> {
|
|
await db.run(
|
|
`INSERT INTO card (lemma_id, state, ease, interval, due, reps, lapses, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(lemma_id) DO UPDATE SET
|
|
state = excluded.state, ease = excluded.ease, interval = excluded.interval,
|
|
due = excluded.due, reps = excluded.reps, lapses = excluded.lapses,
|
|
updated_at = excluded.updated_at`,
|
|
[lemmaId, card.state, card.ease, card.interval, card.due, card.reps, card.lapses, now()],
|
|
);
|
|
}
|
|
|
|
/** Forgetting a card back to new, from the vocabulary tab. */
|
|
export async function editCardReset(db: Db, lemmaId: number): Promise<void> {
|
|
await db.tx(async (tx) => {
|
|
await tx.run("DELETE FROM card WHERE lemma_id = ?", [lemmaId]);
|
|
await tombstone(tx, "card", lemmaId);
|
|
});
|
|
}
|
|
|
|
/** The tutor's ::progress read, or the learner moving the unit by hand. */
|
|
export async function editUnitConfidence(
|
|
db: Db,
|
|
unitId: string,
|
|
confidence: number,
|
|
): Promise<void> {
|
|
await db.run(
|
|
`INSERT INTO progress (unit_id, state, confidence, updated_at)
|
|
VALUES (?, 'now', ?, ?)
|
|
ON CONFLICT(unit_id) DO UPDATE SET confidence = excluded.confidence,
|
|
updated_at = excluded.updated_at`,
|
|
[unitId, Math.max(0, Math.min(100, Math.round(confidence))), now()],
|
|
);
|
|
}
|
|
|
|
/** Marking a unit done / current / not-started. */
|
|
export async function editUnitState(
|
|
db: Db,
|
|
unitId: string,
|
|
state: "todo" | "now" | "done",
|
|
): Promise<void> {
|
|
await db.run(
|
|
`INSERT INTO progress (unit_id, state, confidence, updated_at)
|
|
VALUES (?, ?, 0, ?)
|
|
ON CONFLICT(unit_id) DO UPDATE SET state = excluded.state,
|
|
updated_at = excluded.updated_at`,
|
|
[unitId, state, now()],
|
|
);
|
|
}
|
|
|
|
/** A preference the learner changed. */
|
|
export async function editMeta(db: Db, k: string, v: string): Promise<void> {
|
|
await db.run(
|
|
`INSERT INTO meta (k, v, updated_at) VALUES (?, ?, ?)
|
|
ON CONFLICT(k) DO UPDATE SET v = excluded.v, updated_at = excluded.updated_at`,
|
|
[k, v, now()],
|
|
);
|
|
}
|
|
|
|
/** A turn the learner sent, or a reply he received. */
|
|
export async function editChatTurn(db: Db, role: string, body: string): Promise<void> {
|
|
const t = now();
|
|
await db.run(
|
|
"INSERT INTO chat (role, body, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
|
[role, body, t, t],
|
|
);
|
|
}
|
|
|
|
/** Wipe the transcript. Progress and cards are untouched. */
|
|
export async function editChatClear(db: Db): Promise<void> {
|
|
await db.tx(async (tx) => {
|
|
const rows = await tx.all<{ id: number }>("SELECT id FROM chat");
|
|
await tx.run("DELETE FROM chat");
|
|
for (const r of rows) await tombstone(tx, "chat", r.id);
|
|
});
|
|
}
|
|
|
|
/** Trim the transcript. The artifact kept the last 26 turns. */
|
|
export async function editChatTrim(db: Db, keep: number): Promise<void> {
|
|
await db.tx(async (tx) => {
|
|
const doomed = await tx.all<{ id: number }>(
|
|
`SELECT id FROM chat WHERE id NOT IN (SELECT id FROM chat ORDER BY id DESC LIMIT ?)`,
|
|
[keep],
|
|
);
|
|
if (!doomed.length) return;
|
|
await tx.run(
|
|
`DELETE FROM chat WHERE id NOT IN (SELECT id FROM chat ORDER BY id DESC LIMIT ?)`,
|
|
[keep],
|
|
);
|
|
for (const r of doomed) await tombstone(tx, "chat", r.id);
|
|
});
|
|
}
|
|
|
|
/** Study happened today: reviews, correct answers, drill answers. */
|
|
export async function editStudyLog(
|
|
db: Db,
|
|
day: number,
|
|
delta: { reviews?: number; correct?: number; drills?: number },
|
|
): Promise<void> {
|
|
const { reviews = 0, correct = 0, drills = 0 } = delta;
|
|
await db.run(
|
|
`INSERT INTO study_log (day, reviews, correct, drills, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(day) DO UPDATE SET
|
|
reviews = study_log.reviews + excluded.reviews,
|
|
correct = study_log.correct + excluded.correct,
|
|
drills = study_log.drills + excluded.drills,
|
|
updated_at = excluded.updated_at`,
|
|
[day, reviews, correct, drills, now()],
|
|
);
|
|
}
|
|
|
|
/** He looked a word up in the rail. Feeds the persistent underline. */
|
|
export async function editPeek(db: Db, form: string): Promise<void> {
|
|
await db.run(
|
|
`INSERT INTO peek (form, count, updated_at) VALUES (?, 1, ?)
|
|
ON CONFLICT(form) DO UPDATE SET count = peek.count + 1,
|
|
updated_at = excluded.updated_at`,
|
|
[form, now()],
|
|
);
|
|
}
|
|
|
|
/* ═════════════════════════════════════════════════════════════════════
|
|
CUSTOM WORDS — the learner's own additions.
|
|
|
|
These live in `lemma` alongside the shipped dictionary, but their ids
|
|
come from a reserved range far above anything the build emits. Band ids
|
|
are assigned sequentially from 1, so a custom word placed in that range
|
|
would be silently overwritten the next time `npm run dict:build` runs and
|
|
the band files are reloaded. The reserved range is what keeps the
|
|
learner's own vocabulary from being collateral damage of a dictionary
|
|
rebuild.
|
|
═════════════════════════════════════════════════════════════════════ */
|
|
|
|
/** First id available to custom words. The build never emits ids this high. */
|
|
export const CUSTOM_LEMMA_BASE = 10_000_000;
|
|
|
|
export interface CustomWord {
|
|
headword: string;
|
|
gloss: string;
|
|
pos: string;
|
|
}
|
|
|
|
export interface AddedWord {
|
|
lemmaId: number;
|
|
/** False when the dictionary already had this (headword, pos). */
|
|
created: boolean;
|
|
}
|
|
|
|
/**
|
|
* Add a word of the learner's own.
|
|
*
|
|
* `lemma` is UNIQUE on (headword, pos), and the shipped dictionary is large —
|
|
* so "add a word" will regularly collide with one already in it. That is not
|
|
* an error and must not surface as one: the intent is "I want to study this",
|
|
* which is satisfied by giving the existing entry a card. Only a genuinely
|
|
* new word creates a row.
|
|
*/
|
|
export async function editAddCustomWord(db: Db, word: CustomWord): Promise<AddedWord> {
|
|
const headword = word.headword.trim();
|
|
const gloss = word.gloss.trim();
|
|
|
|
return db.tx(async (tx) => {
|
|
const existing = await tx.get<{ id: number }>(
|
|
"SELECT id FROM lemma WHERE headword = ? AND pos = ?",
|
|
[headword, word.pos],
|
|
);
|
|
|
|
if (existing) {
|
|
// Already known — just make sure it is studiable. Its gloss stays the
|
|
// dictionary's; overwriting curated content from a text field would be
|
|
// a poor trade.
|
|
await tx.run(
|
|
`INSERT INTO card (lemma_id, state, ease, interval, due, reps, lapses, updated_at)
|
|
VALUES (?, 0, 2.5, 0, 0, 0, 0, ?)
|
|
ON CONFLICT(lemma_id) DO NOTHING`,
|
|
[existing.id, now()],
|
|
);
|
|
return { lemmaId: existing.id, created: false };
|
|
}
|
|
|
|
const top = await tx.get<{ id: number | null }>(
|
|
"SELECT max(id) AS id FROM lemma WHERE id >= ?",
|
|
[CUSTOM_LEMMA_BASE],
|
|
);
|
|
const id = Math.max(CUSTOM_LEMMA_BASE, (top?.id ?? 0) + 1);
|
|
|
|
await tx.run(
|
|
`INSERT INTO lemma (id, headword, pos, freq_rank, level, gloss_en, gloss_ko,
|
|
unit_band, source)
|
|
VALUES (?, ?, ?, NULL, NULL, ?, '', 0, 'custom')`,
|
|
[id, headword, word.pos, gloss],
|
|
);
|
|
await tx.run("INSERT OR REPLACE INTO surface (form, lemma_id, analysis) VALUES (?, ?, ?)", [
|
|
headword,
|
|
id,
|
|
"headword, custom",
|
|
]);
|
|
await tx.run(
|
|
`INSERT INTO card (lemma_id, state, ease, interval, due, reps, lapses, updated_at)
|
|
VALUES (?, 0, 2.5, 0, 0, 0, 0, ?)`,
|
|
[id, now()],
|
|
);
|
|
return { lemmaId: id, created: true };
|
|
});
|
|
}
|
|
|
|
export async function editRemoveCustomWord(db: Db, lemmaId: number): Promise<void> {
|
|
if (lemmaId < CUSTOM_LEMMA_BASE) return; // never touch shipped dictionary rows
|
|
await db.tx(async (tx) => {
|
|
await tx.run("DELETE FROM card WHERE lemma_id = ?", [lemmaId]);
|
|
await tx.run("DELETE FROM surface WHERE lemma_id = ?", [lemmaId]);
|
|
await tx.run("DELETE FROM lemma WHERE id = ?", [lemmaId]);
|
|
await tombstone(tx, "card", lemmaId);
|
|
});
|
|
}
|
|
|
|
/* ═════════════════════════════════════════════════════════════════════
|
|
RESET — deliberately destructive, so it is spelled out here rather than
|
|
assembled ad hoc at a call site.
|
|
|
|
Neither scope touches `lemma` or `surface` for shipped words: the
|
|
dictionary is reference data, rebuildable from the assets, and wiping it
|
|
would leave the app unable to gloss anything until the bands reloaded.
|
|
═════════════════════════════════════════════════════════════════════ */
|
|
|
|
export type ResetScope = "roadmap" | "everything";
|
|
|
|
export async function editReset(db: Db, scope: ResetScope): Promise<void> {
|
|
await db.tx(async (tx) => {
|
|
// Tombstone before deleting, while the keys are still readable — a reset
|
|
// must propagate, or the next pull restores everything it just erased.
|
|
for (const r of await tx.all<{ unit_id: string }>("SELECT unit_id FROM progress"))
|
|
await tombstone(tx, "progress", r.unit_id);
|
|
for (const r of await tx.all<{ id: number }>("SELECT id FROM chat"))
|
|
await tombstone(tx, "chat", r.id);
|
|
|
|
await tx.run("DELETE FROM progress");
|
|
await tx.run("DELETE FROM chat");
|
|
|
|
if (scope === "everything") {
|
|
for (const r of await tx.all<{ lemma_id: number }>("SELECT lemma_id FROM card"))
|
|
await tombstone(tx, "card", r.lemma_id);
|
|
for (const r of await tx.all<{ day: number }>("SELECT day FROM study_log"))
|
|
await tombstone(tx, "study_log", r.day);
|
|
for (const r of await tx.all<{ form: string }>("SELECT form FROM peek"))
|
|
await tombstone(tx, "peek", r.form);
|
|
|
|
await tx.run("DELETE FROM card");
|
|
await tx.run("DELETE FROM study_log");
|
|
await tx.run("DELETE FROM peek");
|
|
await tx.run("DELETE FROM surface WHERE lemma_id >= ?", [CUSTOM_LEMMA_BASE]);
|
|
await tx.run("DELETE FROM lemma WHERE id >= ?", [CUSTOM_LEMMA_BASE]);
|
|
// Preferences, grammar flags and notes, trainer score, and the
|
|
// known-words seed marker. Device bookkeeping (schema_version,
|
|
// dict.*) is left alone — it describes this install, not the learner.
|
|
await tx.run(
|
|
`DELETE FROM meta WHERE k LIKE 'prefs.%' OR k LIKE 'grammar.%'
|
|
OR k LIKE 'trainer.%' OR k LIKE 'seed.%'`,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ═════════════════════════════════════════════════════════════════════
|
|
DICTIONARY WRITES — reference data from the shipped band files.
|
|
Not user data, never synced, and rebuildable from the assets, so these
|
|
tables carry no updated_at at all.
|
|
═════════════════════════════════════════════════════════════════════ */
|
|
|
|
export interface LemmaRow {
|
|
id: number;
|
|
headword: string;
|
|
pos: string;
|
|
freq_rank: number | null;
|
|
level: string | null;
|
|
gloss_en: string;
|
|
gloss_ko: string;
|
|
unit_band: number;
|
|
source: string;
|
|
}
|
|
|
|
export interface SurfaceRow {
|
|
form: string;
|
|
lemma_id: number;
|
|
analysis: string;
|
|
}
|
|
|
|
/**
|
|
* Bound-parameter ceiling for a single statement.
|
|
*
|
|
* sqlite-wasm is built with the modern SQLITE_MAX_VARIABLE_NUMBER of 32766,
|
|
* but Android links the platform's SQLite, which has historically capped it
|
|
* at 999. A multi-row INSERT sized for the browser therefore fails outright
|
|
* on the phone — and band loading is the first thing the app does, so it
|
|
* fails at first launch. Size every batch for the smaller limit.
|
|
*/
|
|
const MAX_PARAMS = 900;
|
|
|
|
/** Rows per statement, given how many columns each row binds. */
|
|
const chunkFor = (columns: number) => Math.max(1, Math.floor(MAX_PARAMS / columns));
|
|
|
|
/** Insert one band's rows, batched to stay under the parameter ceiling. */
|
|
export async function insertBand(
|
|
db: Db,
|
|
lemmas: LemmaRow[],
|
|
surfaces: SurfaceRow[],
|
|
): Promise<void> {
|
|
await db.tx(async (tx) => {
|
|
const lemmaChunk = chunkFor(9);
|
|
for (let i = 0; i < lemmas.length; i += lemmaChunk) {
|
|
const slice = lemmas.slice(i, i + lemmaChunk);
|
|
const values = slice.map(() => "(?,?,?,?,?,?,?,?,?)").join(",");
|
|
const params: Params = slice.flatMap((l) => [
|
|
l.id,
|
|
l.headword,
|
|
l.pos,
|
|
l.freq_rank,
|
|
l.level,
|
|
l.gloss_en,
|
|
l.gloss_ko,
|
|
l.unit_band,
|
|
l.source,
|
|
]);
|
|
await tx.run(
|
|
`INSERT OR REPLACE INTO lemma
|
|
(id, headword, pos, freq_rank, level, gloss_en, gloss_ko, unit_band, source)
|
|
VALUES ${values}`,
|
|
params,
|
|
);
|
|
}
|
|
|
|
const surfaceChunk = chunkFor(3);
|
|
for (let i = 0; i < surfaces.length; i += surfaceChunk) {
|
|
const slice = surfaces.slice(i, i + surfaceChunk);
|
|
const values = slice.map(() => "(?,?,?)").join(",");
|
|
const params: Params = slice.flatMap((s) => [s.form, s.lemma_id, s.analysis]);
|
|
await tx.run(
|
|
`INSERT OR REPLACE INTO surface (form, lemma_id, analysis) VALUES ${values}`,
|
|
params,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
/* Re-exported so callers can grade without importing srs separately. */
|
|
export type { Card, Grade };
|