diff --git a/app/src/db/migrations.ts b/app/src/db/migrations.ts index 41c0529..4f2364c 100644 --- a/app/src/db/migrations.ts +++ b/app/src/db/migrations.ts @@ -133,6 +133,22 @@ export const MIGRATIONS: Migration[] = [ CREATE INDEX IF NOT EXISTS tombstone_updated ON tombstone(updated_at); `, }, + { + id: 5, + name: "drop the known-words seed — a new deck starts empty", + sql: /* sql */ ` + -- The seed pre-marked ~30 headwords secure on first run. It made a + -- fresh install look like work already done, and because editReset + -- also cleared its 'seed.known' guard, a full wipe re-seeded on the + -- next boot: the one thing a wipe is for, undone. + -- + -- seedCard() is the only writer that leaves updated_at = 0 on a card, + -- and the seed was its only caller, so this deletes exactly the seeded + -- rows and nothing the learner graded. Genuine reviews stamp the clock. + DELETE FROM card WHERE updated_at = 0; + DELETE FROM meta WHERE k = 'seed.known'; + `, + }, ]; export const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]!.id; diff --git a/app/src/db/writes.ts b/app/src/db/writes.ts index 55fc144..1af3770 100644 --- a/app/src/db/writes.ts +++ b/app/src/db/writes.ts @@ -343,12 +343,12 @@ export async function editReset(db: Db, scope: ResetScope): Promise { 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. + // Preferences, grammar flags and notes, and the trainer score. + // 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.%'`, + OR k LIKE 'trainer.%'`, ); } }); diff --git a/app/src/domain/seed-known.ts b/app/src/domain/seed-known.ts deleted file mode 100644 index 2b8b8db..0000000 --- a/app/src/domain/seed-known.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* The words the learner already had before the app existed. - - Carried over from the artifact, which pre-marked these secure on first - run so the first review session was not thirty cards he could already - read. The list is his, not a curriculum artefact — it came from the - progress summary he wrote when the artifact was built. - - Applied through seedCard(), which leaves updated_at = 0. That matters - twice over: it matches the artifact's own stampInit() behaviour, and it - keeps the seed invisible to sync — a fresh device seeding itself must - never look newer than the server's real history. */ - -import type { Db } from "../db/types.js"; -import { seedCard, seedMeta } from "../db/writes.js"; -import { markKnown } from "@lib/srs.js"; - -/** Verbatim from the artifact's SEED_KNOWN. */ -export const SEED_KNOWN = [ - "나", "저", "너", "우리", "이", "그", "뭐", "왜", "누구", "어디", - "언제", "친구", "물", "밥", "책", "학교", "가다", "오다", "먹다", "마시다", - "좋다", "싫다", "크다", "작다", "슬프다", "진짜?", "잠깐만", "안 돼", "좋아", "싫어", -] as const; - -const FLAG = "seed.known"; - -/** - * Mark the seed words secure, once. Returns how many matched a lemma — - * some are phrases the dictionary may not carry as headwords, and a miss - * is not an error. - */ -export async function seedKnownWords(db: Db, today: number): Promise { - const done = await db.get<{ v: string }>("SELECT v FROM meta WHERE k = ?", [FLAG]); - if (done) return 0; - - const holes = SEED_KNOWN.map(() => "?").join(","); - const rows = await db.all<{ id: number }>( - `SELECT id FROM lemma WHERE headword IN (${holes}) AND source != 'custom'`, - [...SEED_KNOWN], - ); - - const card = markKnown(today); - for (const row of rows) await seedCard(db, row.id, card); - - // Bookkeeping, not a user edit: unstamped, like the cards it guards. - await seedMeta(db, FLAG, String(rows.length)); - return rows.length; -} diff --git a/app/src/state/store.tsx b/app/src/state/store.tsx index 4a0d5ed..3e92404 100644 --- a/app/src/state/store.tsx +++ b/app/src/state/store.tsx @@ -24,7 +24,6 @@ import { bandForUnit } from "@shared/bands.mjs"; import { ensureBands, loadManifest, recordProvenance, type DictManifest } from "../domain/dictionary.js"; import { initProgress, readProgress } from "../domain/progress.js"; -import { seedKnownWords } from "../domain/seed-known.js"; import { clearServerConfig, readServerConfig, @@ -173,7 +172,6 @@ export function StoreProvider({ // Needs the band rows present to match headwords, so it runs after // ensureBands. Seeded, so it carries no write timestamp. - await seedKnownWords(db, dayNumber()); const rows = await db.all<{ k: string; v: string }>( "SELECT k, v FROM meta WHERE k LIKE 'prefs.%'", diff --git a/test/domain/reset.test.ts b/test/domain/reset.test.ts new file mode 100644 index 0000000..96ec9c4 --- /dev/null +++ b/test/domain/reset.test.ts @@ -0,0 +1,137 @@ +/* What a full wipe has to leave behind: nothing the learner did. + + The reported failure was that "Reset everything" did not clear learned + words. It did delete them — and then also deleted the `seed.known` guard, + so the next boot re-seeded ~47 cards from the known-words list and the + deck looked untouched. The seed is gone now; these tests pin the wipe so + a future seed cannot reintroduce the same shape. */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { SqliteWasmDb } from "@app/db/sqlite-wasm-core.js"; +import { migrate } from "@app/db/migrate.js"; +import { + editCard, + editChatTurn, + editMeta, + editReset, + editStudyLog, + insertBand, + seedCard, + type LemmaRow, +} from "@app/db/writes.js"; +import { newCard, markKnown } from "@lib/srs.js"; +import type { Db } from "@app/db/types.js"; + +let db: Db; +beforeEach(async () => { + db = await SqliteWasmDb.open({ memory: true }); + await migrate(db); +}); +afterEach(async () => { + await db.close(); +}); + +const lemma = (id: number, headword: string): LemmaRow => ({ + id, + headword, + pos: "noun", + freq_rank: id, + level: null, + gloss_en: `gloss ${id}`, + gloss_ko: "", + unit_band: 0, + source: "curated", +}); + +const count = async (tbl: string): Promise => + (await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM ${tbl}`))!.n; + +async function aLearnerWithHistory(): Promise { + await insertBand(db, [lemma(1, "물"), lemma(2, "책"), lemma(3, "가다")], []); + await editCard(db, 1, markKnown(10)); + await editCard(db, 2, newCard()); + await editStudyLog(db, 10, { reviews: 4, correct: 3 }); + await editChatTurn(db, "user", "Start unit 1.1."); + await editChatTurn(db, "assistant", "좋아요."); + await editMeta(db, "prefs.focus", "particles"); + await editMeta(db, "grammar.learned", '["p1"]'); + await db.run("INSERT INTO progress (unit_id, confidence, updated_at) VALUES ('1.1', 40, 1)"); +} + +describe("editReset('everything')", () => { + it("leaves no cards, no study log, no transcript, no progress", async () => { + await aLearnerWithHistory(); + await editReset(db, "everything"); + + for (const tbl of ["card", "study_log", "chat", "progress", "peek"]) { + expect(await count(tbl), `${tbl} should be empty after a full wipe`).toBe(0); + } + }); + + it("clears the learner's preferences, grammar flags and notes", async () => { + await aLearnerWithHistory(); + await editReset(db, "everything"); + + const left = await db.all<{ k: string }>( + "SELECT k FROM meta WHERE k LIKE 'prefs.%' OR k LIKE 'grammar.%' OR k LIKE 'trainer.%'", + ); + expect(left).toEqual([]); + }); + + it("keeps the dictionary, which is reference data and not the learner's", async () => { + await aLearnerWithHistory(); + await editReset(db, "everything"); + expect(await count("lemma")).toBe(3); + }); + + it("tombstones what it deleted, so a sync cannot restore it", async () => { + await aLearnerWithHistory(); + await editReset(db, "everything"); + + const graves = await db.all<{ tbl: string }>("SELECT DISTINCT tbl FROM tombstone"); + const tables = graves.map((g) => g.tbl).sort(); + expect(tables).toContain("card"); + expect(tables).toContain("chat"); + expect(tables).toContain("progress"); + expect(tables).toContain("study_log"); + }); + + it("does not survive a reboot: nothing reseeds the deck", async () => { + await aLearnerWithHistory(); + await editReset(db, "everything"); + // Re-running the migrations is what a restart does. + await migrate(db); + expect(await count("card")).toBe(0); + }); +}); + +describe("editReset('roadmap')", () => { + it("clears the roadmap and transcript but keeps the deck and history", async () => { + await aLearnerWithHistory(); + await editReset(db, "roadmap"); + + expect(await count("progress")).toBe(0); + expect(await count("chat")).toBe(0); + expect(await count("card")).toBe(2); + expect(await count("study_log")).toBe(1); + }); +}); + +describe("migration 5 — the known-words seed is gone", () => { + it("removes an existing install's seeded cards and keeps graded ones", async () => { + await insertBand(db, [lemma(1, "물"), lemma(2, "책")], []); + await seedCard(db, 1, markKnown(10)); // what the seed used to write + await editCard(db, 2, newCard()); // something the learner actually did + await db.run("INSERT INTO meta (k, v, updated_at) VALUES ('seed.known', '47', 0)"); + + // Wind the recorded version back to 4 so migrate() really runs 5. + await db.run("INSERT OR REPLACE INTO meta (k, v, updated_at) VALUES ('schema_version','4',0)"); + const { from, to } = await migrate(db); + expect(from).toBe(4); + expect(to).toBe(5); + + const rows = await db.all<{ lemma_id: number }>("SELECT lemma_id FROM card"); + expect(rows.map((r) => r.lemma_id)).toEqual([2]); + expect(await db.get("SELECT v FROM meta WHERE k = 'seed.known'")).toBeUndefined(); + }); +});