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:
@@ -133,6 +133,22 @@ export const MIGRATIONS: Migration[] = [
|
|||||||
CREATE INDEX IF NOT EXISTS tombstone_updated ON tombstone(updated_at);
|
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;
|
export const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]!.id;
|
||||||
|
|||||||
@@ -343,12 +343,12 @@ export async function editReset(db: Db, scope: ResetScope): Promise<void> {
|
|||||||
await tx.run("DELETE FROM peek");
|
await tx.run("DELETE FROM peek");
|
||||||
await tx.run("DELETE FROM surface WHERE lemma_id >= ?", [CUSTOM_LEMMA_BASE]);
|
await tx.run("DELETE FROM surface WHERE lemma_id >= ?", [CUSTOM_LEMMA_BASE]);
|
||||||
await tx.run("DELETE FROM lemma WHERE id >= ?", [CUSTOM_LEMMA_BASE]);
|
await tx.run("DELETE FROM lemma WHERE id >= ?", [CUSTOM_LEMMA_BASE]);
|
||||||
// Preferences, grammar flags and notes, trainer score, and the
|
// Preferences, grammar flags and notes, and the trainer score.
|
||||||
// known-words seed marker. Device bookkeeping (schema_version,
|
// Device bookkeeping (schema_version, dict.*) is left alone — it
|
||||||
// dict.*) is left alone — it describes this install, not the learner.
|
// describes this install, not the learner.
|
||||||
await tx.run(
|
await tx.run(
|
||||||
`DELETE FROM meta WHERE k LIKE 'prefs.%' OR k LIKE 'grammar.%'
|
`DELETE FROM meta WHERE k LIKE 'prefs.%' OR k LIKE 'grammar.%'
|
||||||
OR k LIKE 'trainer.%' OR k LIKE 'seed.%'`,
|
OR k LIKE 'trainer.%'`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<number> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,6 @@ import { bandForUnit } from "@shared/bands.mjs";
|
|||||||
|
|
||||||
import { ensureBands, loadManifest, recordProvenance, type DictManifest } from "../domain/dictionary.js";
|
import { ensureBands, loadManifest, recordProvenance, type DictManifest } from "../domain/dictionary.js";
|
||||||
import { initProgress, readProgress } from "../domain/progress.js";
|
import { initProgress, readProgress } from "../domain/progress.js";
|
||||||
import { seedKnownWords } from "../domain/seed-known.js";
|
|
||||||
import {
|
import {
|
||||||
clearServerConfig,
|
clearServerConfig,
|
||||||
readServerConfig,
|
readServerConfig,
|
||||||
@@ -173,7 +172,6 @@ export function StoreProvider({
|
|||||||
|
|
||||||
// Needs the band rows present to match headwords, so it runs after
|
// Needs the band rows present to match headwords, so it runs after
|
||||||
// ensureBands. Seeded, so it carries no write timestamp.
|
// ensureBands. Seeded, so it carries no write timestamp.
|
||||||
await seedKnownWords(db, dayNumber());
|
|
||||||
|
|
||||||
const rows = await db.all<{ k: string; v: string }>(
|
const rows = await db.all<{ k: string; v: string }>(
|
||||||
"SELECT k, v FROM meta WHERE k LIKE 'prefs.%'",
|
"SELECT k, v FROM meta WHERE k LIKE 'prefs.%'",
|
||||||
|
|||||||
137
test/domain/reset.test.ts
Normal file
137
test/domain/reset.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user