feat(db): async DB adapter with swappable sqlite backend (Phase 1)
server/dbx.js selects a backend by DB_BACKEND (default sqlite; pg added at cutover).
server/db/sqlite.js wraps the synchronous node:sqlite instance in the async
interface repos will call — prepare(sql).{get,all,run}, exec(sql), tx(fn), init().
Results come back as resolved Promises so identical repo code runs on synchronous
SQLite (dev/test) and asynchronous Postgres (prod).
tx() gives multi-statement atomicity that stays correct on both engines (sqlite is
single-connection; the pg backend will run it on one pooled client) — needed for the
account-merge transaction in repos.
Verified: get/all/run/tx all work end-to-end; confirmed no code reads
.changes/.lastInsertRowid, so the repo conversion is purely sync->Promise. Unwired —
nothing requires dbx.js yet; prod path untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
// SQLite backend for the async DB adapter (dev + tests; also the current prod engine until pg cutover).
|
||||
//
|
||||
// Wraps the synchronous node:sqlite instance (schema applied at load in ../db.js) in the async interface
|
||||
// the repos call. Results are returned via resolved Promises, so the SAME repo code runs unchanged on this
|
||||
// synchronous engine and on asynchronous Postgres — the app never sees the difference.
|
||||
const raw = require('../db'); // DatabaseSync instance with the full schema already applied
|
||||
|
||||
// node:sqlite re-prepares cheaply, but caching by SQL text avoids re-parsing on hot paths.
|
||||
const cache = new Map();
|
||||
function stmt(sql) {
|
||||
let s = cache.get(sql);
|
||||
if (!s) { s = raw.prepare(sql); cache.set(sql, s); }
|
||||
return s;
|
||||
}
|
||||
const num = (v) => (typeof v === 'bigint' ? Number(v) : v);
|
||||
const runResult = (r) => ({ changes: num(r.changes), lastInsertRowid: num(r.lastInsertRowid) });
|
||||
|
||||
function prepare(sql) {
|
||||
return {
|
||||
get: (...p) => Promise.resolve(stmt(sql).get(...p)),
|
||||
all: (...p) => Promise.resolve(stmt(sql).all(...p)),
|
||||
run: (...p) => Promise.resolve(runResult(stmt(sql).run(...p))),
|
||||
};
|
||||
}
|
||||
|
||||
function exec(sql) { raw.exec(sql); return Promise.resolve(); }
|
||||
|
||||
// Transaction primitive. SQLite is single-connection, so BEGIN/COMMIT/ROLLBACK on `raw` is safe; the
|
||||
// callback gets a runner with the same async run/get shape. (The pg backend implements this on ONE pooled
|
||||
// client — the reason repos must use tx() rather than bare exec('BEGIN') for multi-statement atomicity.)
|
||||
async function tx(fn) {
|
||||
raw.exec('BEGIN');
|
||||
try {
|
||||
const t = {
|
||||
run: (sql, ...p) => Promise.resolve(runResult(stmt(sql).run(...p))),
|
||||
get: (sql, ...p) => Promise.resolve(stmt(sql).get(...p)),
|
||||
all: (sql, ...p) => Promise.resolve(stmt(sql).all(...p)),
|
||||
};
|
||||
const out = await fn(t);
|
||||
raw.exec('COMMIT');
|
||||
return out;
|
||||
} catch (e) {
|
||||
try { raw.exec('ROLLBACK'); } catch (_) {}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function init() { return Promise.resolve(); } // schema already applied synchronously in ../db.js
|
||||
|
||||
module.exports = { prepare, exec, tx, init, name: 'sqlite', _raw: raw };
|
||||
@@ -0,0 +1,6 @@
|
||||
// Async DB adapter facade. The backend is chosen by DB_BACKEND (default 'sqlite'); 'pg' is added at
|
||||
// cutover. Every backend implements the same async interface — prepare(sql).{get,all,run}, exec(sql),
|
||||
// tx(fn), init() — so repos and app code are engine-agnostic. Swapping engines is one backend file, no
|
||||
// repo changes. (This is the same "never hardwire the engine" principle we'll apply to the pub/sub layer.)
|
||||
const name = process.env.DB_BACKEND || 'sqlite';
|
||||
module.exports = require('./db/' + name);
|
||||
Reference in New Issue
Block a user