chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,491 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user