Files
simstudioai--sim/scripts/check-migrations-safety.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

492 lines
16 KiB
TypeScript

#!/usr/bin/env bun
/**
* Guards new Drizzle migrations against deploy-window downtime.
*
* During a deploy the previously-deployed app code keeps serving against the
* freshly-migrated schema (blue/green keeps both versions live). A migration
* that is backward-incompatible with that older code — drops a column it still
* reads, renames, adds a NOT NULL its inserts don't populate — throws until the
* new code takes over. The fix is the expand/contract discipline: additive now,
* destructive only after the dependent code is gone.
*
* This lint is the deterministic half of that guard (the `/db-migrate` skill is
* the judgment half). It classifies every statement in migrations added on this
* branch:
* - HARD ERROR: ops that are essentially never one-deploy-safe. Rewrite them.
* - ANNOTATE: legitimate contract-phase ops. Acknowledge each with a
* `-- migration-safe: <reason>` comment on the preceding line(s),
* only after confirming the dependent code already shipped out.
* - WARN: data backfills — surfaced for review, never block.
*
* Scope is new migration files only (git diff vs base); the existing corpus is
* grandfathered. Usage:
* bun run scripts/check-migrations-safety.ts [baseRef] # base defaults to origin/staging
* bun run scripts/check-migrations-safety.ts --all # whole corpus
* bun run scripts/check-migrations-safety.ts --dir <path> # a directory
*/
import { execFileSync } from 'node:child_process'
import { readdir, readFile } from 'node:fs/promises'
import path from 'node:path'
const ROOT = path.resolve(import.meta.dir, '..')
const MIGRATIONS_DIR = 'packages/db/migrations'
const ANNOTATION_PREFIX = '-- migration-safe:'
type Tier = 'error' | 'warn'
interface Finding {
line: number
statement: string
tier: Tier
rule: string
message: string
}
interface Statement {
sql: string
startLine: number
}
/** Strip quotes and any schema prefix so `"public"."user"` and `"user"` match. */
function bareName(raw: string): string {
const unquoted = raw.replace(/"/g, '')
const parts = unquoted.split('.')
return (parts[parts.length - 1] ?? unquoted).toLowerCase()
}
/**
* Split SQL into statements with their 1-based start line, respecting line
* comments (`--`), block comments, and single-quoted strings so a `;` inside
* any of them does not terminate a statement.
*/
function parseStatements(content: string): Statement[] {
const statements: Statement[] = []
let buf = ''
let startOffset = -1
let inLine = false
let inBlock = false
let inStr = false
let dollarTag: string | null = null
const lineAt = (offset: number): number => {
let line = 1
for (let i = 0; i < offset; i++) if (content[i] === '\n') line++
return line
}
const flush = () => {
const sql = buf.trim()
if (sql.length > 0 && startOffset >= 0) statements.push({ sql, startLine: lineAt(startOffset) })
buf = ''
startOffset = -1
}
for (let i = 0; i < content.length; i++) {
const c = content[i]
const next = content[i + 1]
if (inLine) {
if (c === '\n') inLine = false
continue
}
if (inBlock) {
if (c === '*' && next === '/') {
inBlock = false
i++
}
continue
}
if (inStr) {
buf += c
if (c === "'") {
if (next === "'") {
buf += "'"
i++
} else {
inStr = false
}
}
continue
}
if (dollarTag) {
if (c === '$' && content.startsWith(dollarTag, i)) {
buf += dollarTag
i += dollarTag.length - 1
dollarTag = null
} else {
buf += c
}
continue
}
if (c === '$') {
const tag = /^\$[A-Za-z_]*\$/.exec(content.slice(i))?.[0]
if (tag) {
if (startOffset < 0) startOffset = i
dollarTag = tag
buf += tag
i += tag.length - 1
continue
}
}
if (c === '-' && next === '-') {
inLine = true
i++
continue
}
if (c === '/' && next === '*') {
inBlock = true
i++
continue
}
if (c === "'") {
inStr = true
if (startOffset < 0) startOffset = i
buf += c
continue
}
if (c === ';') {
flush()
continue
}
if (startOffset < 0 && !/\s/.test(c)) startOffset = i
buf += c
}
flush()
return statements
}
/**
* Mirror of the api-validation annotation reader, for `--` SQL comments:
* scans up to three consecutive non-empty preceding lines for the prefix.
* `allowed` when a non-empty reason follows; `missingReason` flags a dangling
* annotation so it fails rather than silently passing.
*/
function readAnnotation(
lines: string[],
startLine: number
): { allowed: boolean; missingReason: boolean } {
let inspected = 0
for (let i = startLine - 2; i >= 0 && inspected < 3; i--) {
const trimmed = lines[i]?.trim() ?? ''
if (trimmed.length === 0) continue
inspected++
if (!trimmed.startsWith('--')) return { allowed: false, missingReason: false }
const idx = trimmed.indexOf(ANNOTATION_PREFIX)
if (idx === -1) continue
const reason = trimmed.slice(idx + ANNOTATION_PREFIX.length).trim()
if (reason.length === 0) return { allowed: false, missingReason: true }
return { allowed: true, missingReason: false }
}
return { allowed: false, missingReason: false }
}
interface RawMatch {
kind: 'error' | 'annotate' | 'warn'
rule: string
message: string
}
/**
* Classify one statement. `createdTables` holds tables created in the same
* migration — ops against a brand-new table have no old rows and no live
* traffic, so they are always safe and skipped. `sawCommit` tracks whether a
* `COMMIT;` breakpoint preceded a CONCURRENTLY index (see migrate.ts).
*/
function classify(sql: string, createdTables: Set<string>, sawCommit: boolean): RawMatch[] {
const s = sql.replace(/\s+/g, ' ').trim()
const matches: RawMatch[] = []
const alterTable = s.match(/\bALTER TABLE (?:IF EXISTS )?(?:ONLY )?("?[.\w]+"?)/i)
const targetTable = alterTable ? bareName(alterTable[1]) : null
const onNewTable = targetTable !== null && createdTables.has(targetTable)
if (/^CREATE (?:UNIQUE )?INDEX\b/i.test(s)) {
const on = s.match(/\bON ("?[.\w]+"?)/i)
const indexTable = on ? bareName(on[1]) : null
const concurrent = /\bCONCURRENTLY\b/i.test(s)
if (!(indexTable && createdTables.has(indexTable))) {
if (!concurrent) {
matches.push({
kind: 'error',
rule: 'index-not-concurrent',
message:
'CREATE INDEX on an existing table write-locks it for the whole build. Use CREATE INDEX CONCURRENTLY IF NOT EXISTS after a COMMIT; breakpoint (see packages/db/scripts/migrate.ts).',
})
} else if (!/\bIF NOT EXISTS\b/i.test(s)) {
matches.push({
kind: 'error',
rule: 'concurrent-index-not-idempotent',
message:
'CREATE INDEX CONCURRENTLY must be IF NOT EXISTS — a failed build replays from the top and a partial INVALID index would be skipped forever.',
})
} else if (!sawCommit) {
matches.push({
kind: 'error',
rule: 'concurrent-index-no-commit',
message:
'CREATE INDEX CONCURRENTLY cannot run inside the migration transaction. Precede it with a COMMIT; breakpoint and SET lock_timeout = 0 (see packages/db/scripts/migrate.ts).',
})
}
}
}
if (
!onNewTable &&
/\bADD COLUMN\b/i.test(s) &&
/\bNOT NULL\b/i.test(s) &&
!/\bDEFAULT\b/i.test(s)
) {
matches.push({
kind: 'error',
rule: 'add-not-null-no-default',
message:
'ADD COLUMN NOT NULL with no DEFAULT breaks old inserts (and fails on existing rows). Add it nullable or with a DEFAULT, backfill, then SET NOT NULL in a later migration once code populates it.',
})
}
if (/\bRENAME COLUMN\b/i.test(s) || /^ALTER TABLE\b[^;]*\bRENAME TO\b/i.test(s)) {
matches.push({
kind: 'error',
rule: 'rename',
message:
'RENAME of a column/table breaks old code reading the old name. Add the new column/table, dual-write in code, then drop the old one in a later deploy.',
})
}
if (
!onNewTable &&
/\bADD CONSTRAINT\b/i.test(s) &&
/\b(FOREIGN KEY|CHECK)\b/i.test(s) &&
!/\bNOT VALID\b/i.test(s)
) {
matches.push({
kind: 'error',
rule: 'constraint-not-valid',
message:
'ADD CONSTRAINT FOREIGN KEY/CHECK on an existing table locks it and rejects old writes that violate it. Add it NOT VALID, then VALIDATE CONSTRAINT in a separate step.',
})
}
if (!onNewTable) {
if (/^DROP TABLE\b/i.test(s)) {
matches.push({ kind: 'annotate', rule: 'drop-table', message: 'DROP TABLE' })
}
if (/\bDROP COLUMN\b/i.test(s)) {
matches.push({ kind: 'annotate', rule: 'drop-column', message: 'DROP COLUMN' })
}
if (/\bDROP CONSTRAINT\b/i.test(s)) {
matches.push({ kind: 'annotate', rule: 'drop-constraint', message: 'DROP CONSTRAINT' })
}
if (/\bDROP DEFAULT\b/i.test(s)) {
matches.push({ kind: 'annotate', rule: 'drop-default', message: 'DROP DEFAULT' })
}
if (/\bSET NOT NULL\b/i.test(s)) {
matches.push({ kind: 'annotate', rule: 'set-not-null', message: 'SET NOT NULL' })
}
if (/\bSET DATA TYPE\b/i.test(s) || /\bALTER COLUMN ("?[.\w]+"?) TYPE\b/i.test(s)) {
matches.push({ kind: 'annotate', rule: 'alter-type', message: 'column type change' })
}
}
if (/^DROP INDEX\b/i.test(s)) {
if (!/\bCONCURRENTLY\b/i.test(s)) {
matches.push({
kind: 'error',
rule: 'drop-index-not-concurrent',
message:
'Plain DROP INDEX takes an ACCESS EXCLUSIVE lock on the table for the whole drop. Use DROP INDEX CONCURRENTLY after a COMMIT; breakpoint (see packages/db/scripts/migrate.ts).',
})
} else if (!/\bIF EXISTS\b/i.test(s)) {
matches.push({
kind: 'error',
rule: 'concurrent-drop-index-not-idempotent',
message:
'DROP INDEX CONCURRENTLY must be IF EXISTS — a failed run replays from the top and would abort re-dropping an already-gone index.',
})
} else if (!sawCommit) {
matches.push({
kind: 'error',
rule: 'concurrent-drop-index-no-commit',
message:
'DROP INDEX CONCURRENTLY cannot run inside the migration transaction. Precede it with a COMMIT; breakpoint (see packages/db/scripts/migrate.ts).',
})
}
}
if (/^(UPDATE|DELETE)\b/i.test(s)) {
const noWhere = !/\bWHERE\b/i.test(s)
matches.push({
kind: 'warn',
rule: 'data-backfill',
message: noWhere
? 'data backfill with no WHERE rewrites/locks the whole table. Confirm it is batched, idempotent, and safe under concurrent writes.'
: 'data backfill. Confirm it is batched, idempotent, and safe under concurrent writes.',
})
}
return matches
}
const ANNOTATE_GUIDANCE =
'is a contract-phase op. Confirm the old code no longer reads/writes it (it must have shipped in an earlier deploy — not this same PR), then acknowledge with a `-- migration-safe: <reason>` comment on the line above.'
/** Lint a single migration's SQL. Returns only actionable findings. */
export function lintSql(content: string): Finding[] {
const lines = content.split('\n')
const statements = parseStatements(content)
const createdTables = new Set<string>()
for (const { sql } of statements) {
const m = sql.match(/^CREATE TABLE (?:IF NOT EXISTS )?("?[.\w]+"?)/i)
if (m) createdTables.add(bareName(m[1]))
}
const findings: Finding[] = []
let sawCommit = false
for (const { sql, startLine } of statements) {
for (const match of classify(sql, createdTables, sawCommit)) {
if (match.kind === 'error') {
findings.push({
line: startLine,
statement: sql,
tier: 'error',
rule: match.rule,
message: match.message,
})
} else if (match.kind === 'warn') {
findings.push({
line: startLine,
statement: sql,
tier: 'warn',
rule: match.rule,
message: match.message,
})
} else {
const ann = readAnnotation(lines, startLine)
if (ann.allowed) continue
findings.push({
line: startLine,
statement: sql,
tier: 'error',
rule: match.rule,
message: ann.missingReason
? `${match.message}: \`-- migration-safe:\` annotation has no reason. Give it a real justification.`
: `${match.message} ${ANNOTATE_GUIDANCE}`,
})
}
}
if (/^COMMIT\b/i.test(sql.trim())) sawCommit = true
}
return findings
}
function git(args: string[]): string | null {
try {
return execFileSync('git', args, {
cwd: ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim()
} catch {
return null
}
}
/** New migration files on this branch vs base, plus uncommitted ones locally. */
function changedMigrationFiles(baseRef: string): string[] {
const files = new Set<string>()
const inDir = (p: string) => p.startsWith(`${MIGRATIONS_DIR}/`) && p.endsWith('.sql')
const mergeBase = git(['merge-base', baseRef, 'HEAD']) ?? baseRef
const committed = git([
'diff',
'--name-only',
'--diff-filter=AM',
mergeBase,
'HEAD',
'--',
MIGRATIONS_DIR,
])
if (committed === null) return [] // git unavailable → fail open (handled by caller)
for (const f of committed.split('\n')) if (inDir(f)) files.add(f)
const status = git(['status', '--porcelain', '--', MIGRATIONS_DIR])
if (status) {
for (const raw of status.split('\n')) {
const p = raw.slice(3).trim()
if (inDir(p)) files.add(p)
}
}
return [...files]
}
async function listSqlFiles(dir: string): Promise<string[]> {
const out: string[] = []
const entries = await readdir(dir, { withFileTypes: true })
for (const e of entries) {
const full = path.join(dir, e.name)
if (e.isDirectory()) out.push(...(await listSqlFiles(full)))
else if (e.name.endsWith('.sql')) out.push(full)
}
return out
}
async function resolveFiles(argv: string[]): Promise<string[] | null> {
if (argv.includes('--all')) {
return (await listSqlFiles(path.join(ROOT, MIGRATIONS_DIR))).map((f) => path.relative(ROOT, f))
}
const dirIdx = argv.indexOf('--dir')
if (dirIdx !== -1) {
const dir = argv[dirIdx + 1]
if (!dir) throw new Error('--dir requires a path')
return (await listSqlFiles(path.resolve(dir))).map((f) => path.relative(ROOT, f))
}
const baseRef = argv.find((a) => !a.startsWith('--')) ?? 'origin/staging'
const files = changedMigrationFiles(baseRef)
if (files.length === 0 && git(['rev-parse', 'HEAD']) === null) {
console.warn('⚠ git unavailable — skipping migration safety check.')
return null
}
return files
}
async function main() {
const files = await resolveFiles(process.argv.slice(2))
if (files === null) process.exit(0)
if (files.length === 0) {
console.log('✓ No new migrations to check.')
process.exit(0)
}
let errors = 0
let warnings = 0
for (const rel of files) {
const content = await readFile(path.join(ROOT, rel), 'utf8')
const findings = lintSql(content)
if (findings.length === 0) continue
console.error(`\n${rel}`)
for (const f of findings.sort((a, b) => a.line - b.line)) {
const icon = f.tier === 'error' ? '✗' : '⚠'
if (f.tier === 'error') errors++
else warnings++
console.error(` ${icon} ${rel}:${f.line} [${f.rule}]`)
console.error(` ${f.statement.replace(/\s+/g, ' ').slice(0, 120)}`)
console.error(` → ${f.message}`)
}
}
if (errors === 0) {
console.log(
warnings > 0
? `\n✓ No blocking migration issues (${warnings} warning(s) to review).`
: '\n✓ Migrations are backward-compatible.'
)
process.exit(0)
}
console.error(
`\nFound ${errors} blocking migration issue(s). Rewrite hard errors into expand/contract, or annotate contract ops once safe. See the /db-migrate skill.`
)
process.exit(1)
}
if (import.meta.main) main()