d1-sqlite.ts
Language TypeScript6.2 KB163 lines
/*** A D1 stand-in backed by real SQLite, so the migration and the queries are* exercised rather than described.** Not a hand-written fake: one that returns canned rows would pass with a schema* that does not apply, an upsert that does not upsert and a CHECK that does not* fire, and every one of those only shows up on live data. Running* `0001_checkin.sql` through an actual engine is what makes the migration file* a contract instead of a document.** Why it might not run: `node:sqlite` arrived mid-Node-22, so a contributor* on a runtime older than the engines field asks for has no SQLite.* The suite must not fail for them and must not quietly shrink either: see* `sqliteAvailable` and the skip message in checkin-store.test.ts. A test that* silently stops running is rule 8 with the volume turned down.** What it is not: D1. It has no network, no eventual consistency and no* batch isolation beyond SQLite's own, so it can prove the SQL is right and* cannot prove the deploy is.*/import { readdirSync, readFileSync } from 'node:fs'import { fileURLToPath } from 'node:url'type Any = any/*** `process.getBuiltinModule`, not `import('node:sqlite')`.** Vitest resolves imports through Vite, which strips the `node:` prefix and* then cannot find a module called `sqlite`, so a dynamic import here reports* "Does the file exist?" and every SQL test below skips, on a runtime that has* SQLite perfectly well. That is the silent-shrink failure this file's own skip* message exists to prevent, arriving through the bundler instead. This call* goes straight to the runtime and is not rewritten.*/const builtin = (process as { getBuiltinModule?: (id: string) => Any }).getBuiltinModuleconst DatabaseSync: Any = (() => {try {return builtin?.('node:sqlite')?.DatabaseSync ?? null} catch {return null}})()export const sqliteAvailable = (): boolean => DatabaseSync !== nullexport const MIGRATIONS_DIR = fileURLToPath(new URL('../migrations/', import.meta.url))/*** Every migration, in the order `wrangler d1 migrations apply` would run them.** All of them, not one hardcoded path: a harness applying only 0001 passes* every test for a feature whose table does not exist on it, so the suite would* be exercising a schema no deploy ever has. Rule 8: a tool that fails silently is worse than one that fails loudly,* and that includes our own.*/export function migrationFiles(): string[] {return readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql'))// The names are zero-padded, so a lexical sort IS the numeric one. That// assumption is pinned by a test rather than trusted: the day somebody// writes `10_x.sql`, it should fail there and not quietly apply it second..sort().map((f) => MIGRATIONS_DIR + f)}/*** One migration by filename, or all of them concatenated in apply order.** A caller naming a file is asserting something about THAT file; a caller* naming none wants the schema a deploy would have.*/export function migrationSql(which?: string): string {const files = which? migrationFiles().filter((f) => f.endsWith(which)): migrationFiles()if (!files.length) throw new Error(`no migration matching ${which}`)return files.map((f) => readFileSync(f, 'utf8')).join('\n')}/*** An in-memory database with the migration applied, shaped like the bits of* D1's interface this code actually calls.*/export function makeD1(): Any {const db = new DatabaseSync(':memory:')db.exec(migrationSql())const prepare = (sql: string): Any => {let args: unknown[] = []const stmt = {bind(...vals: unknown[]): Any {args = valsreturn stmt},first<T>(): Promise<T | null> {return Promise.resolve((db.prepare(sql).get(...args) as T) ?? null)},all(): Promise<{ results: unknown[]; success: true }> {return Promise.resolve({ results: db.prepare(sql).all(...args), success: true })},/*** D1 reports rows touched in `meta.changes`, and the private list's* updates DEPEND on that number: an UPDATE scoped by person and by a* stale-check token matches zero rows when either fails, and zero is how* the route learns to say "not yours / already moved on" instead of* "done". A stand-in that omitted it would make every one of those* refusals look like a success.*/run(): Promise<{ results: unknown[]; success: true; meta: { changes: number } }> {const r = db.prepare(sql).run(...args)return Promise.resolve({results: [],success: true,meta: { changes: Number(r.changes ?? 0) },})},/** Used only by batch(), which runs the statements itself. */__run(): { results: unknown[]; success: true } {const s = db.prepare(sql)// A SELECT answers with rows; everything else answers with none. D1// makes the same distinction and callers read `.results` either way.const results = /^\s*select/i.test(sql) ? s.all(...args) : (s.run(...args), [])return { results, success: true }},}return stmt}return {prepare,batch(stmts: Any[]): Promise<{ results: unknown[]; success: true }[]> {// One transaction, like D1's. A half-applied batch would leave the// append-only log describing some of the versions, which is not a log.db.exec('BEGIN')try {const out = stmts.map((s) => s.__run())db.exec('COMMIT')return Promise.resolve(out)} catch (err) {db.exec('ROLLBACK')return Promise.reject(err)}},/** Escape hatch for assertions that want to look at the raw rows. */__raw: db,}}/** A binding that is present and refuses every query, for the failed state. */export function makeBrokenD1(message = 'D1_ERROR: no such table: checkin'): Any {const boom = (): never => {throw new Error(message)}const stmt: Any = {bind: () => stmt,first: () => Promise.reject(new Error(message)),all: () => Promise.reject(new Error(message)),__run: boom,}return { prepare: () => stmt, batch: () => Promise.reject(new Error(message)) }}