/** * Tests for the standalone migration entrypoint's connection lifecycle. * * runMigrations opens its own connection via createDbClient — which is NOT * the process-wide singleton. Its teardown used to call closeDb(), which * closes the SINGLETON: a different connection. Two consequences, both bad: * the connection this call created was never closed, and a caller that had * the singleton open had it closed out from under it. * * One bun:sqlite caveat this suite deliberately does NOT paper over: closing * a drizzle-wrapped connection releases the file only once drizzle's prepared * statements are collected, so a just-closed connection can still hold locks * until the next GC. The restore path (the only in-tree caller) does not need * the file again after migrating, so the caveat is harmless there — the thing * that must hold is that the CLOSE TARGETS THE RIGHT CONNECTION. */ import { Database } from 'bun:sqlite'; import { describe, expect, it } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { resetTestDbPath } from '../test-utils/db-path'; import { closeDb, getDb } from './client'; import { runMigrations } from './migrate'; import { systemConfig } from './schema'; describe('runMigrations connection lifecycle', () => { it("does not close the caller's singleton connection", async () => { const dir = mkdtempSync(join(tmpdir(), 'celilo-migrate-close-test-')); try { const livePath = join(dir, 'celilo.db'); process.env.CELILO_DB_PATH = livePath; await runMigrations(livePath); // A caller with the singleton open (e.g. the restore CLI between swap // and resync) runs migrations on a path and keeps using ITS OWN // connection. The old teardown called closeDb(), which closes the // singleton — a different connection — so the caller's next query died // with a closed-connection error. const db = getDb(); db.insert(systemConfig).values({ key: 'probe', value: 'before' }).run(); const otherPath = join(dir, 'other.db'); await runMigrations(otherPath); // The singleton must have survived the migration on the other path. expect(() => db.select().from(systemConfig).limit(1).all()).not.toThrow(); closeDb(); } finally { closeDb(); resetTestDbPath(); rmSync(dir, { recursive: true, force: true }); } }); it('closes its own connection: the db reopens cleanly afterwards', async () => { const dir = mkdtempSync(join(tmpdir(), 'celilo-migrate-own-conn-test-')); try { const dbPath = join(dir, 'celilo.db'); await runMigrations(dbPath); // A fresh connection must be able to take the database over. The old // closeDb() closed nothing here (the singleton was null), so this // statement ran against a connection held open by nobody's bookkeeping // — released only by GC, at a time of GC's choosing. const probe = new Database(dbPath, { readonly: true }); expect(probe.query<{ journal_mode: string }, []>('PRAGMA journal_mode').get()).toBeDefined(); probe.close(); } finally { rmSync(dir, { recursive: true, force: true }); } }); });