Retire the SQLite backend — Postgres is the only engine

The dual backend (SQLite via db.js + Postgres via schema.pg.sql) was a
maintenance foot-gun: a schema change could land on the SQLite path only and
silently 500 every read on prod (it just did, with #18/#13). Production has run
on Postgres for weeks, so SQLite is retired: ONE schema source of truth
(db/schema.pg.sql), no drift possible.

- dbx.js: default DB_BACKEND=pg; an unknown backend now fails loudly at require
  time instead of silently selecting a stale engine.
- Deleted server/db.js, server/db/sqlite.js, server/db/migrate-sqlite-to-pg.js,
  server/scripts/migrate-bizgaze-only.js (all SQLite-only, none in the runtime
  path — the running server loads db/pg.js).
- Tests (e2e, db-smoke) target Postgres now and fail-fast (skip) unless
  DATABASE_URL points at a disposable test DB — never SQLite, never prod.
- Removed the dead DB_PATH env + fixed misleading SQLite comments in the
  Dockerfile / docker-compose (kept the /data volume: it holds
  uploads/recordings/transcripts/downloads, not just the old data.db).
- CLAUDE.md: stack + repo-layout + run-locally updated for Postgres-only.

Runtime is unaffected (prod already sets DB_BACKEND=pg and pg is a prod dep);
this only removes the unused SQLite path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 08:20:26 +05:30
parent f3b6e67c19
commit ad48829337
11 changed files with 52 additions and 622 deletions
-56
View File
@@ -1,56 +0,0 @@
// One-shot data migration: copy every row from the SQLite data.db into Postgres. Run ONCE at cutover,
// with the app stopped, BEFORE switching DB_BACKEND to pg.
//
// DB_PATH=/data/data.db DATABASE_URL=postgres://user:pass@host/db node db/migrate-sqlite-to-pg.js
//
// It applies the Postgres schema first, TRUNCATEs the target tables (so a re-run re-copies cleanly), then
// bulk-inserts in FK-dependency order. audit_log.id is a GENERATED identity, so its id is not copied (PG
// assigns fresh ones — nothing references audit_log.id). Timestamps/flags are plain integers on both sides.
const fs = require('fs');
const path = require('path');
const { DatabaseSync } = require('node:sqlite');
const { Pool } = require('pg');
const SQLITE = process.env.DB_PATH || path.join(__dirname, '..', 'data.db');
if (!process.env.DATABASE_URL) { console.error('DATABASE_URL is required'); process.exit(1); }
const src = new DatabaseSync(SQLITE);
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 4 });
// Parents before children (users→teams, sessions_auth→users, machines→teams); the rest have no FKs.
const ORDER = [
'teams', 'users', 'machines', 'sessions_auth', 'audit_log', 'sessions_log', 'refresh_tokens',
'api_keys', 'webhooks', 'messages', 'message_reactions', 'attachments', 'conversations',
'conversation_members', 'call_history', 'user_aliases', 'polls', 'poll_votes', 'scheduled_meetings',
'recordings', 'push_subscriptions', 'device_tokens', 'app_installs', 'favorites',
];
async function main() {
await pool.query(fs.readFileSync(path.join(__dirname, 'schema.pg.sql'), 'utf8')); // ensure schema exists
await pool.query('TRUNCATE ' + ORDER.map((t) => '"' + t + '"').join(', ') + ' RESTART IDENTITY CASCADE');
const totals = {};
for (const table of ORDER) {
let rows = [];
try { rows = src.prepare('SELECT * FROM ' + table).all(); } catch (e) { totals[table] = 'skip(' + e.message + ')'; continue; }
if (!rows.length) { totals[table] = 0; continue; }
let cols = Object.keys(rows[0]);
if (table === 'audit_log') cols = cols.filter((c) => c !== 'id'); // GENERATED — let PG assign
const colList = cols.map((c) => '"' + c + '"').join(',');
const CHUNK = 400; // keep param count well under Postgres' 65535 limit even for wide tables
for (let i = 0; i < rows.length; i += CHUNK) {
const batch = rows.slice(i, i + CHUNK);
const values = []; const params = [];
batch.forEach((r, ri) => {
values.push('(' + cols.map((c, ci) => '$' + (ri * cols.length + ci + 1)).join(',') + ')');
cols.forEach((c) => params.push(r[c] === undefined ? null : r[c]));
});
await pool.query('INSERT INTO "' + table + '" (' + colList + ') VALUES ' + values.join(','), params);
}
totals[table] = rows.length;
}
console.log('MIGRATED rows:', JSON.stringify(totals, null, 0));
await pool.end();
}
main().catch((e) => { console.error('MIGRATION FAILED:', e && e.message); process.exit(1); });
+3 -3
View File
@@ -1,6 +1,6 @@
// PostgreSQL backend for the async DB adapter. Same interface as db/sqlite.js — prepare(sql).{get,all,run},
// exec(sql), tx(fn), init() so repos and app code are engine-agnostic. Selected by DB_BACKEND=pg;
// connection string from DATABASE_URL.
// PostgreSQL backend for the async DB adapter — the ONLY backend (SQLite retired 2026-08-12). Implements
// prepare(sql).{get,all,run}, exec(sql), tx(fn), init() so repos/app code stay engine-agnostic (the facade
// in dbx.js keeps the door open for future backends). Connection string from DATABASE_URL.
const { Pool, types } = require('pg');
const fs = require('fs');
const path = require('path');
-50
View File
@@ -1,50 +0,0 @@
// 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 };