diff --git a/app/src/db/index.ts b/app/src/db/index.ts new file mode 100644 index 0000000..c1e8db8 --- /dev/null +++ b/app/src/db/index.ts @@ -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 | null = null; + +/** Open the database, running migrations. Safe to call repeatedly. */ +export function openDb(): Promise { + 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 { + if (!handle) return; + const db = await handle; + handle = null; + await db.close(); +} diff --git a/app/src/db/migrate.ts b/app/src/db/migrate.ts new file mode 100644 index 0000000..9c1fc37 --- /dev/null +++ b/app/src/db/migrate.ts @@ -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 { + 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 }; +} diff --git a/app/src/db/migrations.ts b/app/src/db/migrations.ts new file mode 100644 index 0000000..33c3dc1 --- /dev/null +++ b/app/src/db/migrations.ts @@ -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 + ); +`; diff --git a/app/src/db/sqlite-wasm-core.ts b/app/src/db/sqlite-wasm-core.ts new file mode 100644 index 0000000..7fa40bf --- /dev/null +++ b/app/src/db/sqlite-wasm-core.ts @@ -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 | null = null; +function loadSqlite(): Promise { + modulePromise ??= sqlite3InitModule() as unknown as Promise; + 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 = Promise.resolve(); + run(fn: () => Promise): Promise { + 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 { + 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(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(sql: string, params: Params = []): Promise { + return this.lock.run(async () => this.rawAll(sql, params)); + } + + get(sql: string, params: Params = []): Promise { + return this.lock.run(async () => this.rawAll(sql, params)[0]); + } + + run(sql: string, params: Params = []): Promise { + return this.lock.run(async () => { + this.rawRun(sql, params); + }); + } + + exec(sql: string): Promise { + return this.lock.run(async () => { + this.db.exec({ sql }); + }); + } + + async tx(fn: (tx: Db) => Promise): Promise { + // 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 (sql: string, params: Params = []) => this.rawAll(sql, params), + get: async (sql: string, params: Params = []) => this.rawAll(sql, params)[0], + run: async (sql: string, params: Params = []) => { + this.rawRun(sql, params); + }, + exec: async (sql: string) => { + this.db.exec({ sql }); + }, + tx: (fn: (tx: Db) => Promise) => this.tx(fn), + close: () => this.close(), + } as Db; + } + + close(): Promise { + return this.lock.run(async () => { + this.db.close(); + }); + } +} diff --git a/app/src/db/sqlite.native.ts b/app/src/db/sqlite.native.ts new file mode 100644 index 0000000..4918467 --- /dev/null +++ b/app/src/db/sqlite.native.ts @@ -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(sql: string, params: Params = []): Promise { + const res = await this.conn.query(sql, params as unknown[]); + return (res.values ?? []) as T[]; + } + + async get(sql: string, params: Params = []): Promise { + return (await this.all(sql, params))[0]; + } + + async run(sql: string, params: Params = []): Promise { + await this.conn.run(sql, params as unknown[], this.autoCommit); + } + + async exec(sql: string): Promise { + await this.conn.execute(sql, this.autoCommit); + } + + async tx(fn: (tx: Db) => Promise): Promise { + 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 { + await this.conn.close(); + await new SQLiteConnection(CapacitorSQLite).closeConnection(DB_NAME, false); + } +} + +export async function openNativeDb(): Promise { + 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; +} diff --git a/app/src/db/sqlite.web.ts b/app/src/db/sqlite.web.ts new file mode 100644 index 0000000..8a70727 --- /dev/null +++ b/app/src/db/sqlite.web.ts @@ -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 + : never + : never; + +/** Serialises transactions against every other caller. */ +class Mutex { + private tail: Promise = Promise.resolve(); + run(fn: () => Promise): Promise { + 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; + 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 { + return new Promise((resolve, reject) => { + pending.set(req.id, { resolve, reject }); + worker.postMessage(req); + }); + } + + private call(req: Request): Promise { + return WorkerDb.post(this.worker, this.pending, { ...req, id: ++this.seq } as WorkerRequest); + } + + static async open(): Promise { + 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) => { + 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(sql: string, params: Params = []): Promise { + return (await this.call({ op: "all", sql, params })) as T[]; + } + + async get(sql: string, params: Params = []): Promise { + return ((await this.call({ op: "get", sql, params })) as T | null) ?? undefined; + } + + async run(sql: string, params: Params = []): Promise { + await this.call({ op: "run", sql, params }); + } + + async exec(sql: string): Promise { + await this.call({ op: "exec", sql }); + } + + async tx(fn: (tx: Db) => Promise): Promise { + 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 { + await this.call({ op: "close" }); + this.worker.terminate(); + } +} + +export const openWebDb = (): Promise => WorkerDb.open(); diff --git a/app/src/db/sqlite.worker.ts b/app/src/db/sqlite.worker.ts new file mode 100644 index 0000000..2e96e73 --- /dev/null +++ b/app/src/db/sqlite.worker.ts @@ -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 | null = null; + +async function database(): Promise { + dbPromise ??= (async () => { + const db = await SqliteWasmDb.open(); + await migrate(db); + return db; + })(); + return dbPromise; +} + +async function handle(req: WorkerRequest): Promise { + 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) => { + 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), + ); +}); diff --git a/app/src/db/types.ts b/app/src/db/types.ts new file mode 100644 index 0000000..2a7799d --- /dev/null +++ b/app/src/db/types.ts @@ -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; + +export interface Db { + /** Every matching row. */ + all(sql: string, params?: Params): Promise; + /** The first row, or undefined. */ + get(sql: string, params?: Params): Promise; + /** One statement, no result. */ + run(sql: string, params?: Params): Promise; + /** Several statements, no parameters — migrations and bulk seeding. */ + exec(sql: string): Promise; + /** + * Run fn inside a transaction, rolling back if it throws. Nested calls + * join the enclosing transaction rather than opening a second one. + */ + tx(fn: (tx: Db) => Promise): Promise; + close(): Promise; +} + +/** 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; +} diff --git a/app/src/db/writes.ts b/app/src/db/writes.ts new file mode 100644 index 0000000..816a5c1 --- /dev/null +++ b/app/src/db/writes.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }; diff --git a/test/db/conformance.ts b/test/db/conformance.ts new file mode 100644 index 0000000..2b705d8 --- /dev/null +++ b/test/db/conformance.ts @@ -0,0 +1,268 @@ +/* The driver conformance suite, written once and pointed at a driver. + + CI runs it against the sqlite-wasm driver in Node. The same export can be + pointed at the Capacitor driver on a device — that is what "same schema, + same queries, same migrations on both" has to mean in practice. */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import type { Db } from "@app/db/types.js"; +import { migrate } from "@app/db/migrate.js"; +import { SCHEMA_VERSION } from "@app/db/migrations.js"; +import { + seedCard, + seedChatTurn, + seedMeta, + seedProgress, + editCard, + editMeta, + editPeek, + editStudyLog, + editUnitConfidence, + insertBand, +} from "@app/db/writes.js"; +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; + +export function conformanceSuite(name: string, open: () => Promise): void { + describe(`Db conformance — ${name}`, () => { + let db: Db; + + beforeEach(async () => { + db = await open(); + await migrate(db); + }); + afterEach(async () => { + await db.close(); + }); + + describe("migrations", () => { + it("reaches the current schema version", async () => { + const row = await db.get<{ v: string }>("SELECT v FROM meta WHERE k='schema_version'"); + expect(row?.v).toBe(String(SCHEMA_VERSION)); + }); + + it("creates every table the app expects", async () => { + const rows = await db.all<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type='table'", + ); + const names = new Set(rows.map((r) => r.name)); + for (const t of ["lemma", "surface", ...SYNCABLE]) expect(names.has(t), t).toBe(true); + }); + + it("is idempotent — running it again changes nothing", async () => { + const again = await migrate(db); + expect(again.from).toBe(SCHEMA_VERSION); + expect(again.to).toBe(SCHEMA_VERSION); + }); + }); + + describe("queries", () => { + it("round-trips every value type", async () => { + await db.run("INSERT INTO lemma (id, headword, pos, freq_rank, gloss_en, source) VALUES (?,?,?,?,?,?)", [ + 1, + "밥", + "noun", + null, + "rice", + "curated", + ]); + const row = await db.get<{ headword: string; freq_rank: number | null }>( + "SELECT headword, freq_rank FROM lemma WHERE id = ?", + [1], + ); + expect(row).toEqual({ headword: "밥", freq_rank: null }); + }); + + it("get() returns undefined when nothing matches", async () => { + expect(await db.get("SELECT 1 AS x WHERE 0")).toBeUndefined(); + }); + + it("all() returns every row, in order", async () => { + await db.exec(` + INSERT INTO lemma (id, headword, pos, freq_rank, gloss_en, source) VALUES + (1,'가','verb',10,'go','curated'), + (2,'나','pron',20,'I','curated'), + (3,'다','adv',30,'all','curated'); + `); + const rows = await db.all<{ headword: string }>( + "SELECT headword FROM lemma ORDER BY freq_rank", + ); + expect(rows.map((r) => r.headword)).toEqual(["가", "나", "다"]); + }); + }); + + describe("transactions", () => { + it("commits on success", async () => { + await db.tx(async (tx) => { + await tx.run("INSERT INTO meta (k, v) VALUES ('a', '1')"); + await tx.run("INSERT INTO meta (k, v) VALUES ('b', '2')"); + }); + const rows = await db.all("SELECT k FROM meta WHERE k IN ('a','b')"); + expect(rows).toHaveLength(2); + }); + + it("rolls back everything when the callback throws", async () => { + await expect( + db.tx(async (tx) => { + await tx.run("INSERT INTO meta (k, v) VALUES ('c', '3')"); + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + expect(await db.get("SELECT k FROM meta WHERE k='c'")).toBeUndefined(); + }); + + it("sees its own writes inside the transaction", async () => { + const seen = await db.tx(async (tx) => { + await tx.run("INSERT INTO meta (k, v) VALUES ('d', '4')"); + return tx.get<{ v: string }>("SELECT v FROM meta WHERE k='d'"); + }); + expect(seen?.v).toBe("4"); + }); + + it("survives a rollback and keeps working", async () => { + await db.tx(async (tx) => tx.run("INSERT INTO meta (k,v) VALUES ('e','5')")).catch(() => {}); + await expect( + db.tx(async (tx) => { + await tx.run("INSERT INTO meta (k,v) VALUES ('f','6')"); + throw new Error("x"); + }), + ).rejects.toThrow(); + await db.run("INSERT INTO meta (k, v) VALUES ('g', '7')"); + expect((await db.get<{ v: string }>("SELECT v FROM meta WHERE k='g'"))?.v).toBe("7"); + }); + }); + + describe("bulk band insert", () => { + it("loads lemma and surface rows together", async () => { + await insertBand( + db, + [ + { + id: 100, + headword: "먹다", + pos: "verb", + freq_rank: 42, + level: "초급", + gloss_en: "to eat", + gloss_ko: "", + unit_band: 1, + source: "curated", + }, + ], + [ + { form: "먹어", lemma_id: 100, analysis: "반말, from 먹다" }, + { form: "먹었어", lemma_id: 100, analysis: "반말 past, from 먹다" }, + ], + ); + const hit = await db.get<{ headword: string; analysis: string }>( + `SELECT l.headword, s.analysis FROM surface s + JOIN lemma l ON l.id = s.lemma_id WHERE s.form = ?`, + ["먹었어"], + ); + expect(hit?.headword).toBe("먹다"); + expect(hit?.analysis).toContain("past"); + }); + + it("is re-runnable — reloading a band does not duplicate", async () => { + const lemma = { + id: 200, + headword: "물", + pos: "noun", + freq_rank: 7, + level: null, + gloss_en: "water", + gloss_ko: "", + unit_band: 0, + source: "curated", + }; + await insertBand(db, [lemma], [{ form: "물", lemma_id: 200, analysis: "headword" }]); + await insertBand(db, [lemma], [{ form: "물", lemma_id: 200, analysis: "headword" }]); + const n = await db.get<{ n: number }>("SELECT count(*) AS n FROM lemma WHERE id = 200"); + expect(n?.n).toBe(1); + }); + }); + + /* ═══════════════════════════════════════════════════════════════ + The trap. A fresh device must never claim its empty defaults are + newer than the server's real history. + ═══════════════════════════════════════════════════════════════ */ + describe("seeded state carries no write timestamp", () => { + it("leaves updated_at at 0 across every syncable table after a full seed", async () => { + await seedProgress(db, "1.1"); + await seedMeta(db, "focus", "auto"); + await seedMeta(db, "prefs.newPerDay", "10"); + await db.run( + "INSERT INTO lemma (id, headword, pos, gloss_en, source) VALUES (1,'밥','noun','rice','curated')", + ); + await seedCard(db, 1, newCard()); + await seedChatTurn(db, "assistant", "안녕! 시작하자.", 0); + + for (const table of SYNCABLE) { + const row = await db.get<{ n: number }>( + `SELECT count(*) AS n FROM ${table} WHERE updated_at != 0`, + ); + expect(row?.n, `${table} has a stamped seed row`).toBe(0); + } + }); + + it("still writes 0 when the migration itself records the schema version", async () => { + const row = await db.get<{ updated_at: number }>( + "SELECT updated_at FROM meta WHERE k='schema_version'", + ); + expect(row?.updated_at).toBe(0); + }); + + it("but a genuine edit does stamp the clock", async () => { + await db.run( + "INSERT INTO lemma (id, headword, pos, gloss_en, source) VALUES (2,'물','noun','water','curated')", + ); + const before = Date.now(); + await editCard(db, 2, grade(newCard(), GOOD, 20_000)); + await editUnitConfidence(db, "1.1", 40); + await editMeta(db, "prefs.newPerDay", "20"); + await editStudyLog(db, 20_000, { reviews: 1, correct: 1 }); + await editPeek(db, "물"); + + for (const [table, where] of [ + ["card", "lemma_id = 2"], + ["progress", "unit_id = '1.1'"], + ["meta", "k = 'prefs.newPerDay'"], + ["study_log", "day = 20000"], + ["peek", "form = '물'"], + ] as const) { + const row = await db.get<{ updated_at: number }>( + `SELECT updated_at FROM ${table} WHERE ${where}`, + ); + expect(row?.updated_at, `${table} was not stamped`).toBeGreaterThanOrEqual(before); + } + }); + + it("an edit on top of a seeded row promotes it from 0", async () => { + await seedProgress(db, "2.1"); + const seeded = await db.get<{ updated_at: number }>( + "SELECT updated_at FROM progress WHERE unit_id='2.1'", + ); + expect(seeded?.updated_at).toBe(0); + + await editUnitConfidence(db, "2.1", 55); + const edited = await db.get<{ updated_at: number; confidence: number }>( + "SELECT updated_at, confidence FROM progress WHERE unit_id='2.1'", + ); + expect(edited?.confidence).toBe(55); + expect(edited?.updated_at).toBeGreaterThan(0); + }); + + it("seeding never overwrites a real edit", async () => { + await editUnitConfidence(db, "3.1", 90); + await seedProgress(db, "3.1"); // first-run path running again + const row = await db.get<{ confidence: number; updated_at: number }>( + "SELECT confidence, updated_at FROM progress WHERE unit_id='3.1'", + ); + expect(row?.confidence).toBe(90); + expect(row?.updated_at).toBeGreaterThan(0); + }); + }); + }); +} diff --git a/test/db/limits.test.ts b/test/db/limits.test.ts new file mode 100644 index 0000000..9e0fc9e --- /dev/null +++ b/test/db/limits.test.ts @@ -0,0 +1,127 @@ +/* Regression tests for the bugs found in the post-implementation scan. + + These are pinned because two of them are invisible on the platform they + were written on: sqlite-wasm allows 32,766 bound parameters, Android's + SQLite allows 999, so a batch sized for the browser fails only on the + phone — at first launch, loading the dictionary. */ + +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 { insertBand, type LemmaRow, type SurfaceRow } from "@app/db/writes.js"; +import { lookupMany, search } from "@app/domain/lexicon.js"; +import type { Db } from "@app/db/types.js"; + +/** The ceiling Android imposes. Nothing may exceed it in one statement. */ +const ANDROID_MAX_PARAMS = 999; + +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", +}); + +describe("bound-parameter ceiling", () => { + /** + * Records the parameter count of every statement the code issues. + * The counter is shared with any transaction handle handed to a callback — + * insertBand does all its work inside a tx, so a probe that started a fresh + * counter there would observe nothing at all. + */ + function counting(inner: Db, shared?: { max: number }): { db: Db; max: () => number } { + const state = shared ?? { max: 0 }; + const note = (params?: readonly unknown[]) => { + state.max = Math.max(state.max, params?.length ?? 0); + }; + const wrapped: Db = { + all: (sql, params) => (note(params), inner.all(sql, params)), + get: (sql, params) => (note(params), inner.get(sql, params)), + run: (sql, params) => (note(params), inner.run(sql, params)), + exec: (sql) => inner.exec(sql), + tx: (fn) => inner.tx((tx) => fn(counting(tx, state).db)), + close: () => inner.close(), + }; + return { db: wrapped, max: () => state.max }; + } + + it("keeps a band insert under Android's limit", async () => { + const lemmas = Array.from({ length: 2500 }, (_, i) => lemma(i + 1, `말${i}`)); + const surfaces: SurfaceRow[] = lemmas.map((l) => ({ + form: l.headword, + lemma_id: l.id, + analysis: "headword", + })); + + const probe = counting(db); + await insertBand(probe.db, lemmas, surfaces); + + // The probe must actually have seen the inserts, or it proves nothing. + expect(probe.max()).toBeGreaterThan(0); + expect(probe.max()).toBeLessThanOrEqual(ANDROID_MAX_PARAMS); + // …and it must still have inserted everything. + const n = await db.get<{ n: number }>("SELECT count(*) AS n FROM lemma"); + expect(n?.n).toBe(2500); + }); + + it("batches a lookup of more forms than the limit allows", async () => { + const lemmas = Array.from({ length: 1500 }, (_, i) => lemma(i + 1, `낱${i}`)); + await insertBand(db, lemmas, []); + + const probe = counting(db); + const found = await lookupMany( + probe.db, + lemmas.map((l) => l.headword), + ); + + expect(probe.max()).toBeLessThanOrEqual(ANDROID_MAX_PARAMS); + expect(found.size).toBe(1500); + }); +}); + +describe("search — LIKE wildcards in user input", () => { + beforeEach(async () => { + await insertBand( + db, + [ + { ...lemma(1, "밥"), gloss_en: "rice" }, + { ...lemma(2, "학교"), gloss_en: "school" }, + { ...lemma(3, "백"), gloss_en: "100% pure" }, + ], + [], + ); + }); + + it("treats % as a character, not a wildcard", async () => { + // Unescaped, "%" matches every row in the table. + const hits = await search(db, "%", { includeReference: true }); + expect(hits.map((h) => h.headword)).toEqual(["백"]); // the one gloss containing "%" + }); + + it("treats _ as a character, not a single-character wildcard", async () => { + const hits = await search(db, "_", { includeReference: true }); + expect(hits).toHaveLength(0); + }); + + it("still finds ordinary queries", async () => { + expect((await search(db, "밥", { includeReference: true })).map((h) => h.headword)).toEqual([ + "밥", + ]); + expect((await search(db, "school", { includeReference: true })).length).toBeGreaterThan(0); + }); +}); diff --git a/test/db/sqlite-wasm.test.ts b/test/db/sqlite-wasm.test.ts new file mode 100644 index 0000000..ac4c870 --- /dev/null +++ b/test/db/sqlite-wasm.test.ts @@ -0,0 +1,11 @@ +/* Runs the conformance suite against the sqlite-wasm driver. + + In Node there is no OPFS, so the driver falls back to an in-memory + database — same code, same SQL, same migrations as the browser's + OPFS-backed one. The Capacitor driver is exercised on a device; point + conformanceSuite() at openNativeDb() from an on-device test to do it. */ + +import { SqliteWasmDb } from "@app/db/sqlite-wasm-core.js"; +import { conformanceSuite } from "./conformance.js"; + +conformanceSuite("sqlite-wasm (in-memory)", () => SqliteWasmDb.open({ memory: true }));