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

View File

@@ -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;

View File

@@ -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 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.%'`,
);
}
});

View File

@@ -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;
}

View File

@@ -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.%'",