feat(app): drop the known-words seed, and make a full wipe actually wipe

The seed pre-marked the artifact's 30 headwords secure on first run. Two
reasons it is gone rather than merely disabled:

It was never 30 cards. The match was on headword, and homographs each
carry their own lemma, so 그 as pronoun and as determiner both matched —
47 rows for a 30-word list.

Worse, "Reset everything" deleted the cards and then deleted the
'seed.known' guard along with the other meta keys, so the next boot
re-seeded and the deck looked untouched. The one thing a wipe exists for,
undone by the wipe itself.

Migration 5 clears the seed from installs that already have it. seedCard()
is the only writer that leaves updated_at = 0 on a card and the seed was
its only caller, so `DELETE FROM card WHERE updated_at = 0` removes exactly
the seeded rows and nothing the learner graded — the timestamp rule paying
for itself a second time.

test/domain/reset.test.ts pins both scopes: what a full wipe must leave
empty, what a roadmap reset must keep, that the deletions are tombstoned
so a sync cannot restore them, and that migration 5 really runs against a
schema-4 database rather than the test performing the delete itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-08 21:05:54 +02:00
parent 58e454411f
commit 7275e156df
5 changed files with 157 additions and 53 deletions

137
test/domain/reset.test.ts Normal file
View File

@@ -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<number> =>
(await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM ${tbl}`))!.n;
async function aLearnerWithHistory(): Promise<void> {
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();
});
});