@bolvrk/engine-sqlite 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,446 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Schema shape traps (STRICT, child-key indexes, nullable keys, dependent
4
+ * views and triggers) and transaction traps (deferred writes, nesting).
5
+ */
6
+ import type { Finding } from "@bolvrk/engine";
7
+ import {
8
+ alterActions,
9
+ constraintOf,
10
+ createdColumns,
11
+ dataTypeName,
12
+ descendants,
13
+ droppedTables,
14
+ hasWhere,
15
+ items,
16
+ nameOf,
17
+ pragmaIsOn,
18
+ pragmaOf,
19
+ tableConstraints,
20
+ tableCreatedInMigration,
21
+ tableOf,
22
+ tableOptions,
23
+ } from "../helpers";
24
+ import { sl020Meta, sl021Meta, sl022Meta, sl023Meta, sl024Meta, sl025Meta, sl026Meta, sl027Meta, sl028Meta, sl029Meta, sl030Meta } from "../metas/index";
25
+ import { finding, type SlRule, type SlRuleContext } from "../rule";
26
+
27
+ type AnyNode = Record<string, any>;
28
+ type Stmt = SlRuleContext["migration"]["statements"][number];
29
+
30
+ /** SL020: a new table without STRICT. */
31
+ export const sl020: SlRule = {
32
+ meta: sl020Meta,
33
+ check(ctx) {
34
+ const out: Finding[] = [];
35
+ for (const stmt of ctx.migration.statements) {
36
+ if (stmt.node.type !== "create_table_stmt") continue;
37
+ // CREATE TABLE ... AS SELECT has no column list to be strict about; virtual tables have their own module rules.
38
+ if (createdColumns(stmt).length === 0) continue;
39
+ if (tableOptions(stmt).includes("STRICT")) continue;
40
+ const table = tableOf(stmt) ?? "";
41
+ out.push(
42
+ finding(sl020, stmt, {
43
+ title: `"${table}" is created without STRICT — column types are not enforced`,
44
+ message:
45
+ `Without STRICT, SQLite stores whatever value arrives regardless of the declared type: text in an INTEGER column, a number in a TEXT one. ` +
46
+ `Add STRICT now — it is a CREATE TABLE option only, and adding it later means rebuilding the table.`,
47
+ object: { table },
48
+ }),
49
+ );
50
+ }
51
+ return out;
52
+ },
53
+ };
54
+
55
+ interface ChildKey {
56
+ table: string;
57
+ columns: string[];
58
+ parent: string;
59
+ /** ON DELETE CASCADE declared on this key. */
60
+ cascade: boolean;
61
+ }
62
+
63
+ /** Foreign keys declared by a CREATE TABLE: column-level REFERENCES and table-level FOREIGN KEY. */
64
+ function childKeys(stmt: Stmt): ChildKey[] {
65
+ if (stmt.node.type !== "create_table_stmt") return [];
66
+ const table = tableOf(stmt) ?? "";
67
+ const out: ChildKey[] = [];
68
+ for (const column of createdColumns(stmt)) {
69
+ const ref = constraintOf(column, "references_specification");
70
+ if (!ref) continue;
71
+ const spec = ref.type === "references_specification" ? ref : ref.constraint;
72
+ out.push({ table, columns: [nameOf(column.name) ?? ""], parent: nameOf(spec.table) ?? "", cascade: cascades(spec) });
73
+ }
74
+ for (const constraint of tableConstraints(stmt)) {
75
+ const fk = constraint.type === "constraint_foreign_key" ? constraint : constraint.constraint?.type === "constraint_foreign_key" ? constraint.constraint : undefined;
76
+ if (!fk) continue;
77
+ const spec = fk.references ?? fk;
78
+ out.push({
79
+ table,
80
+ columns: items(fk.columns).map((c: AnyNode) => nameOf(c) ?? ""),
81
+ parent: nameOf(spec.table) ?? "",
82
+ cascade: cascades(spec) || cascades(fk),
83
+ });
84
+ }
85
+ return out;
86
+ }
87
+
88
+ function cascades(spec: AnyNode): boolean {
89
+ return descendants(spec, (n) => n.type === "referential_action").some((action) => {
90
+ const words = descendants(action, (n) => n.type === "keyword").map((k) => String(k.name).toUpperCase());
91
+ return words.includes("DELETE") && words.includes("CASCADE");
92
+ });
93
+ }
94
+
95
+ /** Column lists of the indexes a migration creates on `table`, plus the unique/primary keys the table declares. */
96
+ function indexedPrefixes(ctx: SlRuleContext, table: string): string[][] {
97
+ const out: string[][] = [];
98
+ for (const stmt of ctx.migration.statements) {
99
+ if (stmt.node.type === "create_index_stmt" && tableOf(stmt)?.toLowerCase() === table.toLowerCase()) {
100
+ out.push(items((stmt.node as AnyNode).columns).map((spec: AnyNode) => nameOf(spec.expr ?? spec) ?? ""));
101
+ }
102
+ if (stmt.node.type === "create_table_stmt" && tableOf(stmt)?.toLowerCase() === table.toLowerCase()) {
103
+ for (const column of createdColumns(stmt)) {
104
+ if (constraintOf(column, "constraint_primary_key") || constraintOf(column, "constraint_unique")) out.push([nameOf(column.name) ?? ""]);
105
+ }
106
+ for (const constraint of tableConstraints(stmt)) {
107
+ const inner = constraint.constraint ?? constraint;
108
+ if (inner.type === "constraint_primary_key" || inner.type === "constraint_unique") {
109
+ out.push(items(inner.columns).map((spec: AnyNode) => nameOf(spec.expr ?? spec) ?? ""));
110
+ }
111
+ }
112
+ }
113
+ }
114
+ return out;
115
+ }
116
+
117
+ function isPrefix(columns: string[], index: string[]): boolean {
118
+ return columns.every((column, i) => index[i]?.toLowerCase() === column.toLowerCase());
119
+ }
120
+
121
+ /** SL021: a child key without an index on its columns. */
122
+ export const sl021: SlRule = {
123
+ meta: sl021Meta,
124
+ check(ctx) {
125
+ const out: Finding[] = [];
126
+ for (const stmt of ctx.migration.statements) {
127
+ for (const key of childKeys(stmt)) {
128
+ if (indexedPrefixes(ctx, key.table).some((index) => isPrefix(key.columns, index))) continue;
129
+ out.push(
130
+ finding(sl021, stmt, {
131
+ title: `Foreign key "${key.table}"(${key.columns.join(", ")}) has no index`,
132
+ message:
133
+ `Every DELETE or UPDATE of a "${key.parent}" row scans all of "${key.table}" to check for references, because SQLite never indexes the child side of a foreign key. ` +
134
+ `Add CREATE INDEX on (${key.columns.join(", ")}) in this migration.`,
135
+ object: { table: key.table, column: key.columns[0] },
136
+ }),
137
+ );
138
+ }
139
+ }
140
+ return out;
141
+ },
142
+ };
143
+
144
+ /** SL022: RENAME while legacy_alter_table is on. */
145
+ export const sl022: SlRule = {
146
+ meta: sl022Meta,
147
+ check(ctx) {
148
+ const out: Finding[] = [];
149
+ let legacy = false;
150
+ for (const stmt of ctx.migration.statements) {
151
+ const pragma = pragmaOf(stmt);
152
+ if (pragma?.name === "legacy_alter_table") {
153
+ legacy = pragmaIsOn(pragma.value);
154
+ continue;
155
+ }
156
+ if (!legacy) continue;
157
+ for (const action of alterActions(stmt)) {
158
+ if (action.type !== "alter_action_rename" && action.type !== "alter_action_rename_column") continue;
159
+ const table = tableOf(stmt) ?? "";
160
+ out.push(
161
+ finding(sl022, stmt, {
162
+ title: `Rename on "${table}" runs with legacy_alter_table on — views and triggers are not rewritten`,
163
+ message:
164
+ `PRAGMA legacy_alter_table=ON restores the pre-3.26 rename: any view or trigger that names the old identifier keeps naming it, and fails the first time it is used. ` +
165
+ `Switch the pragma off before renaming.`,
166
+ object: { table },
167
+ }),
168
+ );
169
+ }
170
+ }
171
+ return out;
172
+ },
173
+ };
174
+
175
+ interface Dependent {
176
+ kind: "view" | "trigger";
177
+ name: string;
178
+ /** Lower-cased identifiers the body mentions. */
179
+ identifiers: Set<string>;
180
+ /** The trigger's target table, lower-cased. */
181
+ target?: string;
182
+ }
183
+
184
+ /** Views and triggers created in this migration, in order, with the names their bodies mention. */
185
+ function dependents(ctx: SlRuleContext, beforeIndex: number): Dependent[] {
186
+ const live = new Map<string, Dependent>();
187
+ for (const stmt of ctx.migration.statements) {
188
+ if (stmt.index >= beforeIndex) break;
189
+ const node = stmt.node as AnyNode;
190
+ if (node.type === "create_view_stmt" || node.type === "create_trigger_stmt") {
191
+ const kind = node.type === "create_view_stmt" ? "view" : "trigger";
192
+ const identifiers = new Set(descendants([node.clauses, node.body], (n) => n.type === "identifier").map((n) => String(n.name).toLowerCase()));
193
+ const target = kind === "trigger" ? nameOf(node.target?.table)?.toLowerCase() : undefined;
194
+ if (target) identifiers.add(target);
195
+ live.set(`${kind}:${(nameOf(node.name) ?? "").toLowerCase()}`, { kind, name: nameOf(node.name) ?? "", identifiers, target });
196
+ } else if (node.type === "drop_view_stmt") {
197
+ for (const view of items(node.views)) live.delete(`view:${(nameOf(view) ?? "").toLowerCase()}`);
198
+ } else if (node.type === "drop_trigger_stmt") {
199
+ live.delete(`trigger:${(nameOf(node.trigger) ?? "").toLowerCase()}`);
200
+ }
201
+ }
202
+ return [...live.values()];
203
+ }
204
+
205
+ /** SL023: DROP TABLE / DROP COLUMN of something a view or trigger created here still references. */
206
+ export const sl023: SlRule = {
207
+ meta: sl023Meta,
208
+ check(ctx) {
209
+ const out: Finding[] = [];
210
+ for (const stmt of ctx.migration.statements) {
211
+ for (const table of droppedTables(stmt)) {
212
+ const hit = dependents(ctx, stmt.index).find((d) => d.identifiers.has(table.toLowerCase()));
213
+ if (!hit) continue;
214
+ out.push(
215
+ finding(sl023, stmt, {
216
+ title: `DROP TABLE "${table}" breaks ${hit.kind} "${hit.name}" created in this migration`,
217
+ message:
218
+ hit.kind === "trigger" && hit.target === table.toLowerCase()
219
+ ? `Dropping the table drops the trigger with it — if that is the intent, drop the trigger explicitly so the migration says so.`
220
+ : `${hit.kind === "view" ? "The view" : "The trigger"} still names "${table}"; it fails the first time it is used. Drop or redefine it first.`,
221
+ object: { table },
222
+ }),
223
+ );
224
+ }
225
+ for (const action of alterActions(stmt)) {
226
+ if (action.type !== "alter_action_drop_column") continue;
227
+ const table = tableOf(stmt) ?? "";
228
+ const column = nameOf(action.column) ?? "";
229
+ const hit = dependents(ctx, stmt.index).find((d) => d.identifiers.has(table.toLowerCase()) && d.identifiers.has(column.toLowerCase()));
230
+ if (!hit) continue;
231
+ out.push(
232
+ finding(sl023, stmt, {
233
+ title: `DROP COLUMN "${column}" breaks ${hit.kind} "${hit.name}", which references it`,
234
+ message:
235
+ hit.kind === "view"
236
+ ? `The view keeps naming "${column}" and fails with "no such column" the first time it is queried (older SQLite releases refuse the DROP instead). Drop or redefine "${hit.name}" first.`
237
+ : `The trigger keeps naming "${column}", so every later write that fires it fails with "no such column" — writes to "${table}" break (older SQLite releases refuse the DROP instead). Drop or redefine "${hit.name}" first.`,
238
+ object: { table, column },
239
+ }),
240
+ );
241
+ }
242
+ }
243
+ return out;
244
+ },
245
+ };
246
+
247
+ /** SL024: a non-INTEGER PRIMARY KEY column in a rowid table without NOT NULL. */
248
+ export const sl024: SlRule = {
249
+ meta: sl024Meta,
250
+ check(ctx) {
251
+ const out: Finding[] = [];
252
+ for (const stmt of ctx.migration.statements) {
253
+ if (stmt.node.type !== "create_table_stmt") continue;
254
+ if (tableOptions(stmt).includes("WITHOUT ROWID")) continue;
255
+ const table = tableOf(stmt) ?? "";
256
+ const keyed = new Set<string>();
257
+ for (const column of createdColumns(stmt)) if (constraintOf(column, "constraint_primary_key")) keyed.add(nameOf(column.name) ?? "");
258
+ for (const constraint of tableConstraints(stmt)) {
259
+ const inner = constraint.constraint ?? constraint;
260
+ if (inner.type === "constraint_primary_key") for (const spec of items(inner.columns)) keyed.add(nameOf(spec.expr ?? spec) ?? "");
261
+ }
262
+ for (const column of createdColumns(stmt)) {
263
+ const name = nameOf(column.name) ?? "";
264
+ if (!keyed.has(name)) continue;
265
+ if (constraintOf(column, "constraint_not_null")) continue;
266
+ // INTEGER PRIMARY KEY (single-column, ascending) is the rowid and can never be NULL.
267
+ const pk = constraintOf(column, "constraint_primary_key");
268
+ const direction = (pk?.direction ?? pk?.constraint?.direction)?.type;
269
+ if (pk && keyed.size === 1 && dataTypeName(column) === "INTEGER" && direction !== "sort_direction_desc") continue;
270
+ out.push(
271
+ finding(sl024, stmt, {
272
+ title: `PRIMARY KEY column "${name}" on "${table}" accepts NULL`,
273
+ message:
274
+ `A non-INTEGER primary key in a rowid table does not imply NOT NULL — SQLite keeps this old behaviour for compatibility — so NULL keys are accepted and each one counts as distinct. ` +
275
+ `Add NOT NULL to the column.`,
276
+ object: { table, column: name },
277
+ }),
278
+ );
279
+ }
280
+ }
281
+ return out;
282
+ },
283
+ };
284
+
285
+ const WRITE_TYPES = new Set([
286
+ "alter_table_stmt", "create_table_stmt", "create_index_stmt", "create_view_stmt", "create_trigger_stmt",
287
+ "drop_table_stmt", "drop_index_stmt", "drop_view_stmt", "drop_trigger_stmt", "insert_stmt", "update_stmt", "delete_stmt", "reindex_stmt",
288
+ ]);
289
+
290
+ /** SL025: a plain (deferred) BEGIN followed by writes. */
291
+ export const sl025: SlRule = {
292
+ meta: sl025Meta,
293
+ check(ctx) {
294
+ const out: Finding[] = [];
295
+ const statements = ctx.migration.statements;
296
+ for (const stmt of statements) {
297
+ if (stmt.node.type !== "start_transaction_stmt") continue;
298
+ const behavior = String((stmt.node as AnyNode).behaviorKw?.name ?? "DEFERRED").toUpperCase();
299
+ if (behavior !== "DEFERRED") continue;
300
+ const writes = statements.some(
301
+ (later) =>
302
+ later.index > stmt.index &&
303
+ WRITE_TYPES.has(later.node.type) &&
304
+ !statements.some((end) => end.index > stmt.index && end.index < later.index && (end.node.type === "commit_transaction_stmt" || end.node.type === "rollback_transaction_stmt")),
305
+ );
306
+ if (!writes) continue;
307
+ out.push(
308
+ finding(sl025, stmt, {
309
+ title: "Write migration starts with a DEFERRED transaction",
310
+ message:
311
+ `A plain BEGIN takes no lock until the first write, then upgrades a read lock to a write lock — and a lock upgrade that finds another writer fails at once with SQLITE_BUSY, busy_timeout or not. ` +
312
+ `Use BEGIN IMMEDIATE for a migration that writes.`,
313
+ }),
314
+ );
315
+ }
316
+ return out;
317
+ },
318
+ };
319
+
320
+ /** SL026: BEGIN inside an open transaction, or COMMIT/ROLLBACK with none open. */
321
+ export const sl026: SlRule = {
322
+ meta: sl026Meta,
323
+ check(ctx) {
324
+ const out: Finding[] = [];
325
+ let open = false;
326
+ for (const stmt of ctx.migration.statements) {
327
+ const type = stmt.node.type;
328
+ if (type === "start_transaction_stmt") {
329
+ if (open) {
330
+ out.push(
331
+ finding(sl026, stmt, {
332
+ title: "BEGIN inside an open transaction fails",
333
+ message: `SQLite cannot nest transactions: this BEGIN fails with "cannot start a transaction within a transaction". Remove it, or use SAVEPOINT for a nested scope.`,
334
+ }),
335
+ );
336
+ }
337
+ open = true;
338
+ } else if (type === "commit_transaction_stmt" || type === "rollback_transaction_stmt") {
339
+ if (!open) {
340
+ out.push(
341
+ finding(sl026, stmt, {
342
+ title: `${type === "commit_transaction_stmt" ? "COMMIT" : "ROLLBACK"} with no open transaction fails`,
343
+ message: `Nothing is open at this point in the file, so SQLite answers "cannot ${type === "commit_transaction_stmt" ? "commit" : "rollback"} - no transaction is active". If the runner wraps the file, drop the statement; otherwise add the BEGIN.`,
344
+ }),
345
+ );
346
+ }
347
+ open = false;
348
+ }
349
+ }
350
+ return out;
351
+ },
352
+ };
353
+
354
+ /** SL027: DELETE without WHERE on a parent whose children (declared here) cascade. */
355
+ export const sl027: SlRule = {
356
+ meta: sl027Meta,
357
+ check(ctx) {
358
+ const out: Finding[] = [];
359
+ const cascading = ctx.migration.statements.flatMap(childKeys).filter((key) => key.cascade);
360
+ if (cascading.length === 0) return out;
361
+ for (const stmt of ctx.migration.statements) {
362
+ if (stmt.node.type !== "delete_stmt" || hasWhere(stmt)) continue;
363
+ const table = tableOf(stmt) ?? "";
364
+ if (tableCreatedInMigration(ctx.migration, stmt.index, table)) continue;
365
+ const children = cascading.filter((key) => key.parent.toLowerCase() === table.toLowerCase()).map((key) => key.table);
366
+ if (children.length === 0) continue;
367
+ out.push(
368
+ finding(sl027, stmt, {
369
+ title: `DELETE FROM "${table}" with no WHERE also empties ${children.map((c) => `"${c}"`).join(", ")}`,
370
+ message:
371
+ `With foreign keys on, ON DELETE CASCADE removes every referencing row in ${children.join(", ")} in the same statement; with them off, nothing happens to those tables — the outcome depends on the runner's connection. ` +
372
+ `Add the predicate, batch it, and delete the children explicitly so the migration reads the way it behaves.`,
373
+ object: { table },
374
+ }),
375
+ );
376
+ }
377
+ return out;
378
+ },
379
+ };
380
+
381
+ const NON_DETERMINISTIC = new Set(["random", "randomblob", "changes", "total_changes", "last_insert_rowid", "sqlite_offset"]);
382
+
383
+ /** SL028: CREATE INDEX over an expression that calls a non-deterministic function. */
384
+ export const sl028: SlRule = {
385
+ meta: sl028Meta,
386
+ check(ctx) {
387
+ const out: Finding[] = [];
388
+ for (const stmt of ctx.migration.statements) {
389
+ if (stmt.node.type !== "create_index_stmt") continue;
390
+ const calls = descendants((stmt.node as AnyNode).columns, (n) => n.type === "func_call")
391
+ .map((call) => String(nameOf(call.name) ?? "").toLowerCase())
392
+ .filter((name) => NON_DETERMINISTIC.has(name));
393
+ if (calls.length === 0) continue;
394
+ const table = tableOf(stmt) ?? "";
395
+ const index = nameOf((stmt.node as AnyNode).name);
396
+ out.push(
397
+ finding(sl028, stmt, {
398
+ title: `Index "${index}" uses ${calls[0]}() — SQLite refuses it`,
399
+ message: `An index expression must be deterministic; ${calls[0]}() is not, so CREATE INDEX fails ("non-deterministic functions prohibited in index expressions") and the migration stops. Index a deterministic expression or a stored column instead.`,
400
+ object: { table, index },
401
+ }),
402
+ );
403
+ }
404
+ return out;
405
+ },
406
+ };
407
+
408
+ /** SL029: AUTOINCREMENT on a WITHOUT ROWID table — the CREATE fails. */
409
+ export const sl029: SlRule = {
410
+ meta: sl029Meta,
411
+ check(ctx) {
412
+ const out: Finding[] = [];
413
+ for (const stmt of ctx.migration.statements) {
414
+ if (stmt.node.type !== "create_table_stmt" || !tableOptions(stmt).includes("WITHOUT ROWID")) continue;
415
+ const column = createdColumns(stmt).find((c) => constraintOf(c, "constraint_auto_increment"));
416
+ if (!column) continue;
417
+ const table = tableOf(stmt) ?? "";
418
+ out.push(
419
+ finding(sl029, stmt, {
420
+ title: `AUTOINCREMENT on WITHOUT ROWID table "${table}" is refused`,
421
+ message: `A WITHOUT ROWID table has no rowid for AUTOINCREMENT to drive, so SQLite rejects the statement ("AUTOINCREMENT not allowed on WITHOUT ROWID tables") and the table is not created. Drop one of the two.`,
422
+ object: { table, column: nameOf(column.name) },
423
+ }),
424
+ );
425
+ }
426
+ return out;
427
+ },
428
+ };
429
+
430
+ /** SL030: ATTACH DATABASE inside a migration. */
431
+ export const sl030: SlRule = {
432
+ meta: sl030Meta,
433
+ check(ctx) {
434
+ const out: Finding[] = [];
435
+ for (const stmt of ctx.migration.statements) {
436
+ if (stmt.node.type !== "attach_database_stmt") continue;
437
+ out.push(
438
+ finding(sl030, stmt, {
439
+ title: "ATTACH DATABASE in a migration",
440
+ message: `This opens a second file by a machine-specific path on the runner's connection, for the life of that connection. Move the import into an operational job that takes the path as a parameter, and keep the migration to the schema.`,
441
+ }),
442
+ );
443
+ }
444
+ return out;
445
+ },
446
+ };
@@ -0,0 +1,76 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /** Whole-table effects of ALTER TABLE: the rewrite DROP COLUMN performs, and the deploy window a rename opens. */
3
+ import type { Finding } from "@bolvrk/engine";
4
+ import { alterActions, nameOf, tableCreatedInMigration, tableOf, tableRebuilds } from "../helpers";
5
+ import { sl006Meta, sl007Meta } from "../metas/index";
6
+ import { finding, type SlRule } from "../rule";
7
+
8
+ /** SL006: DROP COLUMN rewrites the table (and fails if the column is referenced). */
9
+ export const sl006: SlRule = {
10
+ meta: sl006Meta,
11
+ check(ctx) {
12
+ const out: Finding[] = [];
13
+ for (const stmt of ctx.migration.statements) {
14
+ for (const action of alterActions(stmt)) {
15
+ if (action.type !== "alter_action_drop_column") continue;
16
+ const table = tableOf(stmt) ?? "";
17
+ if (table && tableCreatedInMigration(ctx.migration, stmt.index, table)) continue;
18
+ const column = nameOf(action.column);
19
+ out.push(
20
+ finding(sl006, stmt, {
21
+ title: `DROP COLUMN "${column}" rewrites every row of "${table}"`,
22
+ message:
23
+ `SQLite purges the column by rewriting the table's content in one write transaction — the single write lock is held for the whole copy, and every other writer waits. ` +
24
+ `The statement also fails if an index, foreign key, view, trigger or generated column still names "${column}". Remove code references first, drop dependent objects, and schedule the rewrite.`,
25
+ object: { table, column },
26
+ }),
27
+ );
28
+ }
29
+ }
30
+ return out;
31
+ },
32
+ };
33
+
34
+ /** SL007: RENAME COLUMN / RENAME TO breaks the previous release the moment it commits. */
35
+ export const sl007: SlRule = {
36
+ meta: sl007Meta,
37
+ check(ctx) {
38
+ const out: Finding[] = [];
39
+ for (const stmt of ctx.migration.statements) {
40
+ for (const action of alterActions(stmt)) {
41
+ const table = tableOf(stmt) ?? "";
42
+ if (action.type === "alter_action_rename_column") {
43
+ if (table && tableCreatedInMigration(ctx.migration, stmt.index, table)) continue;
44
+ const from = nameOf(action.oldName);
45
+ const to = nameOf(action.newName);
46
+ out.push(
47
+ finding(sl007, stmt, {
48
+ title: `RENAME COLUMN "${from}" → "${to}" on "${table}" breaks code still using the old name`,
49
+ message:
50
+ `The rename is instant; any process still running the previous release fails on "${from}" from the next statement it sends. ` +
51
+ `Expand and contract instead: add "${to}", dual-write, backfill, switch readers, drop "${from}" in a later release.`,
52
+ object: { table, column: from },
53
+ }),
54
+ );
55
+ } else if (action.type === "alter_action_rename") {
56
+ // A rename that finishes a rebuild (the dropped name comes back, onto a table this file created) is the documented recipe, not a deploy-window change.
57
+ // Renaming a table that already existed onto a dropped name is still a rename of that table: code using its old name breaks.
58
+ const to = nameOf(action.newName) ?? "";
59
+ const finishesRebuild = tableRebuilds(ctx.migration).some((r) => r.rename.index === stmt.index && r.replacementCreatedHere);
60
+ if (finishesRebuild) continue;
61
+ if (table && tableCreatedInMigration(ctx.migration, stmt.index, table)) continue;
62
+ out.push(
63
+ finding(sl007, stmt, {
64
+ title: `RENAME TABLE "${table}" → "${to}" breaks code still using the old name`,
65
+ message:
66
+ `The rename is instant; any process still running the previous release fails on "${table}" from the next statement it sends. ` +
67
+ `Create the new table and copy, and swap names only when no deployed code reads the old one.`,
68
+ object: { table },
69
+ }),
70
+ );
71
+ }
72
+ }
73
+ }
74
+ return out;
75
+ },
76
+ };