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>
128 lines
4.3 KiB
TypeScript
128 lines
4.3 KiB
TypeScript
/* 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);
|
|
});
|
|
});
|