Skip to content

Commit 84093a2

Browse files
fix(db): address review — DROP INDEX lock symmetry, RENAME CONSTRAINT false-positive, alter-type literal match
- Non-concurrent DROP INDEX is now a hard error (ACCESS EXCLUSIVE lock), symmetric with CREATE INDEX; DROP INDEX CONCURRENTLY after a COMMIT passes clean. Removes the false-confidence annotate path. - RENAME rule narrowed to RENAME COLUMN / table RENAME TO; RENAME CONSTRAINT and ALTER INDEX ... RENAME (metadata-only) no longer flagged. - alter-type regex now requires TYPE to follow the column identifier, so it no longer matches TYPE inside a string default. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0b278d3 commit 84093a2

3 files changed

Lines changed: 58 additions & 4 deletions

File tree

.agents/skills/db-migrate/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Never put expand and contract in the same PR. If this PR both removes the code t
3434
| Change a column type | add a new column of the new type; dual-write | backfill, swap reads, drop old |
3535
| Add FK / CHECK | `ADD CONSTRAINT ... NOT VALID` | `VALIDATE CONSTRAINT` separately |
3636
| Index an existing table | `COMMIT;` breakpoint → `SET lock_timeout = 0``CREATE INDEX CONCURRENTLY IF NOT EXISTS` (see `packages/db/scripts/migrate.ts`) ||
37+
| Drop an index | `COMMIT;` breakpoint → `DROP INDEX CONCURRENTLY` — plain `DROP INDEX` takes ACCESS EXCLUSIVE on the table ||
3738
| Backfill data | batched + idempotent `UPDATE` (keyset/`WHERE`, bounded) ||
3839

3940
A `CREATE INDEX`, `ADD COLUMN`, or `ADD CONSTRAINT` against a table **created in the same migration** is always safe (no rows, no live traffic) — the lint already suppresses those.

scripts/check-migrations-safety.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,45 @@ describe('warnings (non-blocking)', () => {
137137
})
138138
})
139139

140+
describe('review fixes', () => {
141+
test('RENAME CONSTRAINT is metadata-only — not flagged', () => {
142+
expect(
143+
lintSql('ALTER TABLE "permission_group" RENAME CONSTRAINT "old_fk" TO "new_fk";')
144+
).toEqual([])
145+
})
146+
147+
test('ALTER INDEX ... RENAME is metadata-only — not flagged', () => {
148+
expect(lintSql('ALTER INDEX "old_idx" RENAME TO "new_idx";')).toEqual([])
149+
})
150+
151+
test('table RENAME TO is still a hard error', () => {
152+
expect(rules('ALTER TABLE "marketplace" RENAME TO "listings";')).toEqual(['error:rename'])
153+
})
154+
155+
test('plain DROP INDEX is a hard error (ACCESS EXCLUSIVE lock)', () => {
156+
expect(rules('DROP INDEX "permission_group_workspace_name_unique";')).toEqual([
157+
'error:drop-index-not-concurrent',
158+
])
159+
})
160+
161+
test('DROP INDEX CONCURRENTLY after a COMMIT passes clean', () => {
162+
const sql = `COMMIT;
163+
--> statement-breakpoint
164+
DROP INDEX CONCURRENTLY IF EXISTS "stale_idx";`
165+
expect(lintSql(sql)).toEqual([])
166+
})
167+
168+
test('DROP INDEX CONCURRENTLY without a preceding COMMIT errors', () => {
169+
expect(rules('DROP INDEX CONCURRENTLY IF EXISTS "stale_idx";')).toEqual([
170+
'error:concurrent-drop-index-no-commit',
171+
])
172+
})
173+
174+
test('alter-type does not match TYPE inside a string default', () => {
175+
expect(lintSql(`ALTER TABLE "x" ALTER COLUMN "y" SET DEFAULT 'change TYPE later';`)).toEqual([])
176+
})
177+
})
178+
140179
describe('parser robustness', () => {
141180
test('semicolon inside a string literal does not split', () => {
142181
expect(lintSql(`ALTER TABLE "x" ADD COLUMN "y" text DEFAULT 'a;b' NOT NULL;`)).toEqual([])

scripts/check-migrations-safety.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -243,12 +243,12 @@ function classify(sql: string, createdTables: Set<string>, sawCommit: boolean):
243243
})
244244
}
245245

246-
if (/\bRENAME\b/i.test(s)) {
246+
if (/\bRENAME COLUMN\b/i.test(s) || /^ALTER TABLE\b[^;]*\bRENAME TO\b/i.test(s)) {
247247
matches.push({
248248
kind: 'error',
249249
rule: 'rename',
250250
message:
251-
'RENAME 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.',
251+
'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.',
252252
})
253253
}
254254

@@ -282,12 +282,26 @@ function classify(sql: string, createdTables: Set<string>, sawCommit: boolean):
282282
if (/\bSET NOT NULL\b/i.test(s)) {
283283
matches.push({ kind: 'annotate', rule: 'set-not-null', message: 'SET NOT NULL' })
284284
}
285-
if (/\b(?:SET DATA TYPE|ALTER COLUMN [^;]*\bTYPE)\b/i.test(s)) {
285+
if (/\bSET DATA TYPE\b/i.test(s) || /\bALTER COLUMN ("?[.\w]+"?) TYPE\b/i.test(s)) {
286286
matches.push({ kind: 'annotate', rule: 'alter-type', message: 'column type change' })
287287
}
288288
}
289289
if (/^DROP INDEX\b/i.test(s)) {
290-
matches.push({ kind: 'annotate', rule: 'drop-index', message: 'DROP INDEX' })
290+
if (!/\bCONCURRENTLY\b/i.test(s)) {
291+
matches.push({
292+
kind: 'error',
293+
rule: 'drop-index-not-concurrent',
294+
message:
295+
'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).',
296+
})
297+
} else if (!sawCommit) {
298+
matches.push({
299+
kind: 'error',
300+
rule: 'concurrent-drop-index-no-commit',
301+
message:
302+
'DROP INDEX CONCURRENTLY cannot run inside the migration transaction. Precede it with a COMMIT; breakpoint (see packages/db/scripts/migrate.ts).',
303+
})
304+
}
291305
}
292306

293307
if (/^(UPDATE|DELETE)\b/i.test(s)) {

0 commit comments

Comments
 (0)