feat(dict): stable lemma ids — a card names its word, not a build position

Cards point at lemmas by id, and an id was the entry's position in the
sorted build. One word added near the top of the dictionary would have
moved every card below it onto a different word — silently, because loaded
bands were recorded by number and a rebuilt dictionary never reached an
existing install anyway. A custom word took max(id)+1 on whichever device
added it, so the same id meant different words on a phone and a laptop.

An id is now lemmaId(headword, pos), a 53-bit hash defined once in
shared/ and used by the build, the loader, custom words and the migration.
The build asserts all 30,520 entries are collision-free, and a test pins the
function itself, since changing it re-keys every card.

Band files are format 2: they carry no ids at all. The loader derives each
id from the word, and a surface names its lemma by row index in the same
file. Writing hashed ids out cost 0.5 MB of incompressible digits; leaving
them out makes the files smaller than before (1.1 MB -> 1.0 MB).

The loaded dictionary is now versioned by its band hashes, so a rebuild
reloads on the next boot — safe only now that a reload cannot move a card.

Migration 6 re-keys an existing install without stamping anything: cards
and their tombstones move through the lemma rows still loaded, custom words
become custom_word rows (the learner's data, which can travel) carrying the
time their card was made, and the dictionary is dropped to reload.

Sync is paused until the protocol that replaces it lands: the server still
holds rows under the old ids, and exchanging them would plant cards that
name no word.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-16 19:59:14 +02:00
parent 04a0900a2c
commit f8183d786f
24 changed files with 595 additions and 60 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,4 +1,5 @@
{
"format": 2,
"builtWith": {
"dictionary": "kaikki",
"dictionaryEntries": 33419,
@@ -14,8 +15,8 @@
"file": "band-0.json.gz",
"lemmas": 801,
"surfaces": 1176,
"bytes": 24941,
"sha256": "4173cde20d7434c3e6a9a5062f2704884920ad59f3c156451408c40a4b5d8134",
"bytes": 21332,
"sha256": "e631e95e0263995c23ee58a5a3ed7cadf11d7b10566449192d644ccb2be7fe04",
"reference": false
},
{
@@ -23,8 +24,8 @@
"file": "band-1.json.gz",
"lemmas": 1240,
"surfaces": 2335,
"bytes": 54275,
"sha256": "4161561294ecf7214b2f2dd674ebcd5aeb8ae085bbca90aa7cd52ee40afda2ce",
"bytes": 48215,
"sha256": "f4ab15154ed2cefd0aa86346d874c6f3b54c8455e6c58f1b20cd83750b8402e0",
"reference": false
},
{
@@ -32,8 +33,8 @@
"file": "band-2.json.gz",
"lemmas": 1450,
"surfaces": 2590,
"bytes": 61269,
"sha256": "f3cc9662acbb90d4e1af918445c12ffef50c708ef0e6091a38e7e97b56e5bc10",
"bytes": 54698,
"sha256": "2a48e603909cd969423cc444cfac215e7a2e51c6129a80e0779cbeed0c81ebd1",
"reference": false
},
{
@@ -41,8 +42,8 @@
"file": "band-3.json.gz",
"lemmas": 1972,
"surfaces": 3373,
"bytes": 81310,
"sha256": "3b981e160ecc97b54006424106bf9b8ca6495f0c2d24a801e1a53496ca0d867d",
"bytes": 72713,
"sha256": "1b5bf666475b301930a99b2c23a8d7a9e585a3cbd40a4787feeebdf96e6ad2af",
"reference": false
},
{
@@ -50,8 +51,8 @@
"file": "band-4.json.gz",
"lemmas": 2987,
"surfaces": 4775,
"bytes": 119241,
"sha256": "37843887a427579c41eeb02ddd907e5ce066d75f422aee019c8e7a76d3452f66",
"bytes": 106990,
"sha256": "a1ec6eb219b3b1c303c26296d47e5341329e9773b7a30432729288edbda91957",
"reference": false
},
{
@@ -59,8 +60,8 @@
"file": "band-5.json.gz",
"lemmas": 6963,
"surfaces": 10347,
"bytes": 270967,
"sha256": "baf78213827168cc9074724b2ab1e339d15843b77401645d90931e99e332b2d5",
"bytes": 245251,
"sha256": "1f8576b0d5686b67aece0a5b7a2bf291e5680f3fb7f43ec95a49cf7839d5730d",
"reference": false
},
{
@@ -68,8 +69,8 @@
"file": "band-6.json.gz",
"lemmas": 15107,
"surfaces": 19283,
"bytes": 551959,
"sha256": "b3bf35f53f8f591609075609612442cc3b1ee898f3ad304416f8e49cc1718e7a",
"bytes": 499732,
"sha256": "f9e2ed69ddb4515192d6e24a8abe863656c993414f6e28948ef59d78c6d0056c",
"reference": true
}
],

Binary file not shown.

View File

@@ -17,17 +17,26 @@ async function readVersion(db: Db): Promise<number> {
* Bring the database up to SCHEMA_VERSION. Idempotent: running it twice is a
* no-op, which is what makes it safe to call on every launch.
*
* Each migration's SQL and its data step run in ONE transaction with the
* version bump, so a migration either happened completely or not at all.
*
* The version row is app bookkeeping, not a user edit, so it is written with
* updated_at = 0 — see the timestamp rule in migrations.ts.
*
* `upTo` exists for tests that need a database as an older build left it.
*/
export async function migrate(db: Db): Promise<{ from: number; to: number }> {
export async function migrate(
db: Db,
upTo: number = SCHEMA_VERSION,
): Promise<{ from: number; to: number }> {
await db.exec(BOOTSTRAP_SQL);
const from = await readVersion(db);
for (const m of MIGRATIONS) {
if (m.id <= from) continue;
if (m.id <= from || m.id > upTo) continue;
await db.tx(async (tx) => {
await tx.exec(m.sql);
if (m.run) await m.run(tx);
await tx.run("INSERT OR REPLACE INTO meta (k, v, updated_at) VALUES (?, ?, 0)", [
VERSION_KEY,
String(m.id),
@@ -35,5 +44,5 @@ export async function migrate(db: Db): Promise<{ from: number; to: number }> {
});
}
return { from, to: SCHEMA_VERSION };
return { from, to: Math.min(upTo, SCHEMA_VERSION) };
}

View File

@@ -15,10 +15,21 @@
Sync itself is out of scope for this pass; `change_seq` is deliberately
absent because it is server-assigned and arrives with the sync layer. */
import type { Db } from "./types.js";
import { lemmaId } from "@shared/lemma-id.mjs";
export interface Migration {
id: number;
name: string;
sql: string;
/**
* A data step, for what SQL cannot express. Runs after `sql`, inside the
* same transaction. It converts representation only: it must never stamp
* a row or invent user data — see "Migrations run after the first pull,
* never at boot" in PORT.md; a repair that empties something belongs in
* the sync layer's afterHydration hook, not here.
*/
run?: (db: Db) => Promise<void>;
}
export const MIGRATIONS: Migration[] = [
@@ -149,8 +160,104 @@ export const MIGRATIONS: Migration[] = [
DELETE FROM meta WHERE k = 'seed.known';
`,
},
{
id: 6,
name: "stable lemma ids — a card names its word, not a build position",
sql: /* sql */ `
-- The learner's own words, as data that can travel. A custom lemma row
-- is reference data derived from this; it used to BE the only record,
-- under an id that meant a different word on every device.
CREATE TABLE IF NOT EXISTS custom_word (
headword TEXT NOT NULL,
pos TEXT NOT NULL,
gloss TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (headword, pos)
) WITHOUT ROWID;
`,
run: rekeyLemmas,
},
];
/**
* Migration 6's data step.
*
* Ids were positions in the sorted build, so a dictionary change moved cards
* onto other words. They are now lemmaId(headword, pos). Every reference to
* an old id is rewritten through the lemma rows still loaded — the only place
* the old id's word is recorded — and then the dictionary itself is dropped,
* so the bands reload under the new ids.
*
* Nothing is stamped: the rows are the same rows, renamed. A card whose lemma
* is not loaded cannot be named, so it is left exactly as it was.
*/
async function rekeyLemmas(db: Db): Promise<void> {
const named = await db.all<{ id: number; headword: string; pos: string }>(
"SELECT id, headword, pos FROM lemma",
);
const next = new Map(named.map((l) => [l.id, lemmaId(l.headword, l.pos)]));
/* Cards. Two passes through negative ids, so no new id can collide with
an old one that has not moved yet. */
const cards = await db.all<{ lemma_id: number }>("SELECT lemma_id FROM card");
for (const { lemma_id } of cards) {
const to = next.get(lemma_id);
if (to !== undefined && to !== lemma_id) {
await db.run("UPDATE card SET lemma_id = ? WHERE lemma_id = ?", [-to, lemma_id]);
}
}
await db.run("UPDATE card SET lemma_id = -lemma_id WHERE lemma_id < 0");
/* A card's tombstone names it by the same id, as text. */
const graves = await db.all<{ pk: string }>("SELECT pk FROM tombstone WHERE tbl = 'card'");
for (const { pk } of graves) {
const to = next.get(Number(pk));
if (to !== undefined) {
await db.run(
"INSERT OR REPLACE INTO tombstone (tbl, pk, updated_at) SELECT 'card', ?, updated_at FROM tombstone WHERE tbl = 'card' AND pk = ?",
[String(to), pk],
);
if (String(to) !== pk) await db.run("DELETE FROM tombstone WHERE tbl = 'card' AND pk = ?", [pk]);
}
}
/* The learner's own words become custom_word rows, carrying the time their
card was made — the moment he added them — and their lemma is re-made
under its stable id. */
const custom = await db.all<{ headword: string; pos: string; gloss_en: string }>(
"SELECT headword, pos, gloss_en FROM lemma WHERE source = 'custom'",
);
// The cards have already moved, so each is found under its new id.
for (const w of custom) {
const id = lemmaId(w.headword, w.pos);
const card = await db.get<{ updated_at: number }>("SELECT updated_at FROM card WHERE lemma_id = ?", [id]);
await db.run(
"INSERT OR IGNORE INTO custom_word (headword, pos, gloss, updated_at) VALUES (?, ?, ?, ?)",
[w.headword, w.pos, w.gloss_en, card?.updated_at ?? 0],
);
}
/* Drop the dictionary. The custom lemmas go too and are re-made from
custom_word; the shipped bands reload on the next boot, under new ids,
because nothing is recorded as loaded any more. */
await db.run("DELETE FROM surface");
await db.run("DELETE FROM lemma");
await db.run("DELETE FROM meta WHERE k IN ('dict.loadedBands', 'dict.version')");
for (const w of custom) {
const id = lemmaId(w.headword, w.pos);
await db.run(
`INSERT OR REPLACE INTO lemma (id, headword, pos, freq_rank, level, gloss_en, gloss_ko, unit_band, source)
VALUES (?, ?, ?, NULL, NULL, ?, '', 0, 'custom')`,
[id, w.headword, w.pos, w.gloss_en],
);
await db.run("INSERT OR REPLACE INTO surface (form, lemma_id, analysis) VALUES (?, ?, ?)", [
w.headword,
id,
"headword, custom",
]);
}
}
export const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]!.id;
/** Bootstrap DDL: meta must exist before we can read the schema version. */

View File

@@ -26,6 +26,7 @@
import type { Db, Params } from "./types.js";
import type { Card, Grade } from "@lib/srs.js";
import { lemmaId } from "@shared/lemma-id.mjs";
/* ─────────────────────────────────────────────────────────────────────
The clock. This is the ONLY place in src/db/ that reads it.
@@ -215,17 +216,15 @@ export async function editPeek(db: Db, form: string): Promise<void> {
/* ═════════════════════════════════════════════════════════════════════
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.
═════════════════════════════════════════════════════════════════════ */
The record is a `custom_word` row: user data, stamped, and the thing that
travels between devices. The `lemma` and `surface` rows that let the rest
of the app treat it like any other word are derived from it.
/** First id available to custom words. The build never emits ids this high. */
export const CUSTOM_LEMMA_BASE = 10_000_000;
A custom word's lemma id is lemmaId(headword, pos) — the same function the
dictionary build uses — so it cannot collide with a shipped word, a later
rebuild cannot overwrite it with something else, and the same word added
on a phone and a laptop is the same card on both.
═════════════════════════════════════════════════════════════════════ */
export interface CustomWord {
headword: string;
@@ -271,15 +270,17 @@ export async function editAddCustomWord(db: Db, word: CustomWord): Promise<Added
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);
const id = lemmaId(headword, word.pos);
const t = now();
await tx.run(
`INSERT INTO lemma (id, headword, pos, freq_rank, level, gloss_en, gloss_ko,
unit_band, source)
`INSERT INTO custom_word (headword, pos, gloss, updated_at) VALUES (?, ?, ?, ?)
ON CONFLICT(headword, pos) DO UPDATE SET gloss = excluded.gloss, updated_at = excluded.updated_at`,
[headword, word.pos, gloss, t],
);
await tx.run(
`INSERT OR REPLACE 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],
);
@@ -290,20 +291,28 @@ export async function editAddCustomWord(db: Db, word: CustomWord): Promise<Added
]);
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()],
VALUES (?, 0, 2.5, 0, 0, 0, 0, ?)
ON CONFLICT(lemma_id) DO NOTHING`,
[id, t],
);
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) => {
const word = await tx.get<{ headword: string; pos: string }>(
"SELECT headword, pos FROM lemma WHERE id = ? AND source = 'custom'",
[lemmaId],
);
if (!word) return; // never touch shipped dictionary rows
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 tx.run("DELETE FROM custom_word WHERE headword = ? AND pos = ?", [word.headword, word.pos]);
await tombstone(tx, "card", lemmaId);
await tombstone(tx, "custom_word", JSON.stringify([word.headword, word.pos]));
});
}
@@ -338,11 +347,17 @@ export async function editReset(db: Db, scope: ResetScope): Promise<void> {
for (const r of await tx.all<{ form: string }>("SELECT form FROM peek"))
await tombstone(tx, "peek", r.form);
for (const r of await tx.all<{ headword: string; pos: string }>(
"SELECT headword, pos FROM custom_word",
))
await tombstone(tx, "custom_word", JSON.stringify([r.headword, r.pos]));
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]);
await tx.run("DELETE FROM custom_word");
await tx.run("DELETE FROM surface WHERE lemma_id IN (SELECT id FROM lemma WHERE source = 'custom')");
await tx.run("DELETE FROM lemma WHERE source = 'custom'");
// Preferences, grammar flags and notes, and the trainer score.
// Device bookkeeping (schema_version, dict.*) is left alone — it
// describes this install, not the learner.

View File

@@ -18,6 +18,7 @@
import type { Db } from "../db/types.js";
import { insertBand, seedMeta, type LemmaRow, type SurfaceRow } from "../db/writes.js";
import { REFERENCE_BAND } from "@shared/bands.mjs";
import { lemmaId } from "@shared/lemma-id.mjs";
const BASE = `${import.meta.env.BASE_URL ?? "/"}dict/`;
@@ -32,6 +33,7 @@ export interface BandInfo {
}
export interface DictManifest {
format?: number;
builtWith: { dictionary: string; dictionaryEntries: number; frequencyForms: number };
totals: { lemmas: number; surfaces: number };
bands: BandInfo[];
@@ -41,11 +43,22 @@ export interface DictManifest {
interface BandPayload {
band: number;
format?: number;
columns: { lemma: string[]; surface: string[] };
lemmas: unknown[][];
surfaces: unknown[][];
}
/** A lemma as the file carries it: no id, which is derived from the word. */
type FileLemma = Omit<LemmaRow, "id">;
/** A surface as the file carries it: its lemma is a row index in the file. */
interface FileSurface {
form: string;
lemma: number;
analysis: string;
}
let manifestPromise: Promise<DictManifest> | null = null;
export function loadManifest(): Promise<DictManifest> {
@@ -95,7 +108,61 @@ function toRows<T>(columns: string[], rows: unknown[][]): T[] {
});
}
/**
* Band file rows, as database rows.
*
* The files carry no ids (format 2). A lemma's id is lemmaId(headword, pos) —
* the same function the build asserted collision-free, and the one the
* app uses for custom words — so the id means the same word on every device
* and across every rebuild. A surface points at its lemma by row index.
*/
function rowsOf(payload: BandPayload): { lemmas: LemmaRow[]; surfaces: SurfaceRow[] } {
if (payload.format !== 2) {
throw new Error(`band ${payload.band}: format ${payload.format ?? 1} is not supported — rebuild the dictionary`);
}
const lemmas = toRows<FileLemma>(payload.columns.lemma, payload.lemmas).map((l) => ({
...l,
id: lemmaId(l.headword, l.pos),
}));
const surfaces = toRows<FileSurface>(payload.columns.surface, payload.surfaces).map((s) => {
const lemma = lemmas[s.lemma];
if (!lemma) throw new Error(`band ${payload.band}: surface ${s.form} names a lemma row that does not exist`);
return { form: s.form, lemma_id: lemma.id, analysis: s.analysis };
});
return { lemmas, surfaces };
}
const LOADED_KEY = "dict.loadedBands";
const VERSION_KEY = "dict.version";
/** Which build of the dictionary is loaded: every band file's hash. */
const versionOf = (m: DictManifest): string =>
m.bands.map((b) => `${b.band}:${b.sha256.slice(0, 16)}`).join(",");
/**
* Drop the loaded dictionary when the shipped one has changed.
*
* Loaded bands used to be recorded by number alone, so a rebuilt dictionary
* never reached an existing install: band 0 was "already loaded". Reloading
* is safe now that an id names a word rather than a position — every card
* still points at the same word afterwards. The learner's own words are
* reference data too, but not the shipped kind, so they stay.
*/
async function ensureCurrent(db: Db): Promise<void> {
const want = versionOf(await loadManifest());
const have = await db.get<{ v: string }>("SELECT v FROM meta WHERE k = ?", [VERSION_KEY]);
if (have?.v === want) return;
await db.tx(async (tx) => {
await tx.run(
"DELETE FROM surface WHERE lemma_id IN (SELECT id FROM lemma WHERE source <> 'custom')",
);
await tx.run("DELETE FROM lemma WHERE source <> 'custom'");
await tx.run("DELETE FROM meta WHERE k = ?", [LOADED_KEY]);
// Bookkeeping, not a user edit — never stamped, never synced.
await tx.run("INSERT OR REPLACE INTO meta (k, v, updated_at) VALUES (?, ?, 0)", [VERSION_KEY, want]);
});
}
/**
* Band loading is a read-modify-write on one meta row, and it runs from two
@@ -138,18 +205,15 @@ async function rememberBand(db: Db, band: number): Promise<void> {
/** Load one band if it is not already in the database. */
export function loadBand(db: Db, band: number): Promise<boolean> {
return serialise(async () => {
await ensureCurrent(db);
if ((await loadedBands(db)).has(band)) return false;
const manifest = await loadManifest();
const info = manifest.bands.find((b) => b.band === band);
if (!info) return false;
const payload = await fetchBand(info.file);
await insertBand(
db,
toRows<LemmaRow>(payload.columns.lemma, payload.lemmas),
toRows<SurfaceRow>(payload.columns.surface, payload.surfaces),
);
const { lemmas, surfaces } = rowsOf(await fetchBand(info.file));
await insertBand(db, lemmas, surfaces);
await rememberBand(db, band);
return true;
});

View File

@@ -30,7 +30,7 @@ import {
writeServerConfig,
type ServerConfig,
} from "../domain/server-config.js";
import { trySync, type SyncResult } from "../sync/client.js";
import { SYNC_PAUSED, trySync, type SyncResult } from "../sync/client.js";
import type { ProgressState } from "@lib/gate.js";
import type { FocusMode } from "../domain/gate.js";
@@ -243,6 +243,10 @@ export function StoreProvider({
const running = useRef(false);
const syncNow = useCallback(async () => {
if (!store || !server || running.current) return;
if (SYNC_PAUSED) {
setSyncState({ at: Date.now(), result: null, error: SYNC_PAUSED, running: false });
return;
}
running.current = true;
setSyncState((s) => ({ ...s, running: true }));
const result = await trySync(store.db, server);

View File

@@ -46,6 +46,18 @@ export interface SyncResult {
cursor: number;
}
/**
* Why sync is not running, or null when it is.
*
* Cards are keyed by stable lemma ids now, while the server still holds rows
* written under the old positional ids. Exchanging them would plant cards
* under ids that name no word, on both sides. Everything stays local — which
* this app is built to do — until the sync protocol that replaces this one
* ships with its own server schema.
*/
export const SYNC_PAUSED: string | null =
"Sync is paused while the storage format changes. Everything is kept on this device";
const CURSOR_KEY = "sync.cursor";
const PUSHED_KEY = "sync.pushedAt";
@@ -248,7 +260,7 @@ export async function trySync(
cfg: SyncConfig | null,
signal?: AbortSignal,
): Promise<SyncResult | null> {
if (!cfg?.baseUrl || !cfg.token) return null;
if (!cfg?.baseUrl || !cfg.token || SYNC_PAUSED) return null;
try {
return await syncOnce(db, cfg, signal);
} catch (err) {

View File

@@ -7,7 +7,7 @@ import { useEffect, useMemo, useState } from "react";
import { useStore } from "../../state/store.js";
import { useReview } from "../review/useReview.js";
import { deck, forget, markAsKnown, type CardStatus, type DeckEntry } from "../../domain/cards.js";
import { CUSTOM_LEMMA_BASE, editAddCustomWord, editRemoveCustomWord } from "../../db/writes.js";
import { editAddCustomWord, editRemoveCustomWord } from "../../db/writes.js";
import { search, type Entry } from "../../domain/lexicon.js";
import { ensureReferenceBand } from "../../domain/dictionary.js";
import { statusOf } from "@lib/srs.js";
@@ -238,7 +238,7 @@ export function VocabTab() {
Reset
</button>
)}
{e.lemmaId >= CUSTOM_LEMMA_BASE && (
{e.source === "custom" && (
<button
className="btn sm"
title="Remove this word entirely"

33
shared/lemma-id.mjs Normal file
View File

@@ -0,0 +1,33 @@
/* A lemma's id — derived from what the word IS, not where it sorts.
Ids used to be positions in the sorted build: the 12,000th entry got id
12000. Cards point at lemmas by id, so any change to the dictionary —
one entry added near the top — silently moved every card below it onto a
different word. And a custom word took max(id)+1 on the device that added
it, so the same id meant different words on a phone and a laptop.
Hashing (headword, pos) makes the id a property of the word: stable across
rebuilds, and identical on every device that adds the same word. The
build asserts the whole dictionary is collision-free.
cyrb53 (bryc, public domain): 53 bits, so the result is always a safe
JavaScript integer and fits SQLite's INTEGER PRIMARY KEY. It hashes
UTF-16 code units, which is deterministic across engines. The separator is
written as an escape, never a literal control byte. */
export function lemmaId(headword, pos) {
const str = `${headword}\u0001${pos}`;
let h1 = 0xdeadbeef;
let h2 = 0x41c6ce57;
for (let i = 0; i < str.length; i++) {
const ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
// 0 is never a valid rowid in practice; keep it out of the id space.
return 4294967296 * (2097151 & h2) + (h1 >>> 0) || 1;
}

View File

@@ -23,7 +23,7 @@ import {
import { newCard, grade, GOOD } from "@lib/srs.js";
/** Every table that carries user data, and therefore a write timestamp. */
const SYNCABLE = ["card", "progress", "chat", "meta", "study_log", "peek"] as const;
const SYNCABLE = ["card", "progress", "chat", "meta", "study_log", "peek", "custom_word"] as const;
export function conformanceSuite(name: string, open: () => Promise<Db>): void {
describe(`Db conformance — ${name}`, () => {

62
test/db/lemma-id.test.ts Normal file
View File

@@ -0,0 +1,62 @@
/* A lemma's id is a hash of the word, not its position in the build.
Cards point at lemmas by id. When ids were positions, adding one entry near
the top of the dictionary moved every card below it onto a different word,
and a custom word's id meant different words on different devices. */
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { gunzipSync } from "node:zlib";
import { lemmaId } from "@shared/lemma-id.mjs";
describe("lemmaId", () => {
/* PINNED. Changing the hash re-keys every card on every device; if this
test has to change, a migration has to move the cards with it. */
it("is exactly this function", () => {
expect(lemmaId("밥", "noun")).toBe(55855054575946);
expect(lemmaId("가다", "verb")).toBe(1434356355562999);
});
it("depends on the part of speech as well as the headword", () => {
expect(lemmaId("밥", "noun")).not.toBe(lemmaId("밥", "verb"));
});
it("is a positive safe integer, which SQLite stores as INTEGER PRIMARY KEY", () => {
for (const [h, p] of [["밥", "noun"], ["", ""], ["신경 쓰다", "verb"], ["x".repeat(500), "noun"]]) {
const id = lemmaId(h!, p!);
expect(Number.isSafeInteger(id)).toBe(true);
expect(id).toBeGreaterThan(0);
}
});
});
describe("the shipped dictionary", () => {
const manifest = JSON.parse(
readFileSync(new URL("../../app/public/dict/manifest.json", import.meta.url), "utf8"),
) as { format: number; totals: { lemmas: number }; bands: { file: string }[] };
it("is format 2 — no ids in the files, derived as they load", () => {
expect(manifest.format).toBe(2);
});
it("gives every (headword, pos) its own id", () => {
const owner = new Map<number, string>();
let n = 0;
for (const band of manifest.bands) {
const data = JSON.parse(
gunzipSync(readFileSync(new URL(`../../app/public/dict/${band.file}`, import.meta.url))).toString("utf8"),
) as { format: number; columns: { lemma: string[] }; lemmas: unknown[][] };
expect(data.format).toBe(2);
const hw = data.columns.lemma.indexOf("headword");
const pos = data.columns.lemma.indexOf("pos");
for (const row of data.lemmas) {
const key = `${row[hw]}/${row[pos]}`;
const id = lemmaId(row[hw] as string, row[pos] as string);
expect(owner.get(id) ?? key, `${key} collides`).toBe(key);
owner.set(id, key);
n++;
}
}
expect(n).toBe(manifest.totals.lemmas);
});
});

View File

@@ -0,0 +1,89 @@
/* Migration 6 — cards move to stable lemma ids.
A database as the previous build left it: positional lemma ids, a custom
word at the old reserved offset, a tombstone naming a card by its old id.
After the migration every reference names the same word under its hashed
id, and nothing has been stamped — the rows are the same rows, renamed. */
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 { lemmaId } from "@shared/lemma-id.mjs";
import type { Db } from "@app/db/types.js";
let db: Db;
beforeEach(async () => {
db = await SqliteWasmDb.open({ memory: true });
await migrate(db, 5);
await db.exec(`
INSERT INTO lemma (id, headword, pos, gloss_en, unit_band, source) VALUES
(1, '물', 'noun', 'water', 0, 'curated'),
(2, '밥', 'noun', 'rice', 0, 'curated'),
(3, '책', 'noun', 'book', 0, 'curated'),
(10000000, '던전', 'noun', 'dungeon', 0, 'custom');
INSERT INTO surface (form, lemma_id, analysis) VALUES
('밥', 2, 'headword, noun'),
('던전', 10000000, 'headword, custom');
INSERT INTO card (lemma_id, state, interval, due, reps, updated_at) VALUES
(2, 2, 21, 100, 5, 111),
(10000000, 1, 1, 90, 1, 222),
(424242, 1, 1, 90, 1, 250);
INSERT INTO tombstone (tbl, pk, updated_at) VALUES ('card', '3', 333);
INSERT INTO meta (k, v, updated_at) VALUES ('dict.loadedBands', '[0]', 0);
`);
await migrate(db);
});
afterEach(async () => {
await db.close();
});
describe("migration 6", () => {
it("moves each card to its word's stable id, keeping its schedule and stamp", async () => {
const rice = await db.get<{ interval: number; reps: number; updated_at: number }>(
"SELECT interval, reps, updated_at FROM card WHERE lemma_id = ?",
[lemmaId("밥", "noun")],
);
expect(rice).toEqual({ interval: 21, reps: 5, updated_at: 111 });
expect(await db.get("SELECT 1 FROM card WHERE lemma_id = 2")).toBeUndefined();
});
it("leaves a card it cannot name exactly as it was", async () => {
expect(await db.get<{ updated_at: number }>("SELECT updated_at FROM card WHERE lemma_id = 424242")).toEqual({
updated_at: 250,
});
});
it("renames a card's tombstone with it", async () => {
const graves = await db.all<{ pk: string; updated_at: number }>(
"SELECT pk, updated_at FROM tombstone WHERE tbl = 'card'",
);
expect(graves).toEqual([{ pk: String(lemmaId("책", "noun")), updated_at: 333 }]);
});
it("turns a custom lemma into a custom_word, stamped when its card was made", async () => {
expect(await db.all("SELECT headword, pos, gloss, updated_at FROM custom_word")).toEqual([
{ headword: "던전", pos: "noun", gloss: "dungeon", updated_at: 222 },
]);
const id = lemmaId("던전", "noun");
expect(await db.get("SELECT headword, source FROM lemma WHERE id = ?", [id])).toEqual({
headword: "던전",
source: "custom",
});
expect(await db.get("SELECT form FROM surface WHERE lemma_id = ?", [id])).toEqual({ form: "던전" });
expect(await db.get<{ updated_at: number }>("SELECT updated_at FROM card WHERE lemma_id = ?", [id])).toEqual({
updated_at: 222,
});
});
it("drops the shipped dictionary, so the bands reload under the new ids", async () => {
expect(await db.all("SELECT headword FROM lemma WHERE source <> 'custom'")).toEqual([]);
expect(await db.get("SELECT v FROM meta WHERE k = 'dict.loadedBands'")).toBeUndefined();
});
it("stamps nothing", async () => {
for (const tbl of ["card", "custom_word", "tombstone", "meta"]) {
const row = await db.get<{ top: number | null }>(`SELECT max(updated_at) AS top FROM ${tbl}`);
expect(row?.top ?? 0, tbl).toBeLessThanOrEqual(333);
}
});
});

View File

@@ -0,0 +1,97 @@
/* Loading the shipped dictionary, and reloading it when it changes.
Loaded bands used to be recorded by number alone, so a rebuilt dictionary
never reached an existing install. Reloading only became safe once an id
named a word instead of a position — these pin both halves. */
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { readFileSync } from "node:fs";
import { SqliteWasmDb } from "@app/db/sqlite-wasm-core.js";
import { migrate } from "@app/db/migrate.js";
import { editCard, editAddCustomWord } from "@app/db/writes.js";
import { markKnown } from "@lib/srs.js";
import { lemmaId } from "@shared/lemma-id.mjs";
import type { Db } from "@app/db/types.js";
const DICT = new URL("../../app/public/dict/", import.meta.url);
const shipped = JSON.parse(readFileSync(new URL("manifest.json", DICT), "utf8")) as {
bands: { band: number; file: string; sha256: string; lemmas: number }[];
};
/** Serve the committed files; `manifest` lets a test pretend to be a rebuild. */
function serve(manifest: unknown) {
vi.stubGlobal("fetch", async (input: string) => {
const name = String(input).split("/dict/")[1] ?? "";
if (name === "manifest.json") return new Response(JSON.stringify(manifest));
return new Response(readFileSync(new URL(name, DICT)));
});
}
async function loader() {
vi.resetModules(); // the manifest is cached per module instance
return import("@app/domain/dictionary.js");
}
let db: Db;
beforeEach(async () => {
db = await SqliteWasmDb.open({ memory: true });
await migrate(db);
});
afterEach(async () => {
vi.unstubAllGlobals();
await db.close();
});
const count = async (sql: string, params: (string | number)[] = []) =>
(await db.get<{ n: number }>(sql, params))!.n;
describe("loading a band", () => {
it("derives every id from the word, so surfaces join their lemmas", async () => {
serve(shipped);
const { ensureBands } = await loader();
await ensureBands(db, 0);
expect(await count("SELECT count(*) AS n FROM lemma")).toBe(shipped.bands[0]!.lemmas);
const rice = await db.get<{ id: number }>("SELECT id FROM lemma WHERE headword = '밥' AND pos = 'noun'");
expect(rice?.id).toBe(lemmaId("밥", "noun"));
expect(await count("SELECT count(*) AS n FROM surface s LEFT JOIN lemma l ON l.id = s.lemma_id WHERE l.id IS NULL")).toBe(0);
});
it("does not load a band twice", async () => {
serve(shipped);
const { ensureBands } = await loader();
expect(await ensureBands(db, 0)).toEqual([0]);
expect(await ensureBands(db, 0)).toEqual([]);
});
});
describe("a rebuilt dictionary", () => {
it("reloads, and every card and custom word still names its word", async () => {
serve(shipped);
let dict = await loader();
await dict.ensureBands(db, 0);
const rice = lemmaId("밥", "noun");
await editCard(db, rice, markKnown(100));
const custom = await editAddCustomWord(db, { headword: "던전돌", gloss: "dungeon stone", pos: "noun" });
// A rebuild: band 0's file hash changes.
const rebuilt = structuredClone(shipped);
rebuilt.bands[0]!.sha256 = "f".repeat(64);
serve(rebuilt);
dict = await loader();
await db.run("DELETE FROM lemma WHERE headword = '물'"); // prove the reload really re-inserts
expect(await dict.ensureBands(db, 0)).toEqual([0]);
expect(await count("SELECT count(*) AS n FROM lemma WHERE headword = '물'")).toBeGreaterThan(0);
const card = await db.get<{ headword: string }>(
"SELECT l.headword FROM card c JOIN lemma l ON l.id = c.lemma_id WHERE c.lemma_id = ?",
[rice],
);
expect(card?.headword).toBe("밥");
const mine = await db.get<{ headword: string; source: string }>(
"SELECT headword, source FROM lemma WHERE id = ?",
[custom.lemmaId],
);
expect(mine).toEqual({ headword: "던전돌", source: "custom" });
});
});

View File

@@ -10,6 +10,7 @@ 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 {
editAddCustomWord,
editCard,
editChatTurn,
editMeta,
@@ -96,6 +97,20 @@ describe("editReset('everything')", () => {
expect(tables).toContain("study_log");
});
it("removes the learner's own words, lemma and all, and tombstones them", async () => {
await aLearnerWithHistory();
const { lemmaId } = await editAddCustomWord(db, { headword: "던전", gloss: "dungeon", pos: "noun" });
await editReset(db, "everything");
expect(await count("custom_word")).toBe(0);
expect(await db.get("SELECT 1 FROM lemma WHERE id = ?", [lemmaId])).toBeUndefined();
expect(await db.get("SELECT 1 FROM surface WHERE lemma_id = ?", [lemmaId])).toBeUndefined();
expect(await db.get("SELECT pk FROM tombstone WHERE tbl = 'custom_word'")).toEqual({
pk: JSON.stringify(["던전", "noun"]),
});
expect(await count("lemma")).toBe(3); // the shipped words stay
});
it("does not survive a reboot: nothing reseeds the deck", async () => {
await aLearnerWithHistory();
await editReset(db, "everything");
@@ -124,9 +139,10 @@ describe("migration 5 — the known-words seed is gone", () => {
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.
// Wind the recorded version back to 4 so migrate() really runs 5 — and
// only 5: migration 6 re-keys cards, which is not what this pins.
await db.run("INSERT OR REPLACE INTO meta (k, v, updated_at) VALUES ('schema_version','4',0)");
const { from, to } = await migrate(db);
const { from, to } = await migrate(db, 5);
expect(from).toBe(4);
expect(to).toBe(5);

View File

@@ -28,6 +28,7 @@ import { DatabaseSync } from "node:sqlite";
import { surfaceForms } from "../../lib/conjugation.js";
import { flatten } from "../../lib/gate.js";
import { bandOf, bandForUnit, REFERENCE_BAND, BANDS } from "../../shared/bands.mjs";
import { lemmaId } from "../../shared/lemma-id.mjs";
import { readKaikki, KAIKKI_ATTRIBUTION } from "./sources/kaikki.mjs";
import { readKrdict, findKrdict, KRDICT_ATTRIBUTION } from "./sources/krdict.mjs";
@@ -258,17 +259,28 @@ async function main() {
for (const w of u.words ?? []) if (!introducedIn.has(w)) introducedIn.set(w, u.id);
}
/* Stable ids: sort first so a rebuild produces byte-identical output. */
/* Sorted, so a rebuild produces byte-identical output. The ids do NOT come
from this order: an id is a hash of (headword, pos) — see
shared/lemma-id.mjs — so a card keeps its word however the dictionary
changes around it. */
entries.sort((a, b) =>
a.headword === b.headword ? a.pos.localeCompare(b.pos) : a.headword.localeCompare(b.headword),
);
const lemmas = [];
const surfaces = [];
let id = 0;
const idOwner = new Map();
for (const e of entries) {
id++;
const id = lemmaId(e.headword, e.pos);
const clash = idOwner.get(id);
if (clash) {
throw new Error(
`lemma id collision: ${clash} and ${e.headword}/${e.pos} both hash to ${id}. ` +
"Two words would share every card and review — change the hash before shipping.",
);
}
idOwner.set(id, `${e.headword}/${e.pos}`);
const rank = ranks.get(`${e.headword} ${e.pos}`) ?? null;
let band = bandOf({ source: e.source, freqRank: rank, level: e.level });
@@ -318,17 +330,24 @@ async function main() {
for (const band of allBands) {
const data = byBand.get(band) ?? { lemmas: [], surfaces: [] };
// Arrays, not objects: the key names would otherwise be ~60% of the file.
const rowOf = new Map(data.lemmas.map((l, i) => [l.id, i]));
/* Arrays, not objects: the key names would otherwise be ~60% of the file.
Format 2 ships no ids at all. An id is lemmaId(headword, pos), which the
app computes as it loads, and a surface names its lemma by row index in
this same file — a surface always travels in its lemma's band. Hashed
ids written out in full cost 0.5 MB of incompressible digits. */
const payload = {
band,
format: 2,
columns: {
lemma: ["id", "headword", "pos", "freq_rank", "level", "gloss_en", "gloss_ko", "unit_band", "source"],
surface: ["form", "lemma_id", "analysis"],
lemma: ["headword", "pos", "freq_rank", "level", "gloss_en", "gloss_ko", "unit_band", "source"],
surface: ["form", "lemma", "analysis"],
},
lemmas: data.lemmas.map((l) => [
l.id, l.headword, l.pos, l.freq_rank, l.level, l.gloss_en, l.gloss_ko, l.unit_band, l.source,
l.headword, l.pos, l.freq_rank, l.level, l.gloss_en, l.gloss_ko, l.unit_band, l.source,
]),
surfaces: data.surfaces.map((s) => [s.form, s.lemma_id, s.analysis]),
surfaces: data.surfaces.map((s) => [s.form, rowOf.get(s.lemma_id), s.analysis]),
};
const gz = gzipSync(Buffer.from(JSON.stringify(payload), "utf8"), { level: 9 });
@@ -376,6 +395,7 @@ async function main() {
seed.close();
const manifest = {
format: 2,
builtWith: {
dictionary: dict.name,
dictionaryEntries: dict.entries,

6
types/shared/lemma-id.mjs.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/* Declarations for shared/lemma-id.mjs — imported by the app, the migration
that re-keys existing cards, and the dictionary build, so a word's id is
computed in exactly one place. */
/** A stable 53-bit id for (headword, pos). Same word, same id, everywhere. */
export function lemmaId(headword: string, pos: string): number;