From 74fb21ffcabde9e526a5806221be754f14f6a8b1 Mon Sep 17 00:00:00 2001 From: aliferous3 Date: Sun, 18 Oct 2026 18:19:28 +0400 Subject: [PATCH 1/2] fix: make pre-migration backups schema-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mandatory backup taken before applying a pending migration (e.g. 012_ownership.sql) previously requested every BACKUP_TABLES entry via --table, so pg_dump would fail on tables the target schema had not yet created. db:backup now reads the target's schema_migrations and resolves the dump set to BACKUP_TABLES intersected with the tables the applied migrations actually created. Resolution fails closed on schema inconsistencies: an expected table missing from the DB, or an allowlisted table present without a recorded migration, aborts the backup instead of producing a silently partial artifact. The dump remains allowlist-only — unknown tables can never be requested, an empty resolved set is refused, and the archive TOC assertions in the recovery drill are unchanged. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- docs/disaster-recovery.md | 18 +++- scripts/backup-db.mjs | 33 +++++++- scripts/backup-scope.mjs | 109 ++++++++++++++++++++++-- test/backup-schema.test.mjs | 163 ++++++++++++++++++++++++++++++++++++ test/recovery.test.mjs | 8 +- 5 files changed, 313 insertions(+), 18 deletions(-) create mode 100644 test/backup-schema.test.mjs diff --git a/docs/disaster-recovery.md b/docs/disaster-recovery.md index 672e73e..37d5c3f 100644 --- a/docs/disaster-recovery.md +++ b/docs/disaster-recovery.md @@ -57,12 +57,22 @@ migrations are the authoritative schema/security definition and are replayed first on any recovery target. `schema_migrations` is rebuilt by the runner. **The dump is allowlist-only.** `pg_dump` is invoked with repeated -`--table=public.` for exactly `BACKUP_TABLES` — never a whole-database -dump. Supabase warns that raw `pg_dump` can include platform-internal -schemas/objects; the allowlist guarantees an unknown or newly added -schema/table is *not* backed up until explicitly classified in +`--table=public.` for the schema-aware subset of `BACKUP_TABLES` — +never a whole-database dump. Supabase warns that raw `pg_dump` can include +platform-internal schemas/objects; the allowlist guarantees an unknown or +newly added schema/table is *not* backed up until explicitly classified in `scripts/backup-scope.mjs` (and CI fails loudly on any unclassified table). +**The dump is migration-aware.** Before `pg_dump` runs, `db:backup` reads the +target's `schema_migrations` and requests only the allowlisted tables the +*applied* migrations actually created (`resolveBackupTables()`). This keeps +the mandatory pre-migration backup safe when the target lags repo main — +e.g. backing up before applying `012_ownership.sql` simply excludes the +not-yet-created ownership tables. It fails closed on a broken schema: a +table the applied migrations expect but the DB lacks, or an allowlisted +table present without a recorded migration, aborts the backup rather than +producing a silently partial artifact. + ### Table classification (scripts/backup-scope.mjs is the code-level truth) | Table | Class | In backup? | Reason | diff --git a/scripts/backup-db.mjs b/scripts/backup-db.mjs index 0514e28..6848fb8 100644 --- a/scripts/backup-db.mjs +++ b/scripts/backup-db.mjs @@ -24,7 +24,7 @@ import { fileURLToPath } from 'node:url' import postgres from 'postgres' import { loadOperatorEnv, databaseUrl, pgEnv } from './operator-env.mjs' import { runPgTool } from './pg-tools.mjs' -import { BACKUP_TABLES } from './backup-scope.mjs' +import { BACKUP_TABLES, migrationFiles, resolveBackupTables } from './backup-scope.mjs' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') @@ -38,14 +38,39 @@ async function main() { const env = pgEnv(url) const sql = postgres(url, { onnotice: () => {} }) - let serverMajor + let serverMajor, applied, existing try { const [{ server_version_num }] = await sql`show server_version_num` serverMajor = Math.floor(Number(server_version_num) / 10000) + // Migration-aware backup scope: the target may lag repo main (a fresh + // pre-migration backup is taken BEFORE the pending migration runs), so + // the allowlist must be resolved against the applied schema, not + // blindly requested. Missing/unrecorded schema state fails closed. + try { + applied = (await sql`select filename from public.schema_migrations`).map((r) => r.filename) + } catch { + throw new Error('schema_migrations is missing — the target was never migrated; refusing to back up') + } + existing = (await sql` + select table_name from information_schema.tables + where table_schema = 'public'`).map((r) => r.table_name) } finally { await sql.end() } + const resolved = resolveBackupTables({ applied, existing }) + if (resolved.length === 0) { + throw new Error('resolved backup set is empty — target schema predates any backed-up table') + } + const latest = [...applied].sort().at(-1) + const repoCount = migrationFiles().length + const behind = applied.length < repoCount + console.log(`schema: ${applied.length} migrations applied (latest: ${latest || 'none'}), repo has ${repoCount}${behind ? ' — target is BEHIND repo main' : ''}`) + const skipped = BACKUP_TABLES.filter((t) => !resolved.includes(t)) + if (skipped.length) { + console.log(`note: schema-aware resolution excludes not-yet-created table(s) from this backup: ${skipped.join(', ')}`) + } + const dir = backupDir() fs.mkdirSync(dir, { recursive: true }) const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19) + 'Z' @@ -58,7 +83,7 @@ async function main() { '--data-only', '--no-owner', '--no-privileges', - ...BACKUP_TABLES.map((t) => `--table=public.${t}`), + ...resolved.map((t) => `--table=public.${t}`), ], { binaryOut: true }) fs.writeFileSync(file, stdout) @@ -72,7 +97,7 @@ async function main() { console.log(` bytes: ${fs.statSync(file).size}`) console.log(` sha256: ${sha}`) console.log(` postgres: server major ${serverMajor}`) - console.log(` tables: ${BACKUP_TABLES.length} allowlisted (${BACKUP_TABLES.join(', ')})`) + console.log(` tables: ${resolved.length} of ${BACKUP_TABLES.length} allowlisted (${resolved.join(', ')})`) console.log('store this artifact ONLY in an encrypted vault / encrypted disk / encrypted off-site location') } diff --git a/scripts/backup-scope.mjs b/scripts/backup-scope.mjs index 8d062de..4f864c8 100644 --- a/scripts/backup-scope.mjs +++ b/scripts/backup-scope.mjs @@ -1,13 +1,21 @@ // Backup scope — the single classification of every public table. // // The backup is ALLOWLIST-ONLY: pg_dump receives repeated -// --table=public. for exactly BACKUP_TABLES. Supabase warns that raw -// pg_dump can pick up platform-internal schemas/objects, so the safe -// property here is "unknown/new schema or table → NOT backed up until -// explicitly classified". backup-db.mjs, recovery-drill.mjs, the tests and -// the docs all derive from these lists; a new table added by a migration -// but not classified fails CI loudly instead of silently entering (or -// silently missing) the dump. +// --table=public. for exactly the tables resolveBackupTables() +// returns — a subset of BACKUP_TABLES valid for the target's applied +// migration state. Supabase warns that raw pg_dump can pick up +// platform-internal schemas/objects, so the safe property here is +// "unknown/new schema or table → NOT backed up until explicitly +// classified". backup-db.mjs, recovery-drill.mjs, the tests and the docs +// all derive from these lists; a new table added by a migration but not +// classified fails CI loudly instead of silently entering (or silently +// missing) the dump. + +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') // CORE — irreplaceable business data. Losing these means real user loss. export const CORE_TABLES = [ @@ -48,5 +56,90 @@ export const EPHEMERAL_TABLES = [ 'schema_migrations', // rebuilt by the migration runner, not restored ] -// The canonical dump allowlist — the ONLY tables pg_dump is asked for. +// The canonical dump allowlist — the ONLY tables pg_dump may ever be asked +// for. The set actually requested for a given target is computed by +// resolveBackupTables(). export const BACKUP_TABLES = [...CORE_TABLES, ...OPERATIONAL_TABLES] + +// ── migration-aware resolution ─────────────────────────────────────────── +// +// A target database may lag repository main (e.g. a mandatory pre-migration +// backup taken while migrations/012_ownership.sql is committed but not yet +// applied). Requesting --table for an absent table makes pg_dump fail — +// but silently skipping absent allowlisted tables would mask a broken +// schema. So resolution is: +// +// backup set = BACKUP_TABLES ∩ tables the APPLIED migrations create +// +// plus fail-closed validation: +// * a table the applied schema expects but the DB lacks → broken schema +// * an allowlisted table physically present whose migration is NOT +// recorded in schema_migrations → bookkeeping inconsistency +// +// Unknown/unclassified tables in the database never enter the result. + +// filename -> { created, dropped } Sets of tables that migration touches, +// parsed from the repo migration files — the schema source of truth. +export function migrationTables() { + const dir = path.join(root, 'migrations') + const map = new Map() + for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.sql')).sort()) { + const text = fs.readFileSync(path.join(dir, f), 'utf8') + const created = new Set() + const dropped = new Set() + for (const m of text.matchAll(/create table(?:\s+if not exists)?\s+(?:public\.)?(\w+)/gi)) created.add(m[1]) + for (const m of text.matchAll(/drop table(?:\s+if exists)?\s+(?:public\.)?(\w+)/gi)) dropped.add(m[1]) + map.set(f, { created, dropped }) + } + return map +} + +export function migrationFiles() { + return [...migrationTables().keys()] +} + +// Tables the applied migration set is expected to have created, replayed +// in filename order so drops in later migrations (e.g. 002 dropping 001's +// sync_state/daily_activity) actually remove them. +export function expectedTables(applied) { + const byMig = migrationTables() + const appliedSet = new Set(applied) + const expected = new Set() + for (const [file, { created, dropped }] of byMig) { + if (!appliedSet.has(file)) continue + for (const t of dropped) expected.delete(t) + for (const t of created) expected.add(t) + } + return expected +} + +// Exact safe table list for the target DB. +// applied — iterable of filenames recorded in public.schema_migrations +// existing — iterable of table names present in the public schema +// Returns BACKUP_TABLES filtered to the applied-schema table set, or throws. +export function resolveBackupTables({ applied, existing }) { + const appliedSet = new Set(applied) + const existingSet = new Set(existing) + const expected = expectedTables(appliedSet) + + // fail closed: applied schema claims tables the DB does not have + const missing = [...expected].filter((t) => !existingSet.has(t)) + if (missing.length) { + throw new Error( + `applied schema expects table(s) that do not exist: ${missing.join(', ')} — refusing to produce a partial backup` + ) + } + + // fail closed: an allowlisted table exists but its migration is not + // recorded — the schema and schema_migrations disagree. + const unrecorded = [...existingSet].filter( + (t) => BACKUP_TABLES.includes(t) && !expected.has(t) + ) + if (unrecorded.length) { + throw new Error( + `allowlisted table(s) present without a recorded migration: ${unrecorded.join(', ')} — refusing to guess backup scope` + ) + } + + return BACKUP_TABLES.filter((t) => expected.has(t)) +} diff --git a/test/backup-schema.test.mjs b/test/backup-schema.test.mjs new file mode 100644 index 0000000..8866d9e --- /dev/null +++ b/test/backup-schema.test.mjs @@ -0,0 +1,163 @@ +// Schema-aware backup resolution — unit tests for +// scripts/backup-scope.mjs resolveBackupTables(). Covers the transition +// window where repository main has migration 012 but a target database has +// not applied it yet: the backup must request only the tables the applied +// schema actually created, and fail closed on any broken/inconsistent +// schema state. The live end-to-end path (real Postgres) is exercised by +// scripts/recovery-drill.mjs, whose target is post-012 and therefore backs +// up all allowlisted tables. + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { + BACKUP_TABLES, + CORE_TABLES, + EPHEMERAL_TABLES, + migrationFiles, + migrationTables, + resolveBackupTables, +} from '../scripts/backup-scope.mjs' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const src = (rel) => readFileSync(path.join(ROOT, rel), 'utf8') + +const files = migrationFiles() +const byMig = migrationTables() +// replay creates/drops like expectedTables() does — includes honoring 002's +// drops of 001-era tables +const expected = (applied) => { + const out = new Set() + const set = new Set(applied) + for (const [f, { created, dropped }] of byMig) { + if (!set.has(f)) continue + for (const t of dropped) out.delete(t) + for (const t of created) out.add(t) + } + return out +} +const allCreated = expected(files) +const pre012Files = files.filter((f) => !f.startsWith('012')) +const pre012Created = expected(pre012Files) +const OWNERSHIP = ['ownership_scopes', 'ownership_contributors', 'ownership_snapshots', 'ownership_sync_state'] + +/* ── mapping sanity ──────────────────────────────────────────────────── */ + +test('migrationTables maps 001..012 to their created/dropped tables', () => { + assert.ok(files.length >= 12, `expected >=12 migration files, got ${files.length}`) + assert.deepEqual(files.at(-1), '012_ownership.sql') + for (const t of OWNERSHIP) assert.ok(byMig.get('012_ownership.sql').created.has(t), `012 must create ${t}`) + assert.ok(byMig.get('001_initial.sql').created.has('users')) + assert.ok(byMig.get('001_initial.sql').created.has('repositories')) + // 001-era tables dropped by 002 must not remain expected + assert.ok(byMig.get('002_sync_and_analytics.sql').dropped.has('sync_state')) + assert.ok(byMig.get('002_sync_and_analytics.sql').dropped.has('daily_activity')) + assert.ok(!allCreated.has('sync_state') && !allCreated.has('daily_activity'), + 'dropped tables must not be expected on the applied schema') +}) + +/* ── A. pre-012 target ───────────────────────────────────────────────── */ + +test('PRE-012 target: resolves the 10 pre-012 tables, excludes ownership + unknowns', () => { + const existing = new Set([...pre012Created, 'drill_internal', 'pg_stat_statements_info']) + const resolved = resolveBackupTables({ applied: pre012Files, existing }) + assert.equal(resolved.length, BACKUP_TABLES.length - OWNERSHIP.length) + for (const t of OWNERSHIP) assert.ok(!resolved.includes(t), `${t} must not be requested pre-012`) + assert.ok(resolved.includes('commits') && resolved.includes('users'), 'old core tables must be included') + assert.ok(!resolved.includes('drill_internal'), 'unknown table must never be requested') + assert.deepEqual(resolved, BACKUP_TABLES.filter((t) => !OWNERSHIP.includes(t)), + 'resolution must preserve allowlist ordering') +}) + +/* ── B. post-012 target ──────────────────────────────────────────────── */ + +test('POST-012 target: all 14 allowlisted tables resolve', () => { + const resolved = resolveBackupTables({ applied: files, existing: allCreated }) + assert.deepEqual(resolved, BACKUP_TABLES) + assert.equal(resolved.length, 14) +}) + +/* ── C. broken post-012 target fails closed ──────────────────────────── */ + +test('BROKEN post-012: recorded 012 + missing ownership table fails closed', () => { + for (const t of OWNERSHIP) { + const existing = new Set([...allCreated].filter((x) => x !== t)) + assert.throws( + () => resolveBackupTables({ applied: files, existing }), + new RegExp(t), + `missing ${t} with 012 applied must fail`, + ) + } +}) + +/* ── D. unknown tables never enter the backup ────────────────────────── */ + +test('UNKNOWN public tables are never included regardless of schema state', () => { + const withJunk = new Set([...allCreated, 'unclassified_secret_table', 'pg_toast_thing']) + const resolved = resolveBackupTables({ applied: files, existing: withJunk }) + assert.deepEqual(resolved, BACKUP_TABLES) + assert.ok(!resolved.includes('unclassified_secret_table')) +}) + +/* ── E. required old table missing fails closed ──────────────────────── */ + +test('missing core table under applied schema fails closed', () => { + for (const t of ['users', 'repositories', 'commits', 'repo_sync']) { + const existing = new Set([...allCreated].filter((x) => x !== t)) + assert.throws( + () => resolveBackupTables({ applied: files, existing }), + new RegExp(t), + `missing ${t} must fail closed`, + ) + } +}) + +/* ── bookkeeping inconsistencies fail closed ─────────────────────────── */ + +test('allowlisted table present without recorded migration fails closed', () => { + // 012 tables exist on disk but 012 is not in schema_migrations — the + // schema and the bookkeeping disagree; never silently skip the data. + const existing = new Set(allCreated) + assert.throws( + () => resolveBackupTables({ applied: pre012Files, existing }), + /without a recorded migration/, + ) +}) + +test('unmigrated target (schema_migrations empty) resolves nothing and is refused', () => { + // With no applied files, expected set is empty — an existing allowlisted + // table would be "unrecorded" and throw, but a truly empty DB resolves + // to zero tables which backup-db.mjs refuses outright. + assert.throws(() => resolveBackupTables({ applied: [], existing: ['users'] }), /without a recorded migration/) + const resolved = resolveBackupTables({ applied: [], existing: [] }) + assert.deepEqual(resolved, []) +}) + +test('applied filenames not present in repo migrations are tolerated', () => { + // a target one migration AHEAD of the checkout: unknown applied filename + // contributes no expected tables; everything still consistent. + const resolved = resolveBackupTables({ applied: [...files, '099_future.sql'], existing: allCreated }) + assert.deepEqual(resolved, BACKUP_TABLES) +}) + +/* ── integration surface ─────────────────────────────────────────────── */ + +test('backup-db.mjs resolves against the live DB and refuses an empty set', () => { + const b = src('scripts/backup-db.mjs') + assert.match(b, /select filename from public\.schema_migrations/) + assert.match(b, /information_schema\.tables/) + assert.match(b, /resolveBackupTables\(\{ applied, existing \}\)/) + assert.match(b, /resolved\.length === 0/) + assert.match(b, /resolved\.map\(\(t\) => `--table=public\.\$\{t\}`\)/) + // metadata reporting without secrets + assert.match(b, /migrations applied/) + assert.doesNotMatch(b, /console\.log[^\n]*(DATABASE_URL|password|PGPASSWORD)/i) +}) + +test('ephemeral tables still never enter the resolved set', () => { + const resolved = resolveBackupTables({ applied: files, existing: allCreated }) + for (const t of EPHEMERAL_TABLES) assert.ok(!resolved.includes(t)) +}) diff --git a/test/recovery.test.mjs b/test/recovery.test.mjs index ca3691a..ca3443a 100644 --- a/test/recovery.test.mjs +++ b/test/recovery.test.mjs @@ -65,9 +65,13 @@ test('backup script puts credentials in env, never argv', () => { assert.doesNotMatch(b, /process\.env\.(SUPABASE_DB_PASSWORD|DATABASE_URL)\s*\+/); // no value concatenation into args }); -test('backup is allowlist-only — explicit --table per BACKUP_TABLES, no exclusion-list', () => { +test('backup is allowlist-only — explicit --table per resolved table, no exclusion-list', () => { const b = src('scripts/backup-db.mjs'); - assert.match(b, /BACKUP_TABLES\.map\(.*--table=public\./s, 'must emit --table=public. per allowlisted table'); + // --table args are emitted only from the schema-aware resolved subset of + // BACKUP_TABLES — never the full DB, never an exclusion list + assert.match(b, /resolveBackupTables/, 'must resolve the backup set against the applied schema'); + assert.match(b, /resolved\.map\(.*--table=public\./s, 'must emit --table=public. per resolved allowlisted table'); + assert.match(b, /resolved\.length === 0/, 'must refuse to dump when nothing allowlisted exists'); assert.doesNotMatch(b, /--exclude-table/, 'exclusion-list model silently backs up unclassified tables'); assert.equal(BACKUP_TABLES.length, 14); for (const t of ['users', 'github_installations', 'repositories', 'repository_languages', From ecfd96af41ae024a73b09f3972888c9700cedaba Mon Sep 17 00:00:00 2001 From: aliferous3 Date: Sun, 18 Oct 2026 18:29:18 +0400 Subject: [PATCH 2/2] fix: fail closed on ledger divergence and non-table relations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fail-closed hardenings to schema-aware backup resolution: - an applied migration filename unknown to this checkout now aborts the backup ("target database schema is ahead of this repository checkout") instead of being tolerated — an older tool cannot know whether its allowlist is still complete - the applied ledger must be a contiguous prefix of the repo migration list; gaps or skipped migrations abort before any table resolution - the live schema query restricts to BASE TABLE relations, so a view can never satisfy a required backup table Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- scripts/backup-db.mjs | 4 ++- scripts/backup-scope.mjs | 39 ++++++++++++++++++-- test/backup-schema.test.mjs | 71 ++++++++++++++++++++++++++++++++++--- 3 files changed, 106 insertions(+), 8 deletions(-) diff --git a/scripts/backup-db.mjs b/scripts/backup-db.mjs index 6848fb8..cb94c74 100644 --- a/scripts/backup-db.mjs +++ b/scripts/backup-db.mjs @@ -51,9 +51,11 @@ async function main() { } catch { throw new Error('schema_migrations is missing — the target was never migrated; refusing to back up') } + // BASE TABLE only — a view must never satisfy a required backup table. existing = (await sql` select table_name from information_schema.tables - where table_schema = 'public'`).map((r) => r.table_name) + where table_schema = 'public' + and table_type = 'BASE TABLE'`).map((r) => r.table_name) } finally { await sql.end() } diff --git a/scripts/backup-scope.mjs b/scripts/backup-scope.mjs index 4f864c8..6854038 100644 --- a/scripts/backup-scope.mjs +++ b/scripts/backup-scope.mjs @@ -98,6 +98,37 @@ export function migrationFiles() { return [...migrationTables().keys()] } +// The applied set must be EXACTLY a prefix of this checkout's ordered +// migration list: +// * an applied filename unknown to this checkout → the target schema is +// ahead of the code — an older backup tool cannot know whether its +// allowlist is still complete, so it must fail closed +// * a skipped known migration (a gap) → broken migration bookkeeping +// Lagging repo main is legitimate and valid. +export function validateAppliedMigrationPrefix(applied) { + const repoFiles = migrationFiles() + const repoSet = new Set(repoFiles) + const seen = new Set() + for (const f of applied) { + if (!repoSet.has(f)) { + throw new Error( + `target database schema is ahead of this repository checkout ` + + `(unknown applied migration ${f}); update the repository before creating a backup` + ) + } + seen.add(f) + } + const prefix = repoFiles.slice(0, seen.size) + const missing = prefix.filter((f) => !seen.has(f)) + if (missing.length) { + throw new Error( + `applied migrations are not a contiguous prefix of the repo list — ` + + `missing ${missing.join(', ')}; refusing to reason about a gapped schema` + ) + } + return true +} + // Tables the applied migration set is expected to have created, replayed // in filename order so drops in later migrations (e.g. 002 dropping 001's // sync_state/daily_activity) actually remove them. @@ -115,10 +146,14 @@ export function expectedTables(applied) { // Exact safe table list for the target DB. // applied — iterable of filenames recorded in public.schema_migrations -// existing — iterable of table names present in the public schema +// existing — iterable of BASE TABLE names present in the public schema // Returns BACKUP_TABLES filtered to the applied-schema table set, or throws. export function resolveBackupTables({ applied, existing }) { - const appliedSet = new Set(applied) + // The applied ledger must be a contiguous prefix of this checkout's + // migration list before any resolution happens. + const appliedList = [...applied] + validateAppliedMigrationPrefix(appliedList) + const appliedSet = new Set(appliedList) const existingSet = new Set(existing) const expected = expectedTables(appliedSet) diff --git a/test/backup-schema.test.mjs b/test/backup-schema.test.mjs index 8866d9e..bc301a0 100644 --- a/test/backup-schema.test.mjs +++ b/test/backup-schema.test.mjs @@ -20,6 +20,7 @@ import { migrationFiles, migrationTables, resolveBackupTables, + validateAppliedMigrationPrefix, } from '../scripts/backup-scope.mjs' const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') @@ -127,6 +128,60 @@ test('allowlisted table present without recorded migration fails closed', () => ) }) +test('applied filename unknown to this checkout fails closed (ahead-of-checkout)', () => { + // a target one migration AHEAD of the checkout must never be backed up — + // the older tool cannot know whether its allowlist is still complete + assert.throws( + () => resolveBackupTables({ applied: [...files, '013_future.sql'], existing: allCreated }), + /ahead of this repository checkout/, + ) + assert.throws( + () => resolveBackupTables({ applied: [...files, '999_anything.sql'], existing: allCreated }), + /ahead of this repository checkout/, + ) +}) + +/* ── contiguous-prefix validation ────────────────────────────────────── */ + +test('prefix validator: exact prefixes pass, gaps and unknowns fail', () => { + // A. exact 001..011 — legitimate lag + assert.equal(validateAppliedMigrationPrefix(pre012Files), true) + // B. exact 001..012 — fully migrated + assert.equal(validateAppliedMigrationPrefix(files), true) + // C. gap: 005 missing but later migrations applied + const gapped = files.filter((f) => !f.startsWith('005')) + assert.throws(() => validateAppliedMigrationPrefix(gapped), /contiguous prefix/) + // D. 012 recorded while 011 missing + const noEleven = files.filter((f) => !f.startsWith('011')) + assert.throws(() => validateAppliedMigrationPrefix(noEleven), /contiguous prefix/) + // E. unknown future migration + assert.throws(() => validateAppliedMigrationPrefix([...files, '013_future.sql']), /ahead of this repository checkout/) +}) + +test('resolver refuses a gapped ledger before resolving any tables', () => { + const gapped = files.filter((f) => !f.startsWith('005')) + assert.throws( + () => resolveBackupTables({ applied: gapped, existing: allCreated }), + /contiguous prefix/, + ) +}) + +/* ── BASE TABLE requirement ──────────────────────────────────────────── */ + +test('a required name present only as a view fails closed', () => { + // backup-db.mjs collects BASE TABLE rows only, so a view named like a + // required table cannot satisfy the schema check — simulate it by the + // name being absent from the base-table set. + const existing = new Set([...allCreated].filter((x) => x !== 'commits')) + assert.throws( + () => resolveBackupTables({ applied: files, existing }), + /commits/, + 'view-only commits must not satisfy the check', + ) + assert.match(src('scripts/backup-db.mjs'), /table_type = 'BASE TABLE'/, + 'the live schema query must restrict to BASE TABLE') +}) + test('unmigrated target (schema_migrations empty) resolves nothing and is refused', () => { // With no applied files, expected set is empty — an existing allowlisted // table would be "unrecorded" and throw, but a truly empty DB resolves @@ -136,11 +191,17 @@ test('unmigrated target (schema_migrations empty) resolves nothing and is refuse assert.deepEqual(resolved, []) }) -test('applied filenames not present in repo migrations are tolerated', () => { - // a target one migration AHEAD of the checkout: unknown applied filename - // contributes no expected tables; everything still consistent. - const resolved = resolveBackupTables({ applied: [...files, '099_future.sql'], existing: allCreated }) - assert.deepEqual(resolved, BACKUP_TABLES) +test('the exact production transition: pre-012 then post-012', () => { + // checkout has 001..012; production applied exactly 001..011 with the + // ownership tables absent — the real mandatory pre-migration backup + assert.equal(validateAppliedMigrationPrefix(pre012Files), true) + const pre = resolveBackupTables({ applied: pre012Files, existing: pre012Created }) + assert.deepEqual(pre, BACKUP_TABLES.filter((t) => !OWNERSHIP.includes(t))) + assert.equal(pre.length, 10) + // after 012 is applied, all 14 resolve + assert.equal(validateAppliedMigrationPrefix(files), true) + const post = resolveBackupTables({ applied: files, existing: allCreated }) + assert.equal(post.length, 14) }) /* ── integration surface ─────────────────────────────────────────────── */