feat(db): one storage interface, two SQLite drivers
Nothing above db/ knows which driver it got. sqlite.web.ts runs
@sqlite.org/sqlite-wasm in a dedicated Worker; sqlite.native.ts uses the
Capacitor plugin. Both apply the same migration array.
The web driver uses the OPFS SAHPool VFS rather than the plain opfs VFS:
the latter needs COOP/COEP cross-origin isolation headers, which neither a
static host nor the Capacitor webview reliably provides. Same storage,
fewer deployment constraints.
THE TIMESTAMP RULE. The artifact had a sync bug where a fresh device
stamped its own empty defaults as newer than the server's real history and
clobbered it. Three layers make that unrepresentable rather than merely
avoided:
1. updated_at INTEGER NOT NULL DEFAULT 0 on every syncable table, so
forgetting the column is the SAFE failure — a row that loses every
last-write-wins comparison, not one that wins them all.
2. writes.ts splits every mutation into seedX() (never stamps) and
editX() (always stamps), and is the only file allowed to read the clock.
3. An ESLint rule enforces that, and the conformance suite asserts a
freshly seeded database has no non-zero updated_at anywhere.
Two batching limits are sized for the platform we cannot test here: Android
links the system SQLite, historically capped at 999 bound parameters, while
sqlite-wasm allows 32766. A multi-row insert sized for the browser would
fail only on the phone, at first launch, loading the dictionary. Both the
band insert and the word-rail lookup batch under the smaller ceiling, and
test/db/limits.test.ts fails at 3600 if that regresses.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
27
app/src/db/index.ts
Normal file
27
app/src/db/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/* The single entry point. Everything above this folder calls openDb() and
|
||||
never learns which driver it got. */
|
||||
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import type { Db, DbInfo } from "./types.js";
|
||||
|
||||
export type { Db, DbInfo, Params, Row, SqlValue } from "./types.js";
|
||||
export { MIGRATIONS, SCHEMA_VERSION } from "./migrations.js";
|
||||
export { migrate } from "./migrate.js";
|
||||
|
||||
let handle: Promise<Db & { info: DbInfo }> | null = null;
|
||||
|
||||
/** Open the database, running migrations. Safe to call repeatedly. */
|
||||
export function openDb(): Promise<Db & { info: DbInfo }> {
|
||||
handle ??= Capacitor.isNativePlatform()
|
||||
? import("./sqlite.native.js").then((m) => m.openNativeDb())
|
||||
: import("./sqlite.web.js").then((m) => m.openWebDb());
|
||||
return handle;
|
||||
}
|
||||
|
||||
/** Tests and the dev console; production code should just await openDb(). */
|
||||
export async function closeDb(): Promise<void> {
|
||||
if (!handle) return;
|
||||
const db = await handle;
|
||||
handle = null;
|
||||
await db.close();
|
||||
}
|
||||
39
app/src/db/migrate.ts
Normal file
39
app/src/db/migrate.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/* The migration runner. Driver-agnostic: both drivers hand it a Db and get
|
||||
the same schema out. */
|
||||
|
||||
import type { Db } from "./types.js";
|
||||
import { BOOTSTRAP_SQL, MIGRATIONS, SCHEMA_VERSION } from "./migrations.js";
|
||||
|
||||
const VERSION_KEY = "schema_version";
|
||||
|
||||
async function readVersion(db: Db): Promise<number> {
|
||||
const row = await db.get<{ v: string }>("SELECT v FROM meta WHERE k = ?", [VERSION_KEY]);
|
||||
if (!row) return 0;
|
||||
const n = Number.parseInt(row.v, 10);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export async function migrate(db: Db): 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;
|
||||
await db.tx(async (tx) => {
|
||||
await tx.exec(m.sql);
|
||||
await tx.run("INSERT OR REPLACE INTO meta (k, v, updated_at) VALUES (?, ?, 0)", [
|
||||
VERSION_KEY,
|
||||
String(m.id),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
return { from, to: SCHEMA_VERSION };
|
||||
}
|
||||
130
app/src/db/migrations.ts
Normal file
130
app/src/db/migrations.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/* The schema, as an ordered list. Both drivers apply exactly this array, in
|
||||
order, tracked in meta.schema_version — that is what "same migrations on
|
||||
both" means.
|
||||
|
||||
THE TIMESTAMP RULE, enforced here in the DDL:
|
||||
|
||||
Every syncable row carries `updated_at INTEGER NOT NULL DEFAULT 0`.
|
||||
|
||||
The artifact had a sync bug where a fresh device stamped its own empty
|
||||
default state as newer than the server's real data and clobbered it. The
|
||||
defence is that forgetting the column is the SAFE failure: an INSERT that
|
||||
omits updated_at gets 0, which loses every last-write-wins comparison.
|
||||
Only a genuine user edit stamps the clock, and only db/writes.ts may do it.
|
||||
|
||||
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. */
|
||||
|
||||
export interface Migration {
|
||||
id: number;
|
||||
name: string;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
export const MIGRATIONS: Migration[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "initial schema",
|
||||
sql: /* sql */ `
|
||||
-- ── dictionary ───────────────────────────────────────────────
|
||||
-- Populated from the build pipeline's band files. Not user data:
|
||||
-- no updated_at, never synced, rebuilt from the shipped assets.
|
||||
CREATE TABLE IF NOT EXISTS lemma (
|
||||
id INTEGER PRIMARY KEY,
|
||||
headword TEXT NOT NULL,
|
||||
pos TEXT NOT NULL,
|
||||
freq_rank INTEGER, -- NULL = unranked
|
||||
level TEXT, -- 초급 / 중급 / 고급, when the source grades
|
||||
gloss_en TEXT NOT NULL DEFAULT '',
|
||||
gloss_ko TEXT NOT NULL DEFAULT '',
|
||||
unit_band INTEGER NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL -- krdict | kaikki | curated | grammar | sentence | sfx
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS lemma_hw_pos ON lemma(headword, pos);
|
||||
CREATE INDEX IF NOT EXISTS lemma_headword ON lemma(headword);
|
||||
CREATE INDEX IF NOT EXISTS lemma_freq ON lemma(freq_rank);
|
||||
CREATE INDEX IF NOT EXISTS lemma_band ON lemma(unit_band);
|
||||
|
||||
-- Precomputed by lib/conjugation.js surfaceForms() at BUILD time.
|
||||
-- This table is the reason the app ships no runtime morphological
|
||||
-- analyser: lookup of a conjugated form is an index hit.
|
||||
CREATE TABLE IF NOT EXISTS surface (
|
||||
form TEXT NOT NULL,
|
||||
lemma_id INTEGER NOT NULL REFERENCES lemma(id),
|
||||
analysis TEXT NOT NULL, -- e.g. "반말, from 먹다"
|
||||
PRIMARY KEY (form, lemma_id)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX IF NOT EXISTS surface_form ON surface(form);
|
||||
|
||||
-- ── user data — every table below is syncable ─────────────────
|
||||
CREATE TABLE IF NOT EXISTS card (
|
||||
lemma_id INTEGER PRIMARY KEY REFERENCES lemma(id),
|
||||
state INTEGER NOT NULL DEFAULT 0, -- srs.js NEW | LEARNING | REVIEW
|
||||
ease REAL NOT NULL DEFAULT 2.5,
|
||||
interval INTEGER NOT NULL DEFAULT 0,
|
||||
due INTEGER NOT NULL DEFAULT 0, -- day number, not a timestamp
|
||||
reps INTEGER NOT NULL DEFAULT 0,
|
||||
lapses INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS card_due ON card(due);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS progress (
|
||||
unit_id TEXT PRIMARY KEY,
|
||||
state TEXT NOT NULL DEFAULT 'todo', -- todo | now | done
|
||||
confidence INTEGER NOT NULL DEFAULT 0, -- 0-100, the tutor's read
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chat (
|
||||
id INTEGER PRIMARY KEY,
|
||||
role TEXT NOT NULL, -- user | assistant
|
||||
body TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS chat_created ON chat(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
k TEXT PRIMARY KEY,
|
||||
v TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "study log — one row per day, drives the streak and heatmap",
|
||||
sql: /* sql */ `
|
||||
CREATE TABLE IF NOT EXISTS study_log (
|
||||
day INTEGER PRIMARY KEY, -- srs.js dayNumber()
|
||||
reviews INTEGER NOT NULL DEFAULT 0,
|
||||
correct INTEGER NOT NULL DEFAULT 0,
|
||||
drills INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "per-word lookup tally — persistent, drives the word rail underline",
|
||||
sql: /* sql */ `
|
||||
CREATE TABLE IF NOT EXISTS peek (
|
||||
form TEXT PRIMARY KEY,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]!.id;
|
||||
|
||||
/** Bootstrap DDL: meta must exist before we can read the schema version. */
|
||||
export const BOOTSTRAP_SQL = /* sql */ `
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
k TEXT PRIMARY KEY,
|
||||
v TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`;
|
||||
183
app/src/db/sqlite-wasm-core.ts
Normal file
183
app/src/db/sqlite-wasm-core.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/* The sqlite-wasm driver proper. Runs unchanged in three places:
|
||||
- a dedicated Worker in the browser, over OPFS (the real app)
|
||||
- the main thread, if a Worker is unavailable
|
||||
- Node, against an in-memory database (the conformance suite)
|
||||
|
||||
OPFS note: this uses the SAHPool VFS, not the plain `opfs` VFS. The plain
|
||||
one requires COOP/COEP cross-origin isolation headers, which neither a
|
||||
static host nor the Capacitor webview reliably provides; SAHPool needs no
|
||||
special headers and is faster. Both are "sqlite-wasm over OPFS". */
|
||||
|
||||
import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
|
||||
import type { Db, DbInfo, Params, Row, SqlValue } from "./types.js";
|
||||
|
||||
export const DB_FILENAME = "hankan.sqlite3";
|
||||
const POOL_NAME = "hankan-pool";
|
||||
|
||||
/* The upstream package ships types that do not describe the OPFS SAHPool
|
||||
helpers, so the handful of members we touch are named here rather than
|
||||
reaching for `any` at every call site. */
|
||||
interface OO1Db {
|
||||
exec(opts: {
|
||||
sql: string;
|
||||
bind?: readonly SqlValue[];
|
||||
rowMode?: "object" | "array";
|
||||
returnValue?: "resultRows";
|
||||
resultRows?: unknown[];
|
||||
}): unknown;
|
||||
close(): void;
|
||||
}
|
||||
interface Sqlite3 {
|
||||
version: { libVersion: string };
|
||||
oo1: {
|
||||
DB: new (filename: string, flags?: string) => OO1Db;
|
||||
};
|
||||
installOpfsSAHPoolVfs?: (opts: { name: string }) => Promise<{
|
||||
OpfsSAHPoolDb: new (filename: string) => OO1Db;
|
||||
}>;
|
||||
}
|
||||
|
||||
let modulePromise: Promise<Sqlite3> | null = null;
|
||||
function loadSqlite(): Promise<Sqlite3> {
|
||||
modulePromise ??= sqlite3InitModule() as unknown as Promise<Sqlite3>;
|
||||
return modulePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialises every operation. SQLite itself is fine with our access pattern,
|
||||
* but tx() awaits caller code between BEGIN and COMMIT, and without a lock a
|
||||
* concurrent query would land inside someone else's transaction.
|
||||
*/
|
||||
class Mutex {
|
||||
private tail: Promise<unknown> = Promise.resolve();
|
||||
run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const out = this.tail.then(fn, fn);
|
||||
// Keep the chain alive even when a caller's promise rejects.
|
||||
this.tail = out.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
export class SqliteWasmDb implements Db {
|
||||
readonly info: DbInfo;
|
||||
private db: OO1Db;
|
||||
private lock = new Mutex();
|
||||
private depth = 0;
|
||||
|
||||
private constructor(db: OO1Db, info: DbInfo) {
|
||||
this.db = db;
|
||||
this.info = info;
|
||||
}
|
||||
|
||||
static async open(opts: { memory?: boolean } = {}): Promise<SqliteWasmDb> {
|
||||
const sqlite3 = await loadSqlite();
|
||||
const version = sqlite3.version.libVersion;
|
||||
|
||||
if (!opts.memory && typeof sqlite3.installOpfsSAHPoolVfs === "function") {
|
||||
try {
|
||||
const pool = await sqlite3.installOpfsSAHPoolVfs({ name: POOL_NAME });
|
||||
return new SqliteWasmDb(new pool.OpfsSAHPoolDb(`/${DB_FILENAME}`), {
|
||||
driver: "sqlite-wasm",
|
||||
persistent: true,
|
||||
version,
|
||||
});
|
||||
} catch (err) {
|
||||
// No OPFS (Node, a private window, an old browser). An in-memory
|
||||
// database still runs every query, so the app works for the session.
|
||||
console.warn("[db] OPFS unavailable, falling back to in-memory:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return new SqliteWasmDb(new sqlite3.oo1.DB(":memory:", "c"), {
|
||||
driver: "sqlite-wasm",
|
||||
persistent: false,
|
||||
version,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── the raw calls, already inside the lock ── */
|
||||
|
||||
private rawAll<T>(sql: string, params: Params): T[] {
|
||||
const rows: unknown[] = [];
|
||||
this.db.exec({ sql, bind: params as SqlValue[], rowMode: "object", resultRows: rows });
|
||||
return rows as T[];
|
||||
}
|
||||
|
||||
private rawRun(sql: string, params: Params): void {
|
||||
this.db.exec({ sql, bind: params as SqlValue[] });
|
||||
}
|
||||
|
||||
/* ── the Db interface ── */
|
||||
|
||||
all<T = Row>(sql: string, params: Params = []): Promise<T[]> {
|
||||
return this.lock.run(async () => this.rawAll<T>(sql, params));
|
||||
}
|
||||
|
||||
get<T = Row>(sql: string, params: Params = []): Promise<T | undefined> {
|
||||
return this.lock.run(async () => this.rawAll<T>(sql, params)[0]);
|
||||
}
|
||||
|
||||
run(sql: string, params: Params = []): Promise<void> {
|
||||
return this.lock.run(async () => {
|
||||
this.rawRun(sql, params);
|
||||
});
|
||||
}
|
||||
|
||||
exec(sql: string): Promise<void> {
|
||||
return this.lock.run(async () => {
|
||||
this.db.exec({ sql });
|
||||
});
|
||||
}
|
||||
|
||||
async tx<T>(fn: (tx: Db) => Promise<T>): Promise<T> {
|
||||
// A nested tx joins the enclosing one; SQLite has no true nesting here
|
||||
// and a savepoint would only complicate rollback for no gain.
|
||||
if (this.depth > 0) return fn(this.unlocked());
|
||||
|
||||
return this.lock.run(async () => {
|
||||
this.depth = 1;
|
||||
this.rawRun("BEGIN", []);
|
||||
try {
|
||||
const out = await fn(this.unlocked());
|
||||
this.rawRun("COMMIT", []);
|
||||
return out;
|
||||
} catch (err) {
|
||||
try {
|
||||
this.rawRun("ROLLBACK", []);
|
||||
} catch {
|
||||
/* the transaction was already gone; the original error is the one
|
||||
worth reporting */
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
this.depth = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** The same database without the mutex — handed to a tx callback, which
|
||||
already holds the lock. Re-acquiring it would deadlock. */
|
||||
private unlocked(): Db {
|
||||
return {
|
||||
all: async <T,>(sql: string, params: Params = []) => this.rawAll<T>(sql, params),
|
||||
get: async <T,>(sql: string, params: Params = []) => this.rawAll<T>(sql, params)[0],
|
||||
run: async (sql: string, params: Params = []) => {
|
||||
this.rawRun(sql, params);
|
||||
},
|
||||
exec: async (sql: string) => {
|
||||
this.db.exec({ sql });
|
||||
},
|
||||
tx: <R,>(fn: (tx: Db) => Promise<R>) => this.tx(fn),
|
||||
close: () => this.close(),
|
||||
} as Db;
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.lock.run(async () => {
|
||||
this.db.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
94
app/src/db/sqlite.native.ts
Normal file
94
app/src/db/sqlite.native.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/* Android driver — the Capacitor SQLite plugin behind the same interface.
|
||||
|
||||
Same schema, same SQL strings, same migration array as the web driver. The
|
||||
only differences are connection setup and the plugin's own transaction
|
||||
API: its run()/execute() auto-commit unless told otherwise, so every
|
||||
statement issued inside tx() passes transaction=false and lets the
|
||||
explicit BEGIN/COMMIT own the boundary. */
|
||||
|
||||
import { CapacitorSQLite, SQLiteConnection } from "@capacitor-community/sqlite";
|
||||
import type { SQLiteDBConnection } from "@capacitor-community/sqlite";
|
||||
import type { Db, DbInfo, Params, Row } from "./types.js";
|
||||
import { migrate } from "./migrate.js";
|
||||
import { SCHEMA_VERSION } from "./migrations.js";
|
||||
|
||||
const DB_NAME = "hankan";
|
||||
|
||||
class NativeDb implements Db {
|
||||
readonly info: DbInfo = {
|
||||
driver: "capacitor-sqlite",
|
||||
persistent: true,
|
||||
version: `capacitor-sqlite, schema ${SCHEMA_VERSION}`,
|
||||
};
|
||||
private conn: SQLiteDBConnection;
|
||||
private depth = 0;
|
||||
|
||||
constructor(conn: SQLiteDBConnection) {
|
||||
this.conn = conn;
|
||||
}
|
||||
|
||||
/** Inside a transaction the plugin must not auto-commit each statement. */
|
||||
private get autoCommit(): boolean {
|
||||
return this.depth === 0;
|
||||
}
|
||||
|
||||
async all<T = Row>(sql: string, params: Params = []): Promise<T[]> {
|
||||
const res = await this.conn.query(sql, params as unknown[]);
|
||||
return (res.values ?? []) as T[];
|
||||
}
|
||||
|
||||
async get<T = Row>(sql: string, params: Params = []): Promise<T | undefined> {
|
||||
return (await this.all<T>(sql, params))[0];
|
||||
}
|
||||
|
||||
async run(sql: string, params: Params = []): Promise<void> {
|
||||
await this.conn.run(sql, params as unknown[], this.autoCommit);
|
||||
}
|
||||
|
||||
async exec(sql: string): Promise<void> {
|
||||
await this.conn.execute(sql, this.autoCommit);
|
||||
}
|
||||
|
||||
async tx<T>(fn: (tx: Db) => Promise<T>): Promise<T> {
|
||||
if (this.depth > 0) return fn(this); // nested calls join the enclosing transaction
|
||||
|
||||
await this.conn.beginTransaction();
|
||||
this.depth = 1;
|
||||
try {
|
||||
const out = await fn(this);
|
||||
this.depth = 0;
|
||||
await this.conn.commitTransaction();
|
||||
return out;
|
||||
} catch (err) {
|
||||
this.depth = 0;
|
||||
try {
|
||||
await this.conn.rollbackTransaction();
|
||||
} catch {
|
||||
/* already gone; the original error is the one worth reporting */
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.conn.close();
|
||||
await new SQLiteConnection(CapacitorSQLite).closeConnection(DB_NAME, false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openNativeDb(): Promise<Db & { info: DbInfo }> {
|
||||
const sqlite = new SQLiteConnection(CapacitorSQLite);
|
||||
|
||||
// A connection can survive a webview reload, so adopt an existing one
|
||||
// rather than failing on "already exists".
|
||||
const existing = (await sqlite.isConnection(DB_NAME, false)).result;
|
||||
const conn = existing
|
||||
? await sqlite.retrieveConnection(DB_NAME, false)
|
||||
: await sqlite.createConnection(DB_NAME, false, "no-encryption", SCHEMA_VERSION, false);
|
||||
|
||||
if (!(await conn.isDBOpen()).result) await conn.open();
|
||||
|
||||
const db = new NativeDb(conn);
|
||||
await migrate(db);
|
||||
return db;
|
||||
}
|
||||
134
app/src/db/sqlite.web.ts
Normal file
134
app/src/db/sqlite.web.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/* Browser driver — a thin proxy over the worker in sqlite.worker.ts, so a
|
||||
bulk band insert or a wide dictionary scan never janks the tutor UI.
|
||||
|
||||
Transactions are real, not batched: the client holds a lock for the
|
||||
duration and sends BEGIN / … / COMMIT as ordinary statements. The worker
|
||||
applies messages through a single mutex in arrival order, so nothing can
|
||||
interleave into an open transaction, and a tx callback can read its own
|
||||
writes like any other Db. */
|
||||
|
||||
import type { Db, DbInfo, Params, Row } from "./types.js";
|
||||
import type { WorkerRequest, WorkerResponse } from "./sqlite.worker.js";
|
||||
|
||||
/* Omit over a union collapses it to the members' common keys, which would
|
||||
erase `sql` and `params`. Distribute it so each variant keeps its own. */
|
||||
type Request = WorkerRequest extends infer R
|
||||
? R extends { id: number }
|
||||
? Omit<R, "id">
|
||||
: never
|
||||
: never;
|
||||
|
||||
/** Serialises transactions against every other caller. */
|
||||
class Mutex {
|
||||
private tail: Promise<unknown> = Promise.resolve();
|
||||
run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const out = this.tail.then(fn, fn);
|
||||
this.tail = out.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
class WorkerDb implements Db {
|
||||
readonly info: DbInfo;
|
||||
private worker: Worker;
|
||||
private pending: Map<number, { resolve(v: unknown): void; reject(e: Error): void }>;
|
||||
private seq: number;
|
||||
private lock = new Mutex();
|
||||
private inTx = false;
|
||||
|
||||
private constructor(worker: Worker, pending: WorkerDb["pending"], seq: number, info: DbInfo) {
|
||||
this.worker = worker;
|
||||
this.pending = pending;
|
||||
this.seq = seq;
|
||||
this.info = info;
|
||||
}
|
||||
|
||||
private static post(
|
||||
worker: Worker,
|
||||
pending: WorkerDb["pending"],
|
||||
req: WorkerRequest,
|
||||
): Promise<unknown> {
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
pending.set(req.id, { resolve, reject });
|
||||
worker.postMessage(req);
|
||||
});
|
||||
}
|
||||
|
||||
private call(req: Request): Promise<unknown> {
|
||||
return WorkerDb.post(this.worker, this.pending, { ...req, id: ++this.seq } as WorkerRequest);
|
||||
}
|
||||
|
||||
static async open(): Promise<WorkerDb> {
|
||||
const worker = new Worker(new URL("./sqlite.worker.js", import.meta.url), {
|
||||
type: "module",
|
||||
name: "hankan-db",
|
||||
});
|
||||
|
||||
const pending: WorkerDb["pending"] = new Map();
|
||||
worker.addEventListener("message", (ev: MessageEvent<WorkerResponse>) => {
|
||||
const slot = pending.get(ev.data.id);
|
||||
if (!slot) return;
|
||||
pending.delete(ev.data.id);
|
||||
if (ev.data.ok) slot.resolve(ev.data.result);
|
||||
else slot.reject(new Error(ev.data.error));
|
||||
});
|
||||
worker.addEventListener("error", (ev) => {
|
||||
const err = new Error(`db worker failed: ${ev.message}`);
|
||||
for (const [, slot] of pending) slot.reject(err);
|
||||
pending.clear();
|
||||
});
|
||||
|
||||
// Opening also runs the migrations, worker-side.
|
||||
const info = (await WorkerDb.post(worker, pending, { id: 1, op: "open" })) as DbInfo;
|
||||
return new WorkerDb(worker, pending, 1, info);
|
||||
}
|
||||
|
||||
async all<T = Row>(sql: string, params: Params = []): Promise<T[]> {
|
||||
return (await this.call({ op: "all", sql, params })) as T[];
|
||||
}
|
||||
|
||||
async get<T = Row>(sql: string, params: Params = []): Promise<T | undefined> {
|
||||
return ((await this.call({ op: "get", sql, params })) as T | null) ?? undefined;
|
||||
}
|
||||
|
||||
async run(sql: string, params: Params = []): Promise<void> {
|
||||
await this.call({ op: "run", sql, params });
|
||||
}
|
||||
|
||||
async exec(sql: string): Promise<void> {
|
||||
await this.call({ op: "exec", sql });
|
||||
}
|
||||
|
||||
async tx<T>(fn: (tx: Db) => Promise<T>): Promise<T> {
|
||||
if (this.inTx) return fn(this); // nested calls join the enclosing transaction
|
||||
|
||||
return this.lock.run(async () => {
|
||||
this.inTx = true;
|
||||
await this.call({ op: "run", sql: "BEGIN", params: [] });
|
||||
try {
|
||||
const out = await fn(this);
|
||||
await this.call({ op: "run", sql: "COMMIT", params: [] });
|
||||
return out;
|
||||
} catch (err) {
|
||||
try {
|
||||
await this.call({ op: "run", sql: "ROLLBACK", params: [] });
|
||||
} catch {
|
||||
/* already gone; the original error is the one worth reporting */
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
this.inTx = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.call({ op: "close" });
|
||||
this.worker.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
export const openWebDb = (): Promise<Db & { info: DbInfo }> => WorkerDb.open();
|
||||
65
app/src/db/sqlite.worker.ts
Normal file
65
app/src/db/sqlite.worker.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/* The database lives here, off the main thread, so a bulk band insert or a
|
||||
wide dictionary scan never janks the tutor UI.
|
||||
|
||||
The protocol is deliberately tiny: one request in, one response out, keyed
|
||||
by id. Transactions are driven by the client as ordinary BEGIN / COMMIT
|
||||
statements; the core's mutex applies every message in arrival order, so
|
||||
nothing can interleave into an open transaction. */
|
||||
|
||||
import { SqliteWasmDb } from "./sqlite-wasm-core.js";
|
||||
import { migrate } from "./migrate.js";
|
||||
import type { DbInfo, Params } from "./types.js";
|
||||
|
||||
export type WorkerRequest =
|
||||
| { id: number; op: "open" }
|
||||
| { id: number; op: "all"; sql: string; params: Params }
|
||||
| { id: number; op: "get"; sql: string; params: Params }
|
||||
| { id: number; op: "run"; sql: string; params: Params }
|
||||
| { id: number; op: "exec"; sql: string }
|
||||
| { id: number; op: "close" };
|
||||
|
||||
export type WorkerResponse =
|
||||
| { id: number; ok: true; result: unknown }
|
||||
| { id: number; ok: false; error: string };
|
||||
|
||||
let dbPromise: Promise<SqliteWasmDb> | null = null;
|
||||
|
||||
async function database(): Promise<SqliteWasmDb> {
|
||||
dbPromise ??= (async () => {
|
||||
const db = await SqliteWasmDb.open();
|
||||
await migrate(db);
|
||||
return db;
|
||||
})();
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
async function handle(req: WorkerRequest): Promise<unknown> {
|
||||
const db = await database();
|
||||
switch (req.op) {
|
||||
case "open":
|
||||
return db.info satisfies DbInfo;
|
||||
case "all":
|
||||
return db.all(req.sql, req.params);
|
||||
case "get":
|
||||
return (await db.get(req.sql, req.params)) ?? null;
|
||||
case "run":
|
||||
return db.run(req.sql, req.params);
|
||||
case "exec":
|
||||
return db.exec(req.sql);
|
||||
case "close":
|
||||
return db.close();
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener("message", (ev: MessageEvent<WorkerRequest>) => {
|
||||
const req = ev.data;
|
||||
handle(req).then(
|
||||
(result) => self.postMessage({ id: req.id, ok: true, result } satisfies WorkerResponse),
|
||||
(err: unknown) =>
|
||||
self.postMessage({
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
} satisfies WorkerResponse),
|
||||
);
|
||||
});
|
||||
35
app/src/db/types.ts
Normal file
35
app/src/db/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/* One storage interface, two drivers.
|
||||
sqlite-wasm over OPFS in the browser, the Capacitor SQLite plugin on
|
||||
Android. Same schema, same queries, same migrations on both — nothing
|
||||
above this folder knows which driver it got. */
|
||||
|
||||
export type SqlValue = string | number | null | Uint8Array;
|
||||
export type Params = readonly SqlValue[];
|
||||
|
||||
/** A row as it comes back from either driver: plain object, column-keyed. */
|
||||
export type Row = Record<string, SqlValue>;
|
||||
|
||||
export interface Db {
|
||||
/** Every matching row. */
|
||||
all<T = Row>(sql: string, params?: Params): Promise<T[]>;
|
||||
/** The first row, or undefined. */
|
||||
get<T = Row>(sql: string, params?: Params): Promise<T | undefined>;
|
||||
/** One statement, no result. */
|
||||
run(sql: string, params?: Params): Promise<void>;
|
||||
/** Several statements, no parameters — migrations and bulk seeding. */
|
||||
exec(sql: string): Promise<void>;
|
||||
/**
|
||||
* Run fn inside a transaction, rolling back if it throws. Nested calls
|
||||
* join the enclosing transaction rather than opening a second one.
|
||||
*/
|
||||
tx<T>(fn: (tx: Db) => Promise<T>): Promise<T>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Which driver is actually underneath — for diagnostics and the About panel. */
|
||||
export interface DbInfo {
|
||||
driver: "sqlite-wasm" | "capacitor-sqlite";
|
||||
/** OPFS-backed, or an ephemeral in-memory database (tests, no-OPFS browsers). */
|
||||
persistent: boolean;
|
||||
version: string;
|
||||
}
|
||||
266
app/src/db/writes.ts
Normal file
266
app/src/db/writes.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/* Every mutation of user data goes through here, in one of two families.
|
||||
|
||||
════ THE TIMESTAMP RULE ════
|
||||
|
||||
The artifact had a sync bug: a fresh device stamped its own empty default
|
||||
state with the current time, which made it look NEWER than the server's
|
||||
real history, and last-write-wins duly clobbered months of progress with
|
||||
an empty seed.
|
||||
|
||||
The rule that prevents it: SEEDED OR DEFAULTED STATE NEVER CARRIES A WRITE
|
||||
TIMESTAMP. Only a genuine user edit stamps the clock.
|
||||
|
||||
Three layers enforce it:
|
||||
|
||||
1. the DDL — `updated_at INTEGER NOT NULL DEFAULT 0` on every syncable
|
||||
table, so forgetting the column is the safe failure, not the unsafe
|
||||
one (see migrations.ts);
|
||||
2. this file — seedX() helpers never mention updated_at; editX() helpers
|
||||
always set it from now();
|
||||
3. lint + test — Date.now() is banned everywhere under src/db/ except
|
||||
here, and test/db/conformance.ts asserts a freshly seeded database
|
||||
has no non-zero updated_at anywhere.
|
||||
|
||||
Sync is out of scope for this pass. The point is that when it lands, the
|
||||
schema already cannot express the bug. */
|
||||
|
||||
import type { Db, Params } from "./types.js";
|
||||
import type { Card, Grade } from "@lib/srs.js";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────
|
||||
The clock. This is the ONLY place in src/db/ that reads it.
|
||||
───────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Wall-clock milliseconds, for stamping a genuine user edit. */
|
||||
export const now = (): number => Date.now();
|
||||
|
||||
/* ═════════════════════════════════════════════════════════════════════
|
||||
SEED WRITES — defaults, first-run state, anything the user did not do.
|
||||
None of these may set updated_at; the column default of 0 is the point.
|
||||
═════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/** First-run roadmap position. Unit 1.1 is where everyone starts. */
|
||||
export async function seedProgress(db: Db, unitId: string): Promise<void> {
|
||||
await db.run(
|
||||
"INSERT OR IGNORE INTO progress (unit_id, state, confidence) VALUES (?, 'now', 0)",
|
||||
[unitId],
|
||||
);
|
||||
}
|
||||
|
||||
/** A default preference or bookkeeping value. Does not overwrite a real one. */
|
||||
export async function seedMeta(db: Db, k: string, v: string): Promise<void> {
|
||||
await db.run("INSERT OR IGNORE INTO meta (k, v) VALUES (?, ?)", [k, v]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A card the learner is assumed to already know, or a pre-scheduled one from
|
||||
* the seed list. Deliberately unstamped: it is not something he did.
|
||||
*/
|
||||
export async function seedCard(db: Db, lemmaId: number, card: Card): Promise<void> {
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO card (lemma_id, state, ease, interval, due, reps, lapses)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[lemmaId, card.state, card.ease, card.interval, card.due, card.reps, card.lapses],
|
||||
);
|
||||
}
|
||||
|
||||
/** The tutor's opening turn, which the app supplies rather than the model. */
|
||||
export async function seedChatTurn(db: Db, role: string, body: string, at: number): Promise<void> {
|
||||
await db.run("INSERT INTO chat (role, body, created_at) VALUES (?, ?, ?)", [role, body, at]);
|
||||
}
|
||||
|
||||
/* ═════════════════════════════════════════════════════════════════════
|
||||
EDIT WRITES — a real action by the learner. Every one stamps the clock.
|
||||
═════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/** Answering a card in the review overlay. */
|
||||
export async function editCard(db: Db, lemmaId: number, card: Card): Promise<void> {
|
||||
await db.run(
|
||||
`INSERT INTO card (lemma_id, state, ease, interval, due, reps, lapses, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(lemma_id) DO UPDATE SET
|
||||
state = excluded.state, ease = excluded.ease, interval = excluded.interval,
|
||||
due = excluded.due, reps = excluded.reps, lapses = excluded.lapses,
|
||||
updated_at = excluded.updated_at`,
|
||||
[lemmaId, card.state, card.ease, card.interval, card.due, card.reps, card.lapses, now()],
|
||||
);
|
||||
}
|
||||
|
||||
/** Forgetting a card back to new, from the vocabulary tab. */
|
||||
export async function editCardReset(db: Db, lemmaId: number): Promise<void> {
|
||||
await db.run("DELETE FROM card WHERE lemma_id = ?", [lemmaId]);
|
||||
}
|
||||
|
||||
/** The tutor's ::progress read, or the learner moving the unit by hand. */
|
||||
export async function editUnitConfidence(
|
||||
db: Db,
|
||||
unitId: string,
|
||||
confidence: number,
|
||||
): Promise<void> {
|
||||
await db.run(
|
||||
`INSERT INTO progress (unit_id, state, confidence, updated_at)
|
||||
VALUES (?, 'now', ?, ?)
|
||||
ON CONFLICT(unit_id) DO UPDATE SET confidence = excluded.confidence,
|
||||
updated_at = excluded.updated_at`,
|
||||
[unitId, Math.max(0, Math.min(100, Math.round(confidence))), now()],
|
||||
);
|
||||
}
|
||||
|
||||
/** Marking a unit done / current / not-started. */
|
||||
export async function editUnitState(
|
||||
db: Db,
|
||||
unitId: string,
|
||||
state: "todo" | "now" | "done",
|
||||
): Promise<void> {
|
||||
await db.run(
|
||||
`INSERT INTO progress (unit_id, state, confidence, updated_at)
|
||||
VALUES (?, ?, 0, ?)
|
||||
ON CONFLICT(unit_id) DO UPDATE SET state = excluded.state,
|
||||
updated_at = excluded.updated_at`,
|
||||
[unitId, state, now()],
|
||||
);
|
||||
}
|
||||
|
||||
/** A preference the learner changed. */
|
||||
export async function editMeta(db: Db, k: string, v: string): Promise<void> {
|
||||
await db.run(
|
||||
`INSERT INTO meta (k, v, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(k) DO UPDATE SET v = excluded.v, updated_at = excluded.updated_at`,
|
||||
[k, v, now()],
|
||||
);
|
||||
}
|
||||
|
||||
/** A turn the learner sent, or a reply he received. */
|
||||
export async function editChatTurn(db: Db, role: string, body: string): Promise<void> {
|
||||
const t = now();
|
||||
await db.run(
|
||||
"INSERT INTO chat (role, body, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
||||
[role, body, t, t],
|
||||
);
|
||||
}
|
||||
|
||||
/** Wipe the transcript. Progress and cards are untouched. */
|
||||
export async function editChatClear(db: Db): Promise<void> {
|
||||
await db.run("DELETE FROM chat");
|
||||
}
|
||||
|
||||
/** Trim the transcript. The artifact kept the last 26 turns. */
|
||||
export async function editChatTrim(db: Db, keep: number): Promise<void> {
|
||||
await db.run(
|
||||
`DELETE FROM chat WHERE id NOT IN (SELECT id FROM chat ORDER BY id DESC LIMIT ?)`,
|
||||
[keep],
|
||||
);
|
||||
}
|
||||
|
||||
/** Study happened today: reviews, correct answers, drill answers. */
|
||||
export async function editStudyLog(
|
||||
db: Db,
|
||||
day: number,
|
||||
delta: { reviews?: number; correct?: number; drills?: number },
|
||||
): Promise<void> {
|
||||
const { reviews = 0, correct = 0, drills = 0 } = delta;
|
||||
await db.run(
|
||||
`INSERT INTO study_log (day, reviews, correct, drills, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(day) DO UPDATE SET
|
||||
reviews = study_log.reviews + excluded.reviews,
|
||||
correct = study_log.correct + excluded.correct,
|
||||
drills = study_log.drills + excluded.drills,
|
||||
updated_at = excluded.updated_at`,
|
||||
[day, reviews, correct, drills, now()],
|
||||
);
|
||||
}
|
||||
|
||||
/** He looked a word up in the rail. Feeds the persistent underline. */
|
||||
export async function editPeek(db: Db, form: string): Promise<void> {
|
||||
await db.run(
|
||||
`INSERT INTO peek (form, count, updated_at) VALUES (?, 1, ?)
|
||||
ON CONFLICT(form) DO UPDATE SET count = peek.count + 1,
|
||||
updated_at = excluded.updated_at`,
|
||||
[form, now()],
|
||||
);
|
||||
}
|
||||
|
||||
/* ═════════════════════════════════════════════════════════════════════
|
||||
DICTIONARY WRITES — reference data from the shipped band files.
|
||||
Not user data, never synced, and rebuildable from the assets, so these
|
||||
tables carry no updated_at at all.
|
||||
═════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
export interface LemmaRow {
|
||||
id: number;
|
||||
headword: string;
|
||||
pos: string;
|
||||
freq_rank: number | null;
|
||||
level: string | null;
|
||||
gloss_en: string;
|
||||
gloss_ko: string;
|
||||
unit_band: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface SurfaceRow {
|
||||
form: string;
|
||||
lemma_id: number;
|
||||
analysis: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound-parameter ceiling for a single statement.
|
||||
*
|
||||
* sqlite-wasm is built with the modern SQLITE_MAX_VARIABLE_NUMBER of 32766,
|
||||
* but Android links the platform's SQLite, which has historically capped it
|
||||
* at 999. A multi-row INSERT sized for the browser therefore fails outright
|
||||
* on the phone — and band loading is the first thing the app does, so it
|
||||
* fails at first launch. Size every batch for the smaller limit.
|
||||
*/
|
||||
const MAX_PARAMS = 900;
|
||||
|
||||
/** Rows per statement, given how many columns each row binds. */
|
||||
const chunkFor = (columns: number) => Math.max(1, Math.floor(MAX_PARAMS / columns));
|
||||
|
||||
/** Insert one band's rows, batched to stay under the parameter ceiling. */
|
||||
export async function insertBand(
|
||||
db: Db,
|
||||
lemmas: LemmaRow[],
|
||||
surfaces: SurfaceRow[],
|
||||
): Promise<void> {
|
||||
await db.tx(async (tx) => {
|
||||
const lemmaChunk = chunkFor(9);
|
||||
for (let i = 0; i < lemmas.length; i += lemmaChunk) {
|
||||
const slice = lemmas.slice(i, i + lemmaChunk);
|
||||
const values = slice.map(() => "(?,?,?,?,?,?,?,?,?)").join(",");
|
||||
const params: Params = slice.flatMap((l) => [
|
||||
l.id,
|
||||
l.headword,
|
||||
l.pos,
|
||||
l.freq_rank,
|
||||
l.level,
|
||||
l.gloss_en,
|
||||
l.gloss_ko,
|
||||
l.unit_band,
|
||||
l.source,
|
||||
]);
|
||||
await tx.run(
|
||||
`INSERT OR REPLACE INTO lemma
|
||||
(id, headword, pos, freq_rank, level, gloss_en, gloss_ko, unit_band, source)
|
||||
VALUES ${values}`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
const surfaceChunk = chunkFor(3);
|
||||
for (let i = 0; i < surfaces.length; i += surfaceChunk) {
|
||||
const slice = surfaces.slice(i, i + surfaceChunk);
|
||||
const values = slice.map(() => "(?,?,?)").join(",");
|
||||
const params: Params = slice.flatMap((s) => [s.form, s.lemma_id, s.analysis]);
|
||||
await tx.run(
|
||||
`INSERT OR REPLACE INTO surface (form, lemma_id, analysis) VALUES ${values}`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* Re-exported so callers can grade without importing srs separately. */
|
||||
export type { Card, Grade };
|
||||
Reference in New Issue
Block a user