Test that the checker still refuses a dropped table or column, a narrowed type and an edited migration, still accepts a change that DECISIONS.md names, and still reads the index rather than the worktree, so an edit to the checker cannot quietly turn it green.
Add "Migration check tests" from ntent to this repo.
Fetch https://ntent.app/r/f/check-migrations-test as plain text. Write it verbatim to scripts/check-migrations.test.mjs.
It needs check-migrations.mjs. If this repo does not have it, stop and tell me.
Then check it: node --test passes. Make the checker compare a column’s type only by name and the widening test fails.Reads https://ntent.app/r/f/check-migrations-test
Read the code
check-migrations.test.mjsscripts/check-migrations.test.mjs
1420 lines
/**
* Tests for the migration check.
*
* Every test builds a real git repository in a temp directory, with a base
* branch (`main`) and a feature branch, because the merge-base, the index and
* `git archive` are what the check is made of. Snapshots come from the real
* check-backend.mjs (PGlite), so each red test is the whole path: git, copy,
* snapshot, compare, waiver, exit code.
*
* Every config is built from DEFAULT_CONFIG, never from CONFIG. Nothing here
* touches the repository you are running from.
*
* Run: node --test scripts/check-migrations.test.mjs
*/
import test from 'node:test'
import assert from 'node:assert/strict'
import { execFileSync, spawnSync } from 'node:child_process'
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { DEFAULT_CONFIG, decisionEntries, findWaiver, isWidening, run } from './check-migrations.mjs'
const SELF = join(dirname(fileURLToPath(import.meta.url)), 'check-migrations.mjs')
const DIR = 'supabase/migrations'
const M1 = `${DIR}/20260101000000_init.sql`
const M2 = `${DIR}/20260201000000_second.sql`
const M3 = `${DIR}/20260301000000_third.sql`
const INIT = `create table public.invoices (
id uuid primary key default gen_random_uuid(),
number varchar(10) not null,
amount integer not null,
pdf_url text,
note text
);
create table public.customers (
id uuid primary key default gen_random_uuid(),
name text not null
);
`
// -------------------------------------------------------------- helpers ---
const cleanEnv = () => {
const env = { ...process.env }
for (const k of Object.keys(env)) if (k.startsWith('GIT_')) delete env[k]
for (const k of ['DATABASE_URL', 'DIRECT_URL', 'PRISMA_SCHEMA_ENGINE_BINARY', 'NTENT_REPO_ROOT']) delete env[k]
return env
}
const git = (cwd, ...args) => execFileSync('git', args, { cwd, env: cleanEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
const tmps = []
// One node_modules for every repo here, so check-backend's snapshot cache
// (node_modules/.cache, keyed by the input bytes) is shared between tests: the
// common base schema is built once, not once per test.
const SHARED_MODULES = mkdtempSync(join(tmpdir(), 'ntent-mig-modules-'))
tmps.push(SHARED_MODULES)
test.after(() => {
for (const d of tmps) rmSync(d, { recursive: true, force: true })
})
const put = (repo, path, text) => {
mkdirSync(dirname(join(repo, path)), { recursive: true })
writeFileSync(join(repo, path), text)
}
/** A repo with `main` holding the given files. */
function makeRepo(files = { [M1]: INIT }, { commit = true } = {}) {
const repo = mkdtempSync(join(tmpdir(), 'ntent-mig-test-'))
tmps.push(repo)
git(repo, 'init', '-q', '-b', 'main')
git(repo, 'config', 'user.email', 't@ntent.test')
git(repo, 'config', 'user.name', 'test')
git(repo, 'config', 'commit.gpgsign', 'false')
put(repo, '.gitignore', 'node_modules\n')
symlinkSync(SHARED_MODULES, join(repo, 'node_modules'), 'dir')
for (const [p, t] of Object.entries(files)) put(repo, p, t)
if (commit) {
git(repo, 'add', '-A')
git(repo, 'commit', '-q', '-m', 'base')
}
return repo
}
const commitAll = (repo, message = 'change') => {
git(repo, 'add', '-A')
git(repo, 'commit', '-q', '-m', message)
}
/** A repo on a feature branch, off a base that has INIT. */
const featureRepo = (files) => {
const repo = makeRepo(files)
git(repo, 'checkout', '-q', '-b', 'feature')
return repo
}
async function check(repo, { argv = ['--check'], config = {}, backend } = {}) {
const lines = []
const errs = []
const env = process.env
process.env = cleanEnv()
try {
const code = await run({
argv,
cwd: repo,
config: { ...DEFAULT_CONFIG, ...config },
backend: backend ?? backendWith(SUPABASE),
log: (s) => lines.push(s),
err: (s) => errs.push(s),
})
return { code, out: lines.join('\n'), err: errs.join('\n') }
} finally {
process.env = env
}
}
const DECISION = (title, ...spans) => `# Decisions\n\n## ${title}\n\nWe meant it. ${spans.map((s) => `\`${s}\``).join(' and ')}\n`
// ------------------------------------------------------ pure functions ---
test('isWidening: the spec list, and nothing narrower', () => {
assert.ok(isWidening('character varying(10)', 'character varying(20)'))
assert.ok(isWidening('character varying(10)', 'text'))
assert.ok(isWidening('character varying(10)', 'character varying'))
assert.ok(isWidening('smallint', 'integer'))
assert.ok(isWidening('smallint', 'bigint'))
assert.ok(isWidening('integer', 'bigint'))
assert.ok(isWidening('numeric(10,2)', 'numeric(12,2)'))
assert.ok(!isWidening('character varying(20)', 'character varying(10)'))
assert.ok(!isWidening('text', 'character varying(10)'))
assert.ok(!isWidening('bigint', 'integer'))
assert.ok(!isWidening('integer', 'text'))
assert.ok(!isWidening('numeric(10,2)', 'numeric(12,4)'))
assert.ok(!isWidening('numeric(10,2)', 'numeric(8,2)'))
})
test('waiver matching: one entry, file and object both in backticks', () => {
const log = [
'# Decisions',
'## 2026-10-01 First',
'We dropped `invoices.pdf_url` for storage.',
'### Detail',
'In `supabase/migrations/a.sql`.',
'## 2026-10-02 Second',
'Elsewhere: `supabase/migrations/b.sql`.',
'```',
'## 2026-10-03 Example',
'`supabase/migrations/c.sql` `invoices.pdf_url`',
'```',
].join('\n')
const entries = decisionEntries(log)
assert.equal(entries.length, 2)
assert.equal(findWaiver(entries, 'supabase/migrations/a.sql', 'public.invoices.pdf_url')?.title, '## 2026-10-01 First')
assert.equal(findWaiver(entries, 'supabase/migrations/b.sql', 'public.invoices.pdf_url'), null)
assert.equal(findWaiver(entries, 'supabase/migrations/c.sql', 'public.invoices.pdf_url'), null)
assert.equal(findWaiver(entries, null, 'public.invoices.pdf_url'), null)
})
// ---------------------------------------------------------------- green ---
test('green: a migration that only adds passes', async () => {
const repo = featureRepo()
put(repo, M2, 'alter table public.invoices add column paid_at timestamptz;\ncreate table public.notes (id uuid primary key);\n')
commitAll(repo)
const r = await check(repo)
assert.equal(r.code, 0, r.out)
assert.match(r.out, /^migrations: /)
})
test('green: no migrations at the base, the branch adds the first, is not exit 2', async () => {
const repo = makeRepo({ 'README.md': 'hi\n' })
git(repo, 'checkout', '-q', '-b', 'feature')
put(repo, M1, INIT)
commitAll(repo)
const r = await check(repo)
assert.equal(r.code, 0, r.out)
})
// ---------------------------------------------------------------- codes ---
test('DROP-TABLE: red, then green once a decision names the file and the table', async () => {
const repo = featureRepo()
put(repo, M2, 'drop table public.customers;\n')
commitAll(repo)
const red = await check(repo, { argv: [] })
assert.equal(red.code, 1, red.out)
assert.match(red.out, /DROP-TABLE {2}public\.customers {2}in supabase\/migrations\/20260201000000_second\.sql/)
assert.match(red.out, /Dropping public\.customers deletes its rows/)
assert.match(red.out, /naming `supabase\/migrations\/20260201000000_second\.sql` and `public\.customers` in backticks/)
put(repo, 'DECISIONS.md', DECISION('2026-10-01 Customers move to the CRM', M2, 'customers'))
const green = await check(repo)
assert.equal(green.code, 0, green.out)
assert.match(green.out, /decided {2}DROP-TABLE public\.customers {2}in supabase\/migrations\/20260201000000_second\.sql {2}\(DECISIONS\.md: "## 2026-10-01 Customers move to the CRM"\)/)
})
test('DROP-COLUMN: red, waived by file and t.c, and a dropped table is one finding, not one per column', async () => {
const repo = featureRepo()
put(repo, M2, 'alter table public.invoices drop column pdf_url;\ndrop table public.customers;\n')
commitAll(repo)
const red = await check(repo, { argv: ['--json'] })
assert.equal(red.code, 1)
const json = JSON.parse(red.out)
assert.deepEqual(json.findings.map((f) => `${f.code} ${f.object}`).sort(), ['DROP-COLUMN public.invoices.pdf_url', 'DROP-TABLE public.customers'])
put(repo, 'DECISIONS.md', DECISION('2026-10-01 PDFs move to storage', M2, 'public.invoices.pdf_url', 'customers'))
const green = await check(repo)
assert.equal(green.code, 0, green.out)
assert.match(green.out, /decided {2}DROP-COLUMN public\.invoices\.pdf_url/)
assert.match(green.out, /decided {2}DROP-TABLE public\.customers/)
})
test('a waiver naming only the object, or only the file, or split across two entries, does not count', async () => {
const repo = featureRepo()
put(repo, M2, 'alter table public.invoices drop column pdf_url;\n')
commitAll(repo)
for (const log of [
DECISION('Object only', 'invoices.pdf_url'),
DECISION('File only', M2),
`# D\n\n## One\n\`${M2}\`\n\n## Two\n\`invoices.pdf_url\`\n`,
'# D\n\n## Prose\nWe dropped invoices.pdf_url in supabase/migrations/20260201000000_second.sql, no backticks.\n',
DECISION('Wrong migration', M1, 'invoices.pdf_url'),
'# D\n\n## Example\n```\n`' + M2 + '` `invoices.pdf_url`\n```\n',
]) {
put(repo, 'DECISIONS.md', log)
const r = await check(repo)
assert.equal(r.code, 1, log)
}
})
test('the waiver names the migration that introduces the change, found among several', async () => {
const repo = featureRepo()
put(repo, M2, 'alter table public.invoices add column tax integer;\n')
put(repo, M3, 'alter table public.invoices drop column pdf_url;\n')
commitAll(repo)
put(repo, 'DECISIONS.md', DECISION('Wrong file', M2, 'invoices.pdf_url'))
assert.equal((await check(repo)).code, 1)
put(repo, 'DECISIONS.md', DECISION('Right file', M3, 'invoices.pdf_url'))
const r = await check(repo)
assert.equal(r.code, 0, r.out)
})
test('TYPE-CHANGED: red on a narrowing, green on a widening, red again when widening is off', async () => {
const repo = featureRepo()
put(repo, M2, 'alter table public.invoices alter column note type integer using 0;\n')
commitAll(repo)
const red = await check(repo)
assert.equal(red.code, 1, red.out)
assert.match(red.out, /TYPE-CHANGED {2}public\.invoices\.note/)
assert.match(red.out, /text to integer|text on the base branch and integer here|from text to integer/)
const widen = featureRepo()
put(widen, M2, 'alter table public.invoices alter column number type varchar(20);\nalter table public.invoices alter column amount type bigint;\nalter table public.invoices alter column note type varchar(5);\n')
git(widen, 'checkout', '-q', '-b', 'x')
commitAll(widen)
// note text to varchar(5) narrows; number and amount widen.
const mixed = await check(widen, { argv: ['--json'] })
assert.deepEqual(JSON.parse(mixed.out).findings.map((f) => f.object), ['public.invoices.note'])
const wide = featureRepo()
put(wide, M2, 'alter table public.invoices alter column number type varchar(20);\nalter table public.invoices alter column amount type bigint;\n')
commitAll(wide)
assert.equal((await check(wide)).code, 0)
const strict = await check(wide, { config: { wideningAllowed: false } })
assert.equal(strict.code, 1)
assert.match(strict.out, /TYPE-CHANGED/)
})
test('NOT-NULL-ADDED: a new required column without a default fails on an existing table, not on a new one', async () => {
const red = featureRepo()
put(red, M2, 'alter table public.invoices add column status text not null;\n')
commitAll(red)
const r = await check(red)
assert.equal(r.code, 1, r.out)
assert.match(r.out, /NOT-NULL-ADDED {2}public\.invoices\.status/)
const existing = featureRepo()
put(existing, M2, 'alter table public.invoices alter column pdf_url set not null;\n')
commitAll(existing)
assert.equal((await check(existing)).code, 1)
const green = featureRepo()
put(green, M2, "alter table public.invoices add column status text not null default 'open';\ncreate table public.fresh (id uuid primary key, label text not null);\n")
commitAll(green)
assert.equal((await check(green)).code, 0)
})
test('MIGRATION-EDITED: editing or deleting a migration on the base branch is red, and no decision waives it', async () => {
const edit = featureRepo()
put(edit, M1, `${INIT}-- tidy\n`)
commitAll(edit)
put(edit, 'DECISIONS.md', DECISION('Edited', M1, 'invoices'))
const r = await check(edit)
assert.equal(r.code, 1, r.out)
assert.match(r.out, /MIGRATION-EDITED {2}supabase\/migrations\/20260101000000_init\.sql/)
assert.match(r.out, /Revert the edit and write a new migration/)
const del = featureRepo({ [M1]: INIT, [M2]: 'alter table public.invoices add column tax integer;\n' })
unlinkSync(join(del, M2))
commitAll(del)
const d = await check(del, { argv: ['--json'] })
assert.equal(d.code, 1)
assert.ok(JSON.parse(d.out).findings.some((f) => f.code === 'MIGRATION-EDITED' && f.object === M2))
})
test('a migration added on the feature branch may be edited again; the base is the merge-base', async () => {
const repo = featureRepo()
put(repo, M2, 'alter table public.invoices add column tax integer;\n')
commitAll(repo)
put(repo, M2, 'alter table public.invoices add column tax bigint;\n')
commitAll(repo)
assert.equal((await check(repo)).code, 0)
})
test('mutable files are not flagged when edited or deleted; a non-mutable one is', async () => {
const files = { [M1]: INIT, [M2]: 'alter table public.invoices add column tax integer;\n' }
const red = featureRepo(files)
put(red, M2, 'alter table public.invoices add column tax integer;\n-- reformatted\n')
commitAll(red)
assert.equal((await check(red)).code, 1)
const green = featureRepo(files)
put(green, M2, 'alter table public.invoices add column tax integer;\n-- reformatted\n')
commitAll(green)
const r = await check(green, { config: { mutable: [...DEFAULT_CONFIG.mutable, `${DIR}/*_second.sql`] } })
assert.equal(r.code, 0, r.out)
})
// ------------------------------------------------------------ base logic ---
test('the base is the merge-base: a base branch that moved on is not read as a drop', async () => {
const repo = featureRepo()
put(repo, M2, 'alter table public.invoices add column tax integer;\n')
commitAll(repo)
git(repo, 'checkout', '-q', 'main')
put(repo, M3, 'create table public.ledger (id uuid primary key);\n')
commitAll(repo, 'main moves on')
git(repo, 'checkout', '-q', 'feature')
// Against main's tip, the branch would lack public.ledger. Against the merge-base it is fine.
const r = await check(repo)
assert.equal(r.code, 0, r.out)
const tip = await check(repo, { argv: ['--check', '--against', 'main'] })
assert.equal(tip.code, 0, 'an explicit --against is still resolved to the merge-base')
})
test('on the base branch the base is HEAD: the commit being made is compared with the last one', async () => {
const repo = makeRepo()
put(repo, M2, 'drop table public.customers;\n')
git(repo, 'add', '-A')
const red = await check(repo, { argv: ['--check', '--staged'] })
assert.equal(red.code, 1, red.out)
assert.match(red.out, /DROP-TABLE/)
put(repo, 'DECISIONS.md', DECISION('Customers go', M2, 'customers'))
git(repo, 'add', '-A')
assert.equal((await check(repo, { argv: ['--check', '--staged'] })).code, 0)
})
test('--against HEAD~1 is the CI form on the base branch', async () => {
const repo = makeRepo()
put(repo, M2, 'drop table public.customers;\n')
commitAll(repo)
assert.equal((await check(repo)).code, 0, 'HEAD against HEAD: the last commit is already the base')
const r = await check(repo, { argv: ['--check', '--against', 'HEAD~1'] })
assert.equal(r.code, 1, r.out)
})
// -------------------------------------------------------------- --staged ---
test('--staged reads the index, not the worktree', async () => {
// Staged drop, worktree reverted: red.
const red = featureRepo()
put(red, M2, 'drop table public.customers;\n')
git(red, 'add', '-A')
unlinkSync(join(red, M2))
const a = await check(red, { argv: ['--check', '--staged'] })
assert.equal(a.code, 1, a.out)
assert.equal((await check(red, { argv: ['--check'] })).code, 0, 'the worktree alone has no drop')
// Staged clean, worktree has a drop: green.
const green = featureRepo()
put(green, M2, 'alter table public.invoices add column tax integer;\n')
git(green, 'add', '-A')
put(green, M2, 'drop table public.customers;\n')
assert.equal((await check(green, { argv: ['--check', '--staged'] })).code, 0)
assert.equal((await check(green, { argv: ['--check'] })).code, 1, 'the worktree has the drop')
})
test('--staged reads the decision log from the index too', async () => {
const repo = featureRepo()
put(repo, M2, 'drop table public.customers;\n')
git(repo, 'add', '-A')
put(repo, 'DECISIONS.md', DECISION('Customers go', M2, 'customers'))
assert.equal((await check(repo, { argv: ['--check', '--staged'] })).code, 1, 'the log is in the worktree only')
git(repo, 'add', 'DECISIONS.md')
assert.equal((await check(repo, { argv: ['--check', '--staged'] })).code, 0)
})
// ----------------------------------------------------------- pre-production ---
test('pre-production: findings print as notes and the check exits 0', async () => {
const repo = featureRepo()
put(repo, M2, 'drop table public.customers;\n')
commitAll(repo)
const r = await check(repo, { config: { stage: 'pre-production' } })
assert.equal(r.code, 0, r.out)
assert.match(r.out, /note {2}DROP-TABLE {2}public\.customers/)
assert.equal((await check(repo)).code, 1, 'the same repo fails in production')
})
// ------------------------------------------------------------------ exit 2 ---
test('no commits yet: exit 0 with a note', async () => {
const repo = makeRepo({ [M1]: INIT }, { commit: false })
const r = await check(repo, { argv: [] })
assert.equal(r.code, 0, r.out)
assert.match(r.out, /No commits yet/)
})
test('not a repository: exit 2', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ntent-mig-norepo-'))
tmps.push(dir)
const r = await check(dir)
assert.equal(r.code, 2, r.out)
assert.match(r.out, /NOT-A-REPO/)
})
test('a base that does not resolve is exit 2, given or defaulted', async () => {
const repo = featureRepo()
const given = await check(repo, { argv: ['--check', '--against', 'nope/branch'] })
assert.equal(given.code, 2, given.out)
assert.match(given.out, /BASE-UNRESOLVED/)
git(repo, 'branch', '-q', '-m', 'main', 'trunk')
const none = await check(repo)
assert.equal(none.code, 2, none.out)
assert.match(none.out, /BASE-UNRESOLVED/)
assert.equal((await check(repo, { config: { baseRef: 'trunk' } })).code, 0)
})
test('unrelated histories have no merge-base: exit 2', async () => {
const repo = featureRepo()
git(repo, 'checkout', '-q', '--orphan', 'other')
put(repo, 'x.txt', 'x\n')
git(repo, 'add', '-A')
git(repo, 'commit', '-q', '-m', 'orphan')
const r = await check(repo)
assert.equal(r.code, 2, r.out)
assert.match(r.out, /NO-MERGE-BASE/)
})
test('git missing: exit 2', async () => {
const repo = featureRepo()
const path = process.env.PATH
process.env.PATH = '/nonexistent'
try {
const lines = []
const code = await run({ argv: ['--check'], cwd: repo, config: { ...DEFAULT_CONFIG }, log: (s) => lines.push(s) })
assert.equal(code, 2)
assert.match(lines.join('\n'), /GIT-MISSING/)
} finally {
process.env.PATH = path
}
})
test('a snapshot that fails to build is exit 2, never "no changes"', async () => {
const repo = featureRepo()
put(repo, M2, 'this is not sql;\n')
commitAll(repo)
const r = await check(repo)
assert.equal(r.code, 2, r.out)
assert.match(r.out, /SNAPSHOT-FAILED/)
assert.match(r.out, /head/)
// A broken base, a good head.
const base = makeRepo({ [M1]: 'this is not sql;\n' })
git(base, 'checkout', '-q', '-b', 'feature')
put(base, M1, 'create table public.ok (id uuid primary key);\n')
commitAll(base)
const b = await check(base)
assert.equal(b.code, 2, b.out)
assert.match(b.out, /BASE-UNBUILDABLE {2}main @ [0-9a-f]{7}/)
assert.match(b.out, /the head does/)
assert.match(b.out, /--against <a newer ref>.*CONFIG\.baseRef/, 'it says how to go on')
// Every exit 2 names the root, the ref and the paths it looked at.
assert.match(b.out, new RegExp(`root {3}${realpathSync(base).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`))
assert.match(b.out, /base {3}main @ [0-9a-f]{7}/)
assert.match(b.out, /paths {2}supabase\/migrations\/\*\.sql|paths {2}supabase\/migrations\//)
})
test('an unreadable snapshot is exit 2 (stub backends)', async () => {
const repo = featureRepo()
put(repo, M2, 'drop table public.customers;\n')
commitAll(repo)
const stubs = mkdtempSync(join(tmpdir(), 'ntent-mig-stub-'))
tmps.push(stubs)
const stub = (name, body) => {
const path = join(stubs, name)
writeFileSync(path, body)
return path
}
const cfg = `if (process.argv.includes('--print-config')) { console.log('inputCount\\t1'); console.log('watch\\tsupabase/migrations/'); process.exit(0) }\n`
for (const [name, tail] of [
['garbage', `console.log('not json')`],
['empty', `console.log('{}')`],
['crash', `process.exit(2)`],
['silent', `process.exit(0)`],
]) {
const r = await check(repo, { backend: stub(`${name}.mjs`, `${cfg}${tail}\n`) })
assert.equal(r.code, 2, `${name}: ${r.out}`)
}
})
test('a malformed CONFIG is exit 2', async () => {
const repo = featureRepo()
for (const bad of [{ stage: 'live' }, { mutable: 'x' }, { decisionsFile: '' }, { wideningAllowed: 'yes' }]) {
const r = await check(repo, { config: bad })
assert.equal(r.code, 2, JSON.stringify(bad))
assert.match(r.out, /CONFIG-INVALID/)
}
})
// ------------------------------------------------------------------- CLI ---
test('CLI: usage errors are exit 2, --print-config lists what the hook needs, exit codes come through', () => {
const repo = featureRepo()
put(repo, M2, 'drop table public.customers;\n')
commitAll(repo)
// The script's own entry point, with the shipped CONFIG and the shipped
// check-backend sources, so an edited CONFIG in this copy changes nothing.
const cli = (...args) => spawnSync(process.execPath, [cliWrapper(), ...args], { cwd: repo, env: cleanEnv(), encoding: 'utf8' })
const typo = cli('--chekc')
assert.equal(typo.status, 2)
assert.match(typo.stdout, /USAGE/)
assert.equal(cli('--against').status, 2)
const cfg = cli('--print-config')
assert.equal(cfg.status, 0, cfg.stderr)
assert.match(cfg.stdout, /^stage\tproduction$/m)
assert.match(cfg.stdout, /^decisionsFile\tDECISIONS\.md$/m)
assert.match(cfg.stdout, /^mutable\tsupabase\/schemas\/\*\.sql$/m)
assert.match(cfg.stdout, /^watch\tsupabase\/migrations\/$/m)
const staged = cli('--check', '--staged')
assert.equal(staged.status, 1, 'nothing new is staged, so the index equals HEAD, which holds the drop')
const against = cli('--check')
assert.equal(against.status, 1, against.stdout)
assert.match(against.stdout, /DROP-TABLE/)
})
// ------------------------------------------------ 1b: command sources ---
// A check-backend whose CONFIG.sources are the ones under test. check-migrations
// spawns whatever script it is given (its `backend` seam), so this is the
// repo's own check-backend with its CONFIG edited, which is how a repo uses it.
const BACKEND_URL = pathToFileURL(join(dirname(SELF), 'check-backend.mjs')).href
const wrappers = new Map()
// The sources a repo gets from the shipped check-backend. Every test that does
// not name its own uses these, so the tests do not follow an edit to the
// CONFIG of the copy they sit beside.
const SUPABASE = [{ kind: 'sql', paths: ['supabase/migrations/*.sql'], order: 'name', sections: null }]
let cliFile = null
/** What `node scripts/check-migrations.mjs` runs, with the shipped settings instead of the copy's CONFIG. */
function cliWrapper() {
if (!cliFile) {
const dir = mkdtempSync(join(tmpdir(), 'ntent-mig-cli-'))
tmps.push(dir)
cliFile = join(dir, 'cli.mjs')
writeFileSync(
cliFile,
`import { run, DEFAULT_CONFIG } from ${JSON.stringify(pathToFileURL(SELF).href)}\nprocess.exit(await run({ argv: process.argv.slice(2), cwd: process.cwd(), config: DEFAULT_CONFIG, backend: ${JSON.stringify(backendWith(SUPABASE))} }))\n`,
)
}
return cliFile
}
function backendWith(sources) {
const body = JSON.stringify(sources)
if (!wrappers.has(body)) {
const dir = mkdtempSync(join(tmpdir(), 'ntent-mig-backend-'))
tmps.push(dir)
const file = join(dir, 'backend.mjs')
writeFileSync(
file,
`import { run, DEFAULT_CONFIG } from ${JSON.stringify(BACKEND_URL)}\nconst config = { ...structuredClone(DEFAULT_CONFIG), platform: 'supabase', sources: ${body} }\nprocess.exit(await run({ argv: process.argv.slice(2), cwd: process.cwd(), config }))\n`,
)
wrappers.set(body, file)
}
return wrappers.get(body)
}
// Stand-ins for the tools, which no test installs or needs a network for. Every
// stub is a link to one script that reads its behaviour from a JSON file beside
// the link: { log, scan, raw }. `scan` lists files or folders whose
// `sql:` comment lines it prints, so what it prints follows the schema file the
// repo holds at the time, the way the real tool's output does. `raw` prints one
// file as it is. The SQL in the fixtures is what prisma 7.10.0 and drizzle-kit
// 0.31.11 printed for equivalent schemas, as in check-backend.test.mjs.
const STUB_SOURCE = `#!/usr/bin/env node
const fs = require('node:fs')
const path = require('node:path')
const b = JSON.parse(fs.readFileSync(process.argv[1] + '.json', 'utf8'))
if (b.log) fs.appendFileSync(b.log, JSON.stringify(process.argv.slice(2)) + '\\n')
const walk = (p) => (fs.statSync(p).isDirectory() ? fs.readdirSync(p).sort().flatMap((e) => walk(path.join(p, e))) : [p])
let out = ''
for (const p of b.scan ?? []) {
if (!fs.existsSync(p)) continue
for (const f of walk(p)) for (const line of fs.readFileSync(f, 'utf8').split('\\n')) { const m = /^\\s*(?:\\/\\/|#|--)\\s*sql: ?(.*)$/.exec(line); if (m) out += m[1] + '\\n' }
}
if (b.raw && fs.existsSync(b.raw)) out += fs.readFileSync(b.raw, 'utf8')
process.stdout.write(out)
`
const STUB = join(SHARED_MODULES, 'stub.cjs')
writeFileSync(STUB, STUB_SOURCE)
chmodSync(STUB, 0o755)
/** An executable at `rel` inside `dir` that behaves as `behaviour` says. */
function stub(dir, rel, behaviour) {
const file = join(dir, rel)
mkdirSync(dirname(file), { recursive: true })
rmSync(file, { force: true })
symlinkSync(STUB, file)
writeFileSync(`${file}.json`, JSON.stringify(behaviour))
return file
}
const calls = (log) => (existsSync(log) ? readFileSync(log, 'utf8').trim().split('\n').map((l) => JSON.parse(l)) : [])
/** Lines of SQL as `// sql:` comments, which the stub prints and the real tool would ignore. */
const sqlComment = (sql, prefix = '//') => sql.trim().split('\n').map((l) => `${prefix} sql: ${l}`).join('\n')
// Prisma on Postgres. Captured once from prisma 7.10.0.
const PRISMA_SQL = (extra = ' "due" TIMESTAMP(3),\n') => `CREATE SCHEMA IF NOT EXISTS "public";
CREATE TABLE "Invoice" (
"id" UUID NOT NULL,
"number" TEXT NOT NULL,
"total" DECIMAL(65,30) NOT NULL,
${extra}
CONSTRAINT "Invoice_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Invoice_number_key" ON "Invoice"("number");
`
const PRISMA_SCHEMA = (sql) => `datasource db {
provider = "postgresql"
}
model Invoice {
id String @id
number String @unique
}
${sqlComment(sql)}
`
const PRISMA_CONFIG = `import { defineConfig } from 'prisma/config'
export default defineConfig({ schema: 'prisma/schema.prisma', datasource: { url: process.env.DATABASE_URL } })
`
const PRISMA_FILES = (sql = PRISMA_SQL()) => ({
'.gitignore': 'node_modules\ncalls.log\n',
'prisma/schema.prisma': PRISMA_SCHEMA(sql),
'prisma.config.ts': PRISMA_CONFIG,
})
// Drizzle on SQLite. Captured once from drizzle-kit 0.31.11.
const DRIZZLE_SQL = (number = 'text', extra = '') =>
['CREATE TABLE `invoices` (', '\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,', `\t\`number\` ${number} NOT NULL,`, `${extra}\t\`paid\` integer`, ');'].join('\n')
const DRIZZLE_CONFIG = `import { defineConfig } from 'drizzle-kit'
export default defineConfig({ dialect: 'sqlite', schema: './src/db/schema.ts', out: './drizzle' })
`
const DRIZZLE_FILES = (sql = DRIZZLE_SQL()) => ({
'.gitignore': 'node_modules\ncalls.log\n',
'drizzle.config.ts': DRIZZLE_CONFIG,
'src/db/schema.ts': `export const invoices = {}\n${sqlComment(sql)}\n`,
'drizzle/meta/_journal.json': '{}\n',
})
/** The tools every command-source repo finds in node_modules, as a repo that ran pnpm install would. */
function installTools() {
const log = join(SHARED_MODULES, 'calls.log')
rmSync(log, { force: true })
mkdirSync(join(SHARED_MODULES, 'prisma'), { recursive: true })
writeFileSync(join(SHARED_MODULES, 'prisma', 'package.json'), '{ "version": "7.10.0" }')
mkdirSync(join(SHARED_MODULES, '@prisma', 'engines'), { recursive: true })
writeFileSync(join(SHARED_MODULES, '@prisma', 'engines', 'schema-engine-darwin-arm64'), '')
mkdirSync(join(SHARED_MODULES, 'drizzle-kit'), { recursive: true })
writeFileSync(join(SHARED_MODULES, 'drizzle-kit', 'package.json'), '{ "version": "0.31.11" }')
stub(SHARED_MODULES, '.bin/prisma', { log, scan: ['prisma/schema.prisma'] })
stub(SHARED_MODULES, '.bin/drizzle-kit', { log, scan: ['src/db/schema.ts'] })
return log
}
const PRISMA = [{ kind: 'command', preset: 'prisma' }]
const DRIZZLE = [{ kind: 'command', preset: 'drizzle' }]
test('prisma-shaped repo: a dropped column is red naming schema.prisma, green with a decision, and the tool got the preset argv', async () => {
const log = installTools()
const repo = featureRepo(PRISMA_FILES())
put(repo, 'prisma/schema.prisma', PRISMA_SCHEMA(PRISMA_SQL('')))
commitAll(repo)
const backend = backendWith(PRISMA)
const red = await check(repo, { argv: [], backend })
assert.equal(red.code, 1, red.out)
assert.match(red.out, /DROP-COLUMN {2}public\.Invoice\.due {2}in prisma\/schema\.prisma/)
assert.doesNotMatch(red.out, /MIGRATION-EDITED/, 'a declarative schema is meant to change')
assert.match(red.out, /0 migrations, via prisma/)
assert.deepEqual(calls(log)[0], ['migrate', 'diff', '--from-empty', '--to-schema', 'prisma/schema.prisma', '--script'])
put(repo, 'DECISIONS.md', DECISION('2026-10-01 Due dates move to the ledger', 'prisma/schema.prisma', 'Invoice.due'))
const green = await check(repo, { backend })
assert.equal(green.code, 0, green.out)
assert.match(green.out, /decided {2}DROP-COLUMN public\.Invoice\.due {2}in prisma\/schema\.prisma/)
// A waiver that names neither watched file that changed does not count.
put(repo, 'DECISIONS.md', DECISION('Wrong file', 'prisma/other.prisma', 'Invoice.due'))
assert.equal((await check(repo, { backend })).code, 1)
})
test('prisma-shaped repo: adding a column, or changing only prisma.config.ts, is green; a waiver naming the config file counts too', async () => {
installTools()
const base = PRISMA_FILES()
const add = featureRepo(base)
put(add, 'prisma/schema.prisma', PRISMA_SCHEMA(PRISMA_SQL(' "due" TIMESTAMP(3),\n "memo" TEXT,\n')))
commitAll(add)
const backend = backendWith(PRISMA)
assert.equal((await check(add, { backend })).code, 0)
const config = featureRepo(base)
put(config, 'prisma.config.ts', `${PRISMA_CONFIG}// touched\n`)
commitAll(config)
assert.equal((await check(config, { backend })).code, 0)
// Schema and config both change: the decision may name either.
put(config, 'prisma/schema.prisma', PRISMA_SCHEMA(PRISMA_SQL('')))
commitAll(config)
put(config, 'DECISIONS.md', DECISION('Config and schema', 'prisma.config.ts', 'Invoice.due'))
assert.equal((await check(config, { backend })).code, 0)
})
test('prisma-shaped repo: a base that cannot be built is exit 2, not a clean pass', async () => {
const log = installTools()
const repo = makeRepo(PRISMA_FILES(PRISMA_SQL('').replace('CREATE TABLE "Invoice"', 'CREATE TABLE')))
git(repo, 'checkout', '-q', '-b', 'feature')
put(repo, 'prisma/schema.prisma', PRISMA_SCHEMA(PRISMA_SQL()))
commitAll(repo)
const r = await check(repo, { backend: backendWith(PRISMA) })
assert.equal(r.code, 2, r.out)
assert.match(r.out, /BASE-UNBUILDABLE {2}main @ [0-9a-f]{7}/)
assert.match(r.out, /LOAD-FAILED/, 'the cause is kept')
assert.ok(calls(log).length > 0)
})
test('drizzle-shaped repo on SQLite: a narrowing type change is red, a same-affinity change is green, and the journal is not a migration', async () => {
const log = installTools()
const repo = featureRepo(DRIZZLE_FILES())
put(repo, 'src/db/schema.ts', `export const invoices = {}\n${sqlComment(DRIZZLE_SQL('blob'))}\n`)
put(repo, 'drizzle/meta/_journal.json', '{ "entries": [] }\n')
commitAll(repo)
const backend = backendWith(DRIZZLE)
const red = await check(repo, { argv: [], backend })
assert.equal(red.code, 1, red.out)
assert.match(red.out, /TYPE-CHANGED {2}invoices\.number {2}in src\/db\/schema\.ts/)
assert.match(red.out, /text on the base branch and BLOB here|text on the base branch and blob here/i)
assert.doesNotMatch(red.out, /MIGRATION-EDITED/)
assert.deepEqual(calls(log)[0], ['export', '--config', 'drizzle.config.ts'])
put(repo, 'DECISIONS.md', DECISION('2026-10-01 Numbers become blobs', 'src/db/schema.ts', 'invoices.number'))
const green = await check(repo, { backend })
assert.equal(green.code, 0, green.out)
assert.match(green.out, /decided {2}TYPE-CHANGED invoices\.number/)
// SQLite does not enforce a declared length: text to varchar(20) loses nothing.
const same = featureRepo(DRIZZLE_FILES())
put(same, 'src/db/schema.ts', `export const invoices = {}\n${sqlComment(DRIZZLE_SQL('varchar(20)'))}\n`)
commitAll(same)
assert.equal((await check(same, { backend })).code, 0)
})
test('drizzle-shaped repo: a dropped table is DROP-TABLE, and integer to real is a widening', async () => {
installTools()
const repo = featureRepo(DRIZZLE_FILES())
put(repo, 'src/db/schema.ts', `export const x = {}\n${sqlComment('CREATE TABLE `other` (\n\t`id` integer PRIMARY KEY NOT NULL\n);')}\n`)
commitAll(repo)
const r = await check(repo, { argv: ['--json'], backend: backendWith(DRIZZLE) })
assert.equal(r.code, 1, r.out)
assert.deepEqual(JSON.parse(r.out).findings.map((f) => `${f.code} ${f.object}`), ['DROP-TABLE invoices'])
const wide = featureRepo(DRIZZLE_FILES())
put(wide, 'src/db/schema.ts', `export const invoices = {}\n${sqlComment(DRIZZLE_SQL().replace('`paid` integer', '`paid` real'))}\n`)
commitAll(wide)
assert.equal((await check(wide, { backend: backendWith(DRIZZLE) })).code, 0)
assert.equal((await check(wide, { backend: backendWith(DRIZZLE), config: { wideningAllowed: false } })).code, 1)
})
// SQLite from plain migration files.
const SQLITE_DIR = 'db/migrations'
const S1 = `${SQLITE_DIR}/0001_init.sql`
const S2 = `${SQLITE_DIR}/0002_next.sql`
const SQLITE_INIT = `create table invoices (
id integer primary key autoincrement,
number varchar(10) not null,
amount integer not null,
note text
);
create table customers (id integer primary key, name text not null);
`
const SQLITE = [{ kind: 'sql', paths: [`${SQLITE_DIR}/*.sql`], dialect: 'sqlite' }]
const sqliteRepo = () => featureRepo({ [S1]: SQLITE_INIT })
test('sqlite migrations: a dropped table and a dropped column are red, a decision makes them green', async () => {
const repo = sqliteRepo()
put(repo, S2, 'drop table customers;\nalter table invoices drop column note;\n')
commitAll(repo)
const backend = backendWith(SQLITE)
const red = await check(repo, { argv: ['--json'], backend })
assert.equal(red.code, 1, red.out)
assert.deepEqual(JSON.parse(red.out).findings.map((f) => `${f.code} ${f.object} ${f.file}`).sort(), [`DROP-COLUMN invoices.note ${S2}`, `DROP-TABLE customers ${S2}`])
assert.match((await check(repo, { argv: [], backend })).out, /sqlite/)
put(repo, 'DECISIONS.md', DECISION('2026-10-01 Drop customers and notes', S2, 'customers', 'invoices.note'))
const green = await check(repo, { backend })
assert.equal(green.code, 0, green.out)
assert.match(green.out, /decided {2}DROP-TABLE customers/)
})
test('sqlite migrations: columns compare by affinity. A rebuild that changes integer to text is red; text to varchar(20) and integer to real are not', async () => {
const backend = backendWith(SQLITE)
const rebuild = (type) => `create table invoices_new (id integer primary key autoincrement, number varchar(10) not null, amount ${type} not null, note text);
insert into invoices_new select * from invoices;
drop table invoices;
alter table invoices_new rename to invoices;
`
const red = sqliteRepo()
put(red, S2, rebuild('text'))
commitAll(red)
const r = await check(red, { backend })
assert.equal(r.code, 1, r.out)
assert.match(r.out, /TYPE-CHANGED {2}invoices\.amount {2}in db\/migrations\/0002_next\.sql/)
assert.doesNotMatch(r.out, /DROP-TABLE/, 'the table is back under its name')
for (const type of ['real']) {
const green = sqliteRepo()
put(green, S2, rebuild(type))
commitAll(green)
assert.equal((await check(green, { backend })).code, 0, type)
}
const length = sqliteRepo()
put(length, S2, rebuild('integer').replace('varchar(10)', 'varchar(20)'))
commitAll(length)
assert.equal((await check(length, { backend })).code, 0)
})
test('sqlite migrations: editing a committed migration is red, and a new table needs no decision', async () => {
const backend = backendWith(SQLITE)
const repo = sqliteRepo()
put(repo, S1, `${SQLITE_INIT}-- tidy\n`)
commitAll(repo)
const r = await check(repo, { backend })
assert.equal(r.code, 1, r.out)
assert.match(r.out, /MIGRATION-EDITED {2}db\/migrations\/0001_init\.sql/)
const add = sqliteRepo()
put(add, S2, 'create table notes (id integer primary key, body text);\nalter table invoices add column paid integer;\n')
commitAll(add)
assert.equal((await check(add, { backend })).code, 0)
})
test('a root-run command source (the Alembic default) builds the base from git and the head from the index, never one worktree for both', async () => {
const backend = backendWith([{ kind: 'command', run: [STUB_ROOT_RUN], dialect: 'sqlite', watch: ['db/'], cwd: 'root' }])
const repo = featureRepo({ 'db/schema.sql': SQLITE_INIT })
stub(dirname(STUB_ROOT_RUN), 'print-root', { raw: 'db/schema.sql' })
put(repo, 'db/schema.sql', 'create table invoices (id integer primary key, number varchar(10) not null);\n')
commitAll(repo)
// Base read from the worktree would see one table on both sides and pass.
const red = await check(repo, { argv: [], backend })
assert.equal(red.code, 1, red.out)
assert.match(red.out, /DROP-TABLE {2}customers {2}in db\/schema\.sql/)
assert.doesNotMatch(red.out, /MIGRATION-EDITED/)
// --staged: the index has the drop, the worktree has put it back.
const staged = featureRepo({ 'db/schema.sql': SQLITE_INIT })
put(staged, 'db/schema.sql', 'create table invoices (id integer primary key, number varchar(10) not null);\n')
git(staged, 'add', '-A')
put(staged, 'db/schema.sql', SQLITE_INIT)
assert.equal((await check(staged, { argv: ['--check', '--staged'], backend })).code, 1)
assert.equal((await check(staged, { argv: ['--check'], backend })).code, 0, 'the worktree alone has no drop')
})
// One stub for the root-run test, outside every repo (an absolute `run` path).
const STUB_ROOT_RUN = join(SHARED_MODULES, 'root-bin', 'print-root')
test('an alembic-shaped repo: versions are history, a dropped table is red, the venv is found on the copy, and editing env.py is not an edit', async () => {
const log = join(SHARED_MODULES, 'alembic.log')
rmSync(log, { force: true })
const version = (n, sql) => `# revision ${n}\n${sqlComment(sql, '#')}\n`
const files = {
'.gitignore': 'node_modules\n.venv\n',
'alembic.ini': '[alembic]\nscript_location = alembic\n',
'alembic/env.py': '# env\n',
'alembic/versions/0001_init.py': version(1, 'create table invoices (id integer primary key, note text);\ncreate table customers (id integer primary key);'),
}
const repo = featureRepo(files)
stub(repo, '.venv/bin/alembic', { log, scan: ['alembic/versions'] })
const V2 = 'alembic/versions/0002_drop.py'
put(repo, V2, version(2, 'drop table customers;'))
put(repo, 'alembic/env.py', '# env, reformatted\n')
commitAll(repo)
const backend = backendWith([{ kind: 'command', preset: 'alembic', dialect: 'sqlite' }])
const red = await check(repo, { argv: [], backend })
assert.equal(red.code, 1, red.out)
assert.match(red.out, new RegExp(`DROP-TABLE {2}customers {2}in ${V2.replace(/\./g, '\\.')}`))
assert.doesNotMatch(red.out, /MIGRATION-EDITED/, 'env.py is not a version')
assert.deepEqual(calls(log)[0], ['upgrade', 'head', '--sql'])
put(repo, 'DECISIONS.md', DECISION('Customers go', V2, 'customers'))
assert.equal((await check(repo, { backend })).code, 0)
// An edit to a version that is on the base is history rewritten.
const edit = featureRepo(files)
stub(edit, '.venv/bin/alembic', { log, scan: ['alembic/versions'] })
put(edit, 'alembic/versions/0001_init.py', version(1, 'create table invoices (id integer primary key, note text);\ncreate table customers (id integer primary key);\n-- same'))
commitAll(edit)
const e = await check(edit, { backend })
assert.equal(e.code, 1, e.out)
assert.match(e.out, /MIGRATION-EDITED {2}alembic\/versions\/0001_init\.py/)
})
test('--print-config lists a command source\'s watch paths, before the files exist, and leaves the scripts and contract out', async () => {
const repo = makeRepo({ 'README.md': 'hi\n' })
const r = await check(repo, { argv: ['--print-config'], backend: backendWith([...PRISMA, ...DRIZZLE]) })
assert.equal(r.code, 0, r.out)
const watch = r.out.split('\n').filter((l) => l.startsWith('watch\t')).map((l) => l.slice(6))
assert.ok(watch.includes('prisma/schema.prisma') && watch.includes('prisma.config.ts') && watch.includes('drizzle.config.ts'), watch.join(', '))
assert.ok(!watch.includes('contract.json') && !watch.some((p) => p.startsWith('scripts/')), watch.join(', '))
})
test('CLI: the script run directly is exit 2 on a usage error', () => {
const r = spawnSync(process.execPath, [SELF, '--chekc'], { cwd: makeRepo(), env: cleanEnv(), encoding: 'utf8' })
assert.equal(r.status, 2)
assert.match(r.stdout, /USAGE/)
})
// ------------------------------------------- a package inside a repository ---
// ntent installed in `backend/` of a larger repository: scripts/, contract.json
// and the migrations are there, and the git top level is one folder up. Taking
// the top level as the root once gave two empty snapshots and an exit 0 over a
// real DROP-COLUMN.
const PKG = 'backend'
const pkgFiles = (files) => ({ [`${PKG}/scripts/check-backend.mjs`]: '// marker\n', ...Object.fromEntries(Object.entries(files).map(([p, t]) => [`${PKG}/${p}`, t])) })
const pkgRepo = () => {
const repo = makeRepo(pkgFiles({ [M1]: INIT }))
git(repo, 'checkout', '-q', '-b', 'feature')
return { repo, pkg: join(repo, PKG) }
}
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
test('a package in a subfolder: the root is the install, not the git top level, so a dropped column is red', async () => {
const { repo, pkg } = pkgRepo()
put(pkg, M2, 'alter table public.invoices drop column pdf_url;\n')
commitAll(repo)
// Run from inside the package: the install is found above the cwd.
const inside = await check(pkg, { argv: [] })
assert.equal(inside.code, 1, inside.out)
assert.match(inside.out, new RegExp(`DROP-COLUMN {2}public\\.invoices\\.pdf_url {2}in ${escapeRe(M2)}`), 'paths are relative to the install root')
// Run from the top with --root, and with the install found from a deeper folder.
assert.equal((await check(repo, { argv: ['--root', PKG] })).code, 1)
assert.equal((await check(join(pkg, 'supabase'), { argv: [] })).code, 1)
// A decision in the package's own DECISIONS.md, naming the path from the package.
put(pkg, 'DECISIONS.md', DECISION('PDFs move to storage', M2, 'invoices.pdf_url'))
const green = await check(pkg)
assert.equal(green.code, 0, green.out)
assert.match(green.out, /decided {2}DROP-COLUMN public\.invoices\.pdf_url/)
})
test('a package in a subfolder: --staged reads the package\'s files and decision log from the index', async () => {
const { repo, pkg } = pkgRepo()
put(pkg, M2, 'alter table public.invoices drop column pdf_url;\n')
put(repo, 'other/README.md', 'unrelated\n')
git(repo, 'add', '-A')
assert.equal((await check(pkg, { argv: ['--check', '--staged'] })).code, 1)
put(pkg, 'DECISIONS.md', DECISION('PDFs move', M2, 'invoices.pdf_url'))
assert.equal((await check(pkg, { argv: ['--check', '--staged'] })).code, 1, 'the log is in the worktree only')
git(repo, 'add', '-A')
assert.equal((await check(pkg, { argv: ['--check', '--staged'] })).code, 0)
})
test('the silent pass: a root with no migrations on either side is exit 2 naming the root, the ref and the paths', async () => {
const { repo, pkg } = pkgRepo()
put(pkg, M2, 'alter table public.invoices drop column pdf_url;\n')
put(repo, 'docs/README.md', 'hi\n')
commitAll(repo)
// The wrong root: --root points at a folder with no migrations in it.
const r = await check(repo, { argv: ['--check', '--root', 'docs'] })
assert.equal(r.code, 2, r.out)
assert.match(r.out, /NOTHING-TO-COMPARE/)
assert.match(r.out, new RegExp(`root {3}${escapeRe(realpathSync(join(repo, 'docs')))}`))
assert.match(r.out, /base {3}main @ [0-9a-f]{7}/)
assert.match(r.out, /paths {2}supabase\/migrations\//)
const json = JSON.parse((await check(repo, { argv: ['--json', '--root', 'docs'] })).out)
assert.equal(json.exit, 2)
assert.equal(json.scope.ref, 'main')
assert.ok(json.scope.paths.includes('supabase/migrations/'))
// The hook's form: a repo that has written no migration yet must still commit, so it is a note that says where it looked.
const allowed = await check(repo, { argv: ['--check', '--allow-empty', '--root', 'docs'] })
assert.equal(allowed.code, 0, allowed.out)
assert.match(allowed.out, /note {2}NOTHING-TO-COMPARE {2}No migration files were found/)
assert.match(allowed.out, /Looked at: supabase\/migrations\//)
// The repo that has no install marker anywhere, with the migrations in the package: the top level is
// not guessed to be the answer. Both sides are empty, and that is never a pass.
const bare = featureRepo({ 'README.md': 'hi\n', [`${PKG}/${M1}`]: INIT })
put(bare, `${PKG}/${M2}`, 'alter table public.invoices drop column pdf_url;\n')
commitAll(bare)
assert.equal((await check(bare)).code, 2)
})
test('a base without the package yet is a new schema: exit 0 with a note, not a pass in silence', async () => {
const repo = makeRepo({ 'README.md': 'hi\n' })
git(repo, 'checkout', '-q', '-b', 'feature')
put(repo, `${PKG}/${M1}`, INIT)
put(repo, `${PKG}/scripts/check-backend.mjs`, '// marker\n')
commitAll(repo)
const r = await check(join(repo, PKG))
assert.equal(r.code, 0, r.out)
assert.match(r.out, /note {2}BASE-EMPTY/)
assert.match(r.out, /new schema/)
const top = featureRepo({ 'README.md': 'hi\n' })
put(top, M1, INIT)
commitAll(top)
const t = await check(top)
assert.equal(t.code, 0, t.out)
assert.match(t.out, /BASE-EMPTY/)
})
test('CLI: --root names the install, from any folder', () => {
const { repo, pkg } = pkgRepo()
put(pkg, M2, 'alter table public.invoices drop column pdf_url;\n')
commitAll(repo)
// The script's own CONFIG and sources are the shipped ones, so only where it looked is checked.
const r = spawnSync(process.execPath, [SELF, '--check', '--root', pkg, '--json'], { cwd: tmpdir(), env: cleanEnv(), encoding: 'utf8' })
assert.equal(JSON.parse(r.stdout).scope.root, realpathSync(pkg))
})
// -------------------------------------------------- the base side's copy ---
test('--no-cache reaches the snapshot builder, on both sides', async () => {
const log = join(SHARED_MODULES, 'argv.log')
rmSync(log, { force: true })
const real = backendWith(SUPABASE)
const wrapper = join(dirname(real), 'argv-backend.mjs')
writeFileSync(
wrapper,
`import { appendFileSync } from 'node:fs'\nappendFileSync(${JSON.stringify(log)}, JSON.stringify(process.argv.slice(2)) + '\\n')\nawait import(${JSON.stringify(pathToFileURL(real).href)})\n`,
)
const repo = featureRepo()
put(repo, M2, 'alter table public.invoices add column tax integer;\n')
commitAll(repo)
assert.equal((await check(repo, { backend: wrapper })).code, 0)
assert.ok(!calls(log).flat().includes('--no-cache'), 'not by default')
rmSync(log, { force: true })
assert.equal((await check(repo, { backend: wrapper, argv: ['--check', '--no-cache'] })).code, 0)
const snaps = calls(log).filter((a) => a.includes('--print-snapshot'))
assert.equal(snaps.length, 2)
assert.ok(snaps.every((a) => a.includes('--no-cache')), JSON.stringify(snaps))
})
// A `run` command whose script is a relative path in the repo, and imports app
// code that no watch list names (what an Alembic env.py does). The copy used
// to hold only the watched files, so the base side failed with "cannot find".
const DUMP_TOOL = `#!/usr/bin/env node
import { tables } from '../app/models.mjs'
console.log(tables.map((t) => \`create table \${t} (id integer primary key);\`).join('\\n'))
`
const MODELS = (...tables) => `export const tables = ${JSON.stringify(tables)}\n`
function toolRepo() {
const repo = makeRepo({ 'db/revision': '1\n', 'app/models.mjs': MODELS('invoices', 'customers'), 'bin/dump.mjs': DUMP_TOOL }, { commit: false })
chmodSync(join(repo, 'bin/dump.mjs'), 0o755)
commitAll(repo, 'base')
git(repo, 'checkout', '-q', '-b', 'feature')
put(repo, 'app/models.mjs', MODELS('invoices'))
put(repo, 'db/revision', '2\n')
return repo
}
test('a run source with a relative binary that imports unwatched app code builds on the base side too', async () => {
const backend = backendWith([{ kind: 'command', run: ['./bin/dump.mjs'], dialect: 'sqlite', watch: ['db/'], cwd: 'root' }])
const repo = toolRepo()
commitAll(repo)
const red = await check(repo, { argv: ['--no-cache'], backend })
assert.equal(red.code, 1, red.out)
assert.match(red.out, /DROP-TABLE {2}customers/)
// The index side holds the whole tree too, and the worktree is not what is read.
const staged = toolRepo()
git(staged, 'add', '-A')
put(staged, 'app/models.mjs', MODELS('invoices', 'customers'))
const s = await check(staged, { argv: ['--check', '--staged', '--no-cache'], backend })
assert.equal(s.code, 1, s.out)
})
// ------------------------------------------- round 2: base and attribution ---
// R18: a `run` source calls a helper script the install added after the base.
const LATE_TOOL = `#!/usr/bin/env node
import { existsSync } from 'node:fs'
if (existsSync('app/models.mjs')) {
const { tables } = await import('../app/models.mjs')
console.log(tables.map((t) => \`create table \${t} (id integer primary key);\`).join('\\n'))
}
`
test('base overlay: a run source script the base ref does not have is taken from the head, and says so', async () => {
const backend = backendWith([{ kind: 'command', run: ['./bin/dump.mjs'], dialect: 'sqlite', watch: ['db/'], cwd: 'root' }])
const repo = makeRepo({ 'db/revision': '1\n', 'app/models.mjs': MODELS('invoices', 'customers') }, { commit: false })
commitAll(repo, 'base')
git(repo, 'checkout', '-q', '-b', 'feature')
put(repo, 'bin/dump.mjs', LATE_TOOL)
chmodSync(join(repo, 'bin/dump.mjs'), 0o755)
put(repo, 'app/models.mjs', MODELS('invoices'))
put(repo, 'db/revision', '2\n')
commitAll(repo)
const r = await check(repo, { argv: ['--no-cache'], backend })
assert.equal(r.code, 1, r.out)
assert.match(r.out, /DROP-TABLE {2}customers/)
assert.match(r.out, /note {2}BASE-OVERLAY {2}The base main @ \w{7} does not have bin\/dump\.mjs, which CONFIG\.sources needs/)
})
test('base overlay: a base schema file listed first is taken from the head when the base lacks it; a migration never is', async () => {
const sources = [
{ kind: 'sql', paths: ['db/base.sql'], order: 'listed', sections: null },
{ kind: 'sql', paths: ['supabase/migrations/*.sql'], order: 'name', sections: null },
]
const backend = backendWith(sources)
const repo = makeRepo({ [M1]: 'alter table public.legacy add column extra text;\n' })
git(repo, 'checkout', '-q', '-b', 'feature')
put(repo, 'db/base.sql', 'create table public.legacy (id uuid primary key, name text);\n')
put(repo, M2, 'alter table public.legacy drop column name;\n')
commitAll(repo)
const r = await check(repo, { argv: ['--no-cache'], backend })
assert.equal(r.code, 1, r.out)
assert.match(r.out, /DROP-COLUMN {2}public\.legacy\.name {2}in supabase\/migrations\/20260201000000_second\.sql/)
assert.match(r.out, /does not have db\/base\.sql/)
assert.ok(!/does not have supabase/.test(r.out), 'the migration M2 is the change under test and is not carried onto the base')
})
test('base overlay: the checker scripts come from the head, and a helper the base has is left as it was', async () => {
const backend = backendWith([{ kind: 'command', run: ['./bin/dump.mjs'], dialect: 'sqlite', watch: ['db/'], cwd: 'root' }])
const repo = toolRepo()
put(repo, 'scripts/check-backend.mjs', '// the install\n')
commitAll(repo)
const r = await check(repo, { argv: ['--no-cache'], backend })
assert.equal(r.code, 1, r.out)
assert.ok(!/BASE-OVERLAY/.test(r.out), 'the base already had bin/dump.mjs')
})
test('base from before the schema: a command that printed no SQL is BASE-EMPTY, not exit 2', async () => {
const backend = backendWith([{ kind: 'command', run: ['./bin/dump.mjs'], dialect: 'sqlite', watch: ['db/'], cwd: 'root' }])
const repo = makeRepo({ 'bin/dump.mjs': LATE_TOOL, 'db/revision': '0\n' }, { commit: false })
chmodSync(join(repo, 'bin/dump.mjs'), 0o755)
commitAll(repo, 'base')
git(repo, 'checkout', '-q', '-b', 'feature')
put(repo, 'app/models.mjs', MODELS('invoices'))
put(repo, 'db/revision', '1\n')
commitAll(repo)
const r = await check(repo, { argv: ['--no-cache'], backend })
assert.equal(r.code, 0, r.out)
assert.match(r.out, /note {2}BASE-EMPTY {2}The base \(main @ \w{7}\) prints no schema \(its command gave no SQL\), so this is a new schema/)
})
test('NOT-NULL-ADDED: a migration that backfills before SET NOT NULL is a note, and without the backfill it is red', async () => {
const backfilled = featureRepo()
put(backfilled, M2, `alter table public.invoices add column hash text;
update public.invoices set hash = md5(number);
alter table public.invoices alter column hash set not null;
update public.invoices set pdf_url = '' where pdf_url is null;
alter table public.invoices alter column pdf_url set not null;
`)
commitAll(backfilled)
const r = await check(backfilled)
assert.equal(r.code, 0, r.out)
assert.match(r.out, /note {2}NOT-NULL-BACKFILLED {2}public\.invoices\.hash: supabase\/migrations\/20260201000000_second\.sql makes public\.invoices\.hash NOT NULL after an UPDATE that sets it/)
assert.match(r.out, /NOT-NULL-BACKFILLED {2}public\.invoices\.pdf_url/)
const bare = featureRepo()
put(bare, M2, 'alter table public.invoices add column hash text;\nalter table public.invoices alter column hash set not null;\n')
commitAll(bare)
const red = await check(bare)
assert.equal(red.code, 1, red.out)
assert.match(red.out, /NOT-NULL-ADDED {2}public\.invoices\.hash/)
const elsewhere = featureRepo()
put(elsewhere, M2, 'update public.invoices set pdf_url = \'\';\n')
put(elsewhere, M3, 'alter table public.invoices alter column pdf_url set not null;\n')
commitAll(elsewhere)
assert.equal((await check(elsewhere)).code, 1, 'an UPDATE in another migration is not the same change')
})
test('sqlite findings name the table, not public.<table>, and a decision may use either form', async () => {
const repo = sqliteRepo()
put(repo, S2, 'alter table invoices drop column note;\n')
commitAll(repo)
const backend = backendWith(SQLITE)
const red = await check(repo, { argv: [], backend })
assert.match(red.out, /DROP-COLUMN {2}invoices\.note {2}in /)
assert.ok(!/public\./.test(red.out.replace(/\n {4}fix:.*/g, '')), red.out)
put(repo, 'DECISIONS.md', DECISION('2026-10-01 Notes go', S2, 'public.invoices.note'))
assert.equal((await check(repo, { backend })).code, 0)
})
test('attribution: findings that each name one table are found without building a prefix, and a shared table still bisects', async () => {
const log = join(SHARED_MODULES, 'prefix.log')
rmSync(log, { force: true })
const real = backendWith(SUPABASE)
const wrapper = join(dirname(real), 'prefix-backend.mjs')
writeFileSync(
wrapper,
`import { appendFileSync } from 'node:fs'\nappendFileSync(${JSON.stringify(log)}, JSON.stringify(process.argv.slice(2)) + '\\n')\nawait import(${JSON.stringify(pathToFileURL(real).href)})\n`,
)
const names = ['a', 'b', 'c', 'd', 'e', 'f']
const repo = makeRepo({ [M1]: names.map((n) => `create table public.${n} (id uuid primary key, x text, y integer);`).join('\n') })
git(repo, 'checkout', '-q', '-b', 'feature')
names.forEach((n, i) => put(repo, `${DIR}/2027010${i}000000_${n}.sql`, `alter table public.${n} drop column x;\n`))
put(repo, `${DIR}/20270109000000_shared.sql`, 'alter table public.a add column z text;\nalter table public.a drop column y;\n')
commitAll(repo)
const r = await check(repo, { argv: ['--json', '--no-cache'], backend: wrapper })
assert.equal(r.code, 1, r.out)
const found = Object.fromEntries(JSON.parse(r.out).findings.map((f) => [f.object, f.file.slice(DIR.length + 1)]))
assert.deepEqual(found, {
'public.a.x': '20270100000000_a.sql',
'public.b.x': '20270101000000_b.sql',
'public.c.x': '20270102000000_c.sql',
'public.d.x': '20270103000000_d.sql',
'public.e.x': '20270104000000_e.sql',
'public.f.x': '20270105000000_f.sql',
'public.a.y': '20270109000000_shared.sql',
})
const builds = calls(log).filter((a) => a.includes('--print-snapshot')).length
assert.ok(builds <= 4, `${builds} snapshot builds: base, head, and a bisect of the one table two files name`)
})
// ------------------------------------------------------------- round 3 ---
test('a nested .venv and a nested venv are linked into both copies, so a package-level alembic is found (R30)', async () => {
const log = join(SHARED_MODULES, 'nested-venv.log')
rmSync(log, { force: true })
const version = (n, sql) => `# revision ${n}\n${sqlComment(sql, '#')}\n`
const files = {
'.gitignore': 'node_modules\n.venv\nvenv\n',
'backend/alembic.ini': '[alembic]\nscript_location = alembic\n',
'backend/alembic/env.py': '# env\n',
'backend/alembic/versions/0001_init.py': version(1, 'create table invoices (id integer primary key, note text);\ncreate table customers (id integer primary key);'),
}
for (const venv of ['.venv', 'venv']) {
const repo = featureRepo(files)
stub(repo, `backend/${venv}/bin/alembic`, { log, scan: ['alembic/versions'] })
if (venv === 'venv') writeFileSync(join(repo, 'backend/venv/pyvenv.cfg'), 'home = /usr/bin\n')
put(repo, 'backend/alembic/versions/0002_drop.py', version(2, 'drop table customers;'))
commitAll(repo)
// The install is at the repository root; the venv is in the package that owns alembic.ini.
const backend = backendWith([{ kind: 'command', preset: 'alembic', dialect: 'sqlite', config: 'backend/alembic.ini' }])
const red = await check(repo, { argv: [], backend })
assert.equal(red.code, 1, `${venv}: ${red.out}`)
assert.match(red.out, /DROP-TABLE {2}customers {2}in backend\/alembic\/versions\/0002_drop\.py/)
}
})
test('a nested node_modules is linked in, at the path the real tree has it', async () => {
const log = installTools()
const repo = featureRepo({ ...PRISMA_FILES(), 'apps/web/package.json': '{}\n' })
put(repo, 'prisma/schema.prisma', PRISMA_SCHEMA(PRISMA_SQL('')))
commitAll(repo)
// A tool that only a nested install has: the preset finds prisma at the root, so check the link directly.
const marker = join(repo, 'apps/web/node_modules')
mkdirSync(marker, { recursive: true })
writeFileSync(join(marker, 'here.txt'), 'nested')
const seen = join(SHARED_MODULES, 'nested-seen.txt')
rmSync(seen, { force: true })
const real = backendWith(PRISMA)
const wrapper = join(dirname(real), 'nested-backend.mjs')
writeFileSync(
wrapper,
`import { appendFileSync, existsSync } from 'node:fs'\nif (process.argv.includes('--print-snapshot')) appendFileSync(${JSON.stringify(seen)}, process.argv[process.argv.indexOf('--root') + 1] + ' ' + existsSync(process.argv[process.argv.indexOf('--root') + 1] + '/apps/web/node_modules/here.txt') + '\\n')\nawait import(${JSON.stringify(pathToFileURL(real).href)})\n`,
)
await check(repo, { argv: ['--no-cache'], backend: wrapper })
const lines = readFileSync(seen, 'utf8').trim().split('\n')
assert.ok(lines.length >= 2, 'both copies were built')
for (const l of lines) assert.match(l, / true$/, `the copy at ${l} has the nested node_modules`)
assert.ok(calls(log).length > 0)
})
// A wrapper swaps the real --print-config loadGate line for the mode under
// test. 'none' drops it, the way a check-backend from before the ratchet prints.
function gated(mode) {
const real = backendWith(SUPABASE)
const file = join(dirname(real), `gate-${mode}-backend.mjs`)
const line = mode === 'none' ? '' : `loadGate\\t${mode}\\n`
writeFileSync(
file,
`import { spawnSync } from 'node:child_process'\nconst r = spawnSync(process.execPath, [${JSON.stringify(real)}, ...process.argv.slice(2)], { encoding: 'utf8', maxBuffer: 1 << 28 })\nprocess.stdout.write(process.argv.includes('--print-config') ? r.stdout.replace(/^loadGate\\t.*\\n/m, '') + '${line}' : r.stdout)\nprocess.stderr.write(r.stderr)\nprocess.exit(r.status ?? 2)\n`,
)
return file
}
const NOT_LOADING = 'alter table public.nothere add column x text;\n'
test('load ratchet: a head that does not load is a note and exit 0 on warn, exit 2 on gate or no line', async () => {
const repo = featureRepo()
put(repo, M2, NOT_LOADING)
commitAll(repo)
const warn = await check(repo, { argv: [], backend: gated('warn') })
assert.equal(warn.code, 0, warn.out)
assert.match(warn.out, /NOT-COMPARED/)
assert.match(warn.out, /LOAD-FAILED/)
for (const mode of ['gate', 'none']) {
const r = await check(repo, { argv: [], backend: gated(mode) })
assert.equal(r.code, 2, `${mode}: ${r.out}`)
assert.match(r.out, /SNAPSHOT-FAILED/)
}
})
test('load ratchet: a base that does not load, with a head that does, is a note on warn and BASE-UNBUILDABLE on gate', async () => {
const repo = makeRepo({ [M1]: NOT_LOADING })
git(repo, 'checkout', '-q', '-b', 'feature')
put(repo, M1, INIT)
commitAll(repo)
const warn = await check(repo, { argv: [], backend: gated('warn') })
assert.equal(warn.code, 0, warn.out)
assert.match(warn.out, /NOT-COMPARED/)
const gate = await check(repo, { argv: [], backend: gated('gate') })
assert.equal(gate.code, 2, gate.out)
assert.match(gate.out, /BASE-UNBUILDABLE/)
})
test('load ratchet: warn does not soften a failure that is not the migrations loading', async () => {
const repo = featureRepo({ ...PRISMA_FILES(), 'prisma/extra.txt': 'x\n' })
// The prisma tool is not installed in this copy of node_modules.
rmSync(join(SHARED_MODULES, '.bin', 'prisma'), { force: true })
rmSync(join(SHARED_MODULES, 'prisma'), { recursive: true, force: true })
put(repo, 'prisma/schema.prisma', PRISMA_SCHEMA(PRISMA_SQL('')))
commitAll(repo)
const real = backendWith(PRISMA)
const file = join(dirname(real), 'gate-warn-prisma-backend.mjs')
writeFileSync(
file,
`import { spawnSync } from 'node:child_process'\nconst r = spawnSync(process.execPath, [${JSON.stringify(real)}, ...process.argv.slice(2)], { encoding: 'utf8', maxBuffer: 1 << 28 })\nprocess.stdout.write(r.stdout)\nif (process.argv.includes('--print-config')) process.stdout.write('loadGate\\twarn\\n')\nprocess.stderr.write(r.stderr)\nprocess.exit(r.status ?? 2)\n`,
)
const r = await check(repo, { argv: [], backend: file })
assert.equal(r.code, 2, r.out)
assert.doesNotMatch(r.out, /NOT-COMPARED/)
installTools()
})
test('NOT-NULL backfill: an aliased UPDATE, quoted or not, counts as the backfill', async () => {
for (const update of ['update public.invoices x set hash = md5(number);', 'update "public"."invoices" as x set "hash" = md5(number);', 'update only public.invoices inv set hash = md5(inv.number) where true;']) {
const repo = featureRepo()
put(repo, M2, `alter table public.invoices add column hash text;\n${update}\nalter table public.invoices alter column hash set not null;\n`)
commitAll(repo)
const r = await check(repo)
assert.equal(r.code, 0, `${update}\n${r.out}`)
assert.match(r.out, /NOT-NULL-BACKFILLED {2}public\.invoices\.hash/)
}
const other = featureRepo()
put(other, M2, 'alter table public.invoices add column hash text;\nupdate public.customers x set name = \'\';\nalter table public.invoices alter column hash set not null;\n')
commitAll(other)
assert.equal((await check(other)).code, 1, 'an UPDATE of another table is no backfill')
})
test('NOT-NULL-ADDED: says so when the column has a default, and does not claim there is none', async () => {
const repo = featureRepo({ [M1]: 'create table public.users (id uuid primary key, role text default \'user\');\n' })
put(repo, M2, 'alter table public.users alter column role set not null;\n')
commitAll(repo)
const r = await check(repo)
assert.equal(r.code, 1, r.out)
assert.match(r.out, /public\.users\.role is NOT NULL here \(it has a default, which does not fill rows that are already NULL\)/)
assert.doesNotMatch(r.out, /with no default/)
assert.match(r.out, /Backfill the NULLs first/)
const bare = featureRepo()
put(bare, M2, 'alter table public.invoices alter column note set not null;\n')
commitAll(bare)
assert.match((await check(bare)).out, /NOT NULL here with no default/)
})
test('attribution: findings that name the same files share their builds, and each is still credited to the right file', async () => {
const log = join(SHARED_MODULES, 'shared-bisect.log')
rmSync(log, { force: true })
const real = backendWith(SUPABASE)
const wrapper = join(dirname(real), 'shared-bisect-backend.mjs')
writeFileSync(
wrapper,
`import { appendFileSync } from 'node:fs'\nappendFileSync(${JSON.stringify(log)}, JSON.stringify(process.argv.slice(2)) + '\\n')\nawait import(${JSON.stringify(pathToFileURL(real).href)})\n`,
)
const T = 5
const at = (i) => `${DIR}/2026${String(i).padStart(6, '0')}_m${i}.sql`
const repo = makeRepo({ [at(0)]: Array.from({ length: T }, (_, t) => `create table public.t${t} (id uuid primary key);`).join('\n') })
for (let i = 1; i <= 20; i++) put(repo, at(i), `alter table public.t${i % T} add column c${i} text;\n`)
commitAll(repo)
git(repo, 'checkout', '-q', '-b', 'feature')
const expected = {}
for (let i = 21; i <= 43; i++) {
const c = i - 20
const change = i % 4 === 0 ? `alter table public.t${c % T} alter column c${c} type integer using 1;\n` : ''
if (change) expected[`public.t${c % T}.c${c}`] = at(i).slice(DIR.length + 1)
put(repo, at(i), `alter table public.t${i % T} add column c${i} text;\n${change}`)
}
commitAll(repo)
const r = await check(repo, { argv: ['--json', '--no-cache'], backend: wrapper })
assert.equal(r.code, 1, r.out)
const found = Object.fromEntries(JSON.parse(r.out).findings.map((f) => [f.object, f.file.slice(DIR.length + 1)]))
assert.deepEqual(found, expected)
const builds = calls(log).filter((a) => a.includes('--print-snapshot')).length
assert.ok(builds <= 2 + 14, `${builds} snapshot builds for ${Object.keys(expected).length} findings`)
})
How to tell it works: node --test passes. Make the checker compare a column’s type only by name and the widening test fails.
Included in
- Any setup that adds Database, for when it stores data in postgres, sqlite or supabase