/** * Migration gate for celilo#1010, over a database that already holds records. * * `0027` rebuilds `dns_internal_records` to drop the foreign key on * `provider_module_id`. A rebuild is copy, drop, rename — the most destructive * shape a migration takes — and it will run on celilo-mgr, which holds the live * internal DNS ledger including the `zone_routable_ip` view overrides the * resolver's split-horizon config is reconciled from. * * Every other test in the suite starts from an empty database and can only prove * the forward invariant. This one builds a database at the schema BEFORE the * change, puts real rows in it, and runs the real migrator over it — the same * shape as `dns-registrations-migration.test.ts`, and for the same reason: the * installed base is where the risk is, and a clean-start suite cannot see it. * * Task 2.6 asks for backup and restore proven before this runs on celilo-mgr. * This is the stronger half of that: it proves the migration PRESERVES the rows, * so a restore is the fallback rather than the plan. */ import { Database } from 'bun:sqlite'; import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { drizzle } from 'drizzle-orm/bun-sqlite'; import { migrate } from 'drizzle-orm/bun-sqlite/migrator'; import { findMigrationsFolder } from './client'; /** The last migration before this change — the schema celilo-mgr is on today. */ const LAST_LEGACY_TAG = '0026_module_integrity_version'; interface JournalEntry { idx: number; version: string; when: number; tag: string; breakpoints: boolean; } /** A migrations folder truncated at `LAST_LEGACY_TAG`, built from the real files. */ function legacyMigrationsFolder(into: string): string { const source = findMigrationsFolder(); const journal = JSON.parse(readFileSync(join(source, 'meta', '_journal.json'), 'utf8')) as { version: string; dialect: string; entries: JournalEntry[]; }; const cutoff = journal.entries.findIndex((e) => e.tag === LAST_LEGACY_TAG); if (cutoff === -1) throw new Error(`Journal has no entry for ${LAST_LEGACY_TAG}`); const kept = journal.entries.slice(0, cutoff + 1); mkdirSync(join(into, 'meta'), { recursive: true }); for (const entry of kept) { cpSync(join(source, `${entry.tag}.sql`), join(into, `${entry.tag}.sql`)); } writeFileSync( join(into, 'meta', '_journal.json'), JSON.stringify({ ...journal, entries: kept }, null, 2), ); return into; } describe('0027 over a database that already holds an internal DNS ledger', () => { let tempDir: string; let dbPath: string; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'celilo-dnsint-')); dbPath = join(tempDir, 'legacy.db'); }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); }); function openLegacyDatabase(): Database { const sqlite = new Database(dbPath, { create: true }); sqlite.run('PRAGMA foreign_keys = ON'); migrate(drizzle(sqlite), { migrationsFolder: legacyMigrationsFolder(join(tempDir, 'legacy-migrations')), }); return sqlite; } function seedModule(sqlite: Database, id: string): void { sqlite.run( 'INSERT INTO modules (id, name, source_path, version, manifest_data) VALUES (?, ?, ?, ?, ?)', [id, id, `/srv/${id}`, '1.0.0', JSON.stringify({ id })], ); } function seedLedger(sqlite: Database): void { seedModule(sqlite, 'technitium'); seedModule(sqlite, 'knot-unbound-internal'); seedModule(sqlite, 'caddy'); seedModule(sqlite, 'forgejo'); sqlite.run( 'INSERT INTO dns_internal_records (provider_module_id, consumer_module_id, host, ip, zone_routable_ip) VALUES (?, ?, ?, ?, ?)', ['technitium', 'caddy', 'auth.example.org', '192.168.0.253', '10.0.10.14'], ); sqlite.run( 'INSERT INTO dns_internal_records (provider_module_id, consumer_module_id, host, ip, zone_routable_ip) VALUES (?, ?, ?, ?, NULL)', ['technitium', 'forgejo', 'git.example.org', '192.168.0.253'], ); } function runCurrentMigrations(sqlite: Database): void { migrate(drizzle(sqlite), { migrationsFolder: findMigrationsFolder() }); } test('the rebuild carries every row across, values intact', () => { const sqlite = openLegacyDatabase(); seedLedger(sqlite); runCurrentMigrations(sqlite); const rows = sqlite .query< { host: string; ip: string; zone_routable_ip: string | null; provider_module_id: string }, [] >( 'SELECT host, ip, zone_routable_ip, provider_module_id FROM dns_internal_records ORDER BY host', ) .all(); expect(rows).toHaveLength(2); expect(rows[0]?.host).toBe('auth.example.org'); // The override is the value whose loss is silent: the resolver keeps // answering, just with an address in-zone clients cannot route to. expect(rows[0]?.zone_routable_ip).toBe('10.0.10.14'); expect(rows[1]?.zone_routable_ip).toBeNull(); // Attribution survives; only its ON DELETE action changed. expect(rows[0]?.provider_module_id).toBe('technitium'); sqlite.close(); }); test('after migrating, removing the PROVIDER no longer empties the ledger', () => { const sqlite = openLegacyDatabase(); seedLedger(sqlite); runCurrentMigrations(sqlite); sqlite.run('DELETE FROM modules WHERE id = ?', ['technitium']); expect( sqlite.query<{ c: number }, []>('SELECT COUNT(*) AS c FROM dns_internal_records').get()?.c, ).toBe(2); sqlite.close(); }); /** * The behaviour that did NOT change. Dropping a foreign key is an easy way to * lose the one you meant to keep, and nothing else would notice. */ test('the consumer cascade still fires after the rebuild', () => { const sqlite = openLegacyDatabase(); seedLedger(sqlite); runCurrentMigrations(sqlite); sqlite.run('DELETE FROM modules WHERE id = ?', ['caddy']); const left = sqlite .query<{ consumer_module_id: string }, []>( 'SELECT consumer_module_id FROM dns_internal_records', ) .all(); expect(left.map((r) => r.consumer_module_id)).toEqual(['forgejo']); sqlite.close(); }); /** The unique index is recreated by the rebuild, not left behind with the old table. */ test('the provider+host uniqueness survives the rebuild', () => { const sqlite = openLegacyDatabase(); seedLedger(sqlite); runCurrentMigrations(sqlite); expect(() => sqlite.run( 'INSERT INTO dns_internal_records (provider_module_id, consumer_module_id, host, ip) VALUES (?, ?, ?, ?)', ['technitium', 'forgejo', 'auth.example.org', '192.168.0.253'], ), ).toThrow(); sqlite.close(); }); });