sqlite-idempotent-migration-adapter-chain
FED-001 lives at ~/sqlite-idempotent-migration-adapter-chain/. The fix is a ledger-based migrator + guarded primitive helpers written against a minimal 4-method adapter contract (all/run/exec/close), so one migration set runs unchanged on all four backends.
Adapter contract (src/adapter.js) — PRAGMA table_info is read through adapter.all, which works on all 4 backends:
// better-sqlite3 / node:sqlite (identical bodies)
all(sql, params = []) { return db.prepare(sql).all(...params); }
// bun:sqlite
all(sql, params = []) { return db.query(sql).all(...params); }
// sql.js (prepare/step/getAsObject loop — no column quirk)
all(sql, params = []) { const s = db.prepare(sql); try {
if (params.length) s.bind(params);
const rows = []; while (s.step()) rows.push(s.getAsObject()); return rows;
} finally { s.free(); } }
Guarded primitives (src/guard.js) — the idempotency core. Names are regex-validated before interpolation because PRAGMA can't take bound params on every backend:
export function columnExists(adapter, table, column) {
assertIdentifier(table, 'table name'); assertIdentifier(column, 'column name');
return adapter.all(`PRAGMA table_info(${table})`) // [] if column missing
.some((row) => String(row.name) === column);
}
export function addColumnIfMissing(adapter, table, column, columnDdl) {
if (!columnExists(adapter, table, column))
adapter.run(`ALTER TABLE ${table} ADD COLUMN ${columnDdl}`);
}
export function insertOrIgnore(adapter, table, row) {
/* INSERT OR IGNORE ... VALUES (?...) — PK/UNIQUE makes seeds idempotent */
}
Migration 002 (src/migrations.js) — guarded ALTERs, CREATE TABLE IF NOT EXISTS, INSERT OR IGNORE seeds:
export const migration002 = {
id: '002-federation',
up(adapter) {
addColumnIfMissing(adapter, 'apps', 'federation_role', `federation_role TEXT NOT NULL DEFAULT 'origin'`);
addColumnIfMissing(adapter, 'apps', 'federation_hub', 'federation_hub TEXT');
addColumnIfMissing(adapter, 'models', 'federation_enabled', 'federation_enabled INTEGER NOT NULL DEFAULT 0');
addColumnIfMissing(adapter, 'providers', 'federation_priority', 'federation_priority INTEGER NOT NULL DEFAULT 0');
addColumnIfMissing(adapter, 'routers', 'federation_zone', `federation_zone TEXT NOT NULL DEFAULT 'local'`);
addColumnIfMissing(adapter, 'kv', 'ttl_seconds', 'ttl_seconds INTEGER');
createTableIfNotExists(adapter, `CREATE TABLE IF NOT EXISTS federation_peers (
id TEXT PRIMARY KEY, peer_url TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'edge',
health INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')))`);
insertOrIgnore(adapter, 'kv', { scope: 'pricing', key: 'default', value: '{"per_million_tokens":0.50,"currency":"USD"}' });
insertOrIgnore(adapter, 'kv', { scope: 'modelAliases', key: 'hub', value: 'http://localhost:8787' });
insertOrIgnore(adapter, 'federation_peers', { id: 'self', peer_url: 'http://localhost:8787', role: 'hub', health: 1 });
},
};
Spec mapping (8 logical → 7 physical): physical = apps models providers routes deployments routers kv; modelAliases and pricing are kv scope rows (kv.scope), never tables.
Migrator: _migrations(id, applied_at) ledger; each migration runs once, in a BEGIN/COMMIT transaction with the ledger entry — a failed migration rolls back atomically.
Verified by `drift.test.js` (34 test executions, 0 failures): | Backend | Runtime | Result | |---|---|---| | `better-sqlite3` v13 | node 22.22.3 | 8/8 ok | | `node:sqlite` (built-in) | node 22.22.3 | 8/8 ok | | `sql.js` (wasm) | node 22.22.3 | 8/8 ok | | `bun:sqlite` | bun 1.3.14 | 8/8 ok | | parity (node↔sql.js vs better-sqlite3) | node | 2/2 ok | Drift test (two fresh boots, 001-only vs 001+002) — the diff is **exactly** the federation additions, nothing removed, nothing extra: ``` apps + federation_role, federation_hub models + federation_enabled providers + federation_priority routers + federation_zone kv + ttl_seconds federation_peers + id, peer_url, role, health, created_at (removed columns: none) ``` Edge cases tested on every backend: - **Re-apply 002 directly** (no ledger) → guarded ALTERs no-op; full snapshot (columns, types, defaults, seed rows, `updated_at`) byte-identical; `pricing`/`self` seed counts stay 1. - **Partial state recovery** — `apps.federation_role` pre-applied outside the ledger → full migrate() converges without "duplicate column name", final schema correct. - **`migrate()` twice** → ledger = `['001-baseline','002-federation']` exactly once each, in order. - **Seed idempotency** — 3× `INSERT OR IGNORE` with different values → 1 row, first value wins. - **Injection guard** — `'apps; DROP TABLE apps'` / `'x; DROP'` rejected by `assertIdentifier`; tables survive. - **Cross-backend parity** — all adapters converge to the identical column layout. One environment note: running the *full* suite under bun crashes Bun 1.3.14 while loading better-sqlite3's native addon (NAPI fatal error, not a code bug) — so `ONLY_KIND=bun:sqlite bun drift.test.js` runs the bun backend in isolation (added the `ONLY_KIND` filter for exactly this). Full suite: `node drift.test.js`.
{"model": "deepseek-v4-flash", "problem_class": "sqlite-idempotent-migration-adapter-chain", "result": "passed", "tests": 34}FED-001 lives at ~/sqlite-idempotent-migration-adapter-chain/. The fix is a ledger-based migrator + guarded primitive helpers written against a minimal 4-method adapter contract (all/run/exec/close), so one migration set runs unchanged on all four backends.
Adapter contract (src/adapter.js) — PRAGMA table_info is read through adapter.all, which works on all 4 backends:
// better-sqlite3 / node:sqlite (identical bodies)
all(sql, params = []) { return db.prepare(sql).all(...params); }
// bun:sqlite
all(sql, params = []) { return db.query(sql).all(...params); }
// sql.js (prepare/step/getAsObject loop — no column quirk)
all(sql, params = []) { const s = db.prepare(sql); try {
if (params.length) s.bind(params);
const rows = []; while (s.step()) rows.push(s.getAsObject()); return rows;
} finally { s.free(); } }
Guarded primitives (src/guard.js) — the idempotency core. Names are regex-validated before interpolation because PRAGMA can't take bound params on every backend:
export function columnExists(adapter, table, column) {
assertIdentifier(table, 'table name'); assertIdentifier(column, 'column name');
return adapter.all(`PRAGMA table_info(${table})`) // [] if column missing
.some((row) => String(row.name) === column);
}
export function addColumnIfMissing(adapter, table, column, columnDdl) {
if (!columnExists(adapter, table, column))
adapter.run(`ALTER TABLE ${table} ADD COLUMN ${columnDdl}`);
}
export function insertOrIgnore(adapter, table, row) {
/* INSERT OR IGNORE ... VALUES (?...) — PK/UNIQUE makes seeds idempotent */
}
Migration 002 (src/migrations.js) — guarded ALTERs, CREATE TABLE IF NOT EXISTS, INSERT OR IGNORE seeds:
export const migration002 = {
id: '002-federation',
up(adapter) {
addColumnIfMissing(adapter, 'apps', 'federation_role', `federation_role TEXT NOT NULL DEFAULT 'origin'`);
addColumnIfMissing(adapter, 'apps', 'federation_hub', 'federation_hub TEXT');
addColumnIfMissing(adapter, 'models', 'federation_enabled', 'federation_enabled INTEGER NOT NULL DEFAULT 0');
addColumnIfMissing(adapter, 'providers', 'federation_priority', 'federation_priority INTEGER NOT NULL DEFAULT 0');
addColumnIfMissing(adapter, 'routers', 'federation_zone', `federation_zone TEXT NOT NULL DEFAULT 'local'`);
addColumnIfMissing(adapter, 'kv', 'ttl_seconds', 'ttl_seconds INTEGER');
createTableIfNotExists(adapter, `CREATE TABLE IF NOT EXISTS federation_peers (
id TEXT PRIMARY KEY, peer_url TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'edge',
health INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')))`);
insertOrIgnore(adapter, 'kv', { scope: 'pricing', key: 'default', value: '{"per_million_tokens":0.50,"currency":"USD"}' });
insertOrIgnore(adapter, 'kv', { scope: 'modelAliases', key: 'hub', value: 'http://localhost:8787' });
insertOrIgnore(adapter, 'federation_peers', { id: 'self', peer_url: 'http://localhost:8787', role: 'hub', health: 1 });
},
};
Spec mapping (8 logical → 7 physical): physical = apps models providers routes deployments routers kv; modelAliases and pricing are kv scope rows (kv.scope), never tables.
Migrator: _migrations(id, applied_at) ledger; each migration runs once, in a BEGIN/COMMIT transaction with the ledger entry — a failed migration rolls back atomically.
Verified by `drift.test.js` (34 test executions, 0 failures): | Backend | Runtime | Result | |---|---|---| | `better-sqlite3` v13 | node 22.22.3 | 8/8 ok | | `node:sqlite` (built-in) | node 22.22.3 | 8/8 ok | | `sql.js` (wasm) | node 22.22.3 | 8/8 ok | | `bun:sqlite` | bun 1.3.14 | 8/8 ok | | parity (node↔sql.js vs better-sqlite3) | node | 2/2 ok | Drift test (two fresh boots, 001-only vs 001+002) — the diff is **exactly** the federation additions, nothing removed, nothing extra: ``` apps + federation_role, federation_hub models + federation_enabled providers + federation_priority routers + federation_zone kv + ttl_seconds federation_peers + id, peer_url, role, health, created_at (removed columns: none) ``` Edge cases tested on every backend: - **Re-apply 002 directly** (no ledger) → guarded ALTERs no-op; full snapshot (columns, types, defaults, seed rows, `updated_at`) byte-identical; `pricing`/`self` seed counts stay 1. - **Partial state recovery** — `apps.federation_role` pre-applied outside the ledger → full migrate() converges without "duplicate column name", final schema correct. - **`migrate()` twice** → ledger = `['001-baseline','002-federation']` exactly once each, in order. - **Seed idempotency** — 3× `INSERT OR IGNORE` with different values → 1 row, first value wins. - **Injection guard** — `'apps; DROP TABLE apps'` / `'x; DROP'` rejected by `assertIdentifier`; tables survive. - **Cross-backend parity** — all adapters converge to the identical column layout. One environment note: running the *full* suite under bun crashes Bun 1.3.14 while loading better-sqlite3's native addon (NAPI fatal error, not a code bug) — so `ONLY_KIND=bun:sqlite bun drift.test.js` runs the bun backend in isolation (added the `ONLY_KIND` filter for exactly this). Full suite: `node drift.test.js`.
{"model": "deepseek-v4-flash", "problem_class": "sqlite-idempotent-migration-adapter-chain", "result": "passed", "tests": 34}