@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.
- package/LICENSE.md +105 -0
- package/README.md +26 -0
- package/dist/engine.d.ts +24 -0
- package/dist/helpers.d.ts +77 -0
- package/dist/index-m4sztshc.js +615 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1421 -0
- package/dist/metas/index.d.ts +53 -0
- package/dist/metas/index.js +80 -0
- package/dist/parser.d.ts +33 -0
- package/dist/rule.d.ts +17 -0
- package/dist/rules/add-column.d.ts +13 -0
- package/dist/rules/dml-and-maintenance.d.ts +7 -0
- package/dist/rules/index.d.ts +5 -0
- package/dist/rules/indexes-and-types.d.ts +7 -0
- package/dist/rules/performance.d.ts +11 -0
- package/dist/rules/rebuild-pragmas.d.ts +11 -0
- package/dist/rules/schema-and-transactions.d.ts +23 -0
- package/dist/rules/table-shape.d.ts +5 -0
- package/package.json +45 -0
- package/src/engine.ts +89 -0
- package/src/helpers.ts +254 -0
- package/src/index.ts +5 -0
- package/src/metas/LICENSE +202 -0
- package/src/metas/index.ts +598 -0
- package/src/parser.ts +68 -0
- package/src/rule.ts +36 -0
- package/src/rules/LICENSE +202 -0
- package/src/rules/add-column.ts +180 -0
- package/src/rules/dml-and-maintenance.ts +75 -0
- package/src/rules/index.ts +18 -0
- package/src/rules/indexes-and-types.ts +99 -0
- package/src/rules/performance.ts +207 -0
- package/src/rules/rebuild-pragmas.ts +142 -0
- package/src/rules/schema-and-transactions.ts +446 -0
- package/src/rules/table-shape.ts +76 -0
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* `@bolvrk/engine-sqlite/metas` — RuleMeta objects ONLY. Nothing in this
|
|
4
|
+
* module may carry a `check` function or import the parser: the website
|
|
5
|
+
* bundles this entry into the browser to render the rule pages and the
|
|
6
|
+
* database filter. The implementations live in ../rules and import their
|
|
7
|
+
* meta from here, never the other way around. A test asserts no exported
|
|
8
|
+
* object has a `check` property.
|
|
9
|
+
*
|
|
10
|
+
* Every danger claim below is backed by the SQLite documentation named in
|
|
11
|
+
* the comment next to `verified`, and the "fails outright" claims are
|
|
12
|
+
* re-proven against a real SQLite in test/claims.test.ts.
|
|
13
|
+
*/
|
|
14
|
+
import type { RuleMeta } from "@bolvrk/engine";
|
|
15
|
+
|
|
16
|
+
/** The SL corpus version. Bump on any rule addition, removal, or behavior change. */
|
|
17
|
+
export const SL_RULE_CORPUS_VERSION = "0.4.0";
|
|
18
|
+
|
|
19
|
+
export const sl001Meta: RuleMeta = {
|
|
20
|
+
id: "SL001",
|
|
21
|
+
version: "1.0.0",
|
|
22
|
+
name: "ADD COLUMN NOT NULL without a default",
|
|
23
|
+
tier: 1,
|
|
24
|
+
defaultSeverity: "critical",
|
|
25
|
+
rationale:
|
|
26
|
+
"SQLite refuses to add a NOT NULL column unless it has a default other than NULL — the statement fails with \"Cannot add a NOT NULL column with default value NULL\", so the migration stops here in every environment, including the one that matters.",
|
|
27
|
+
example: "ALTER TABLE orders ADD COLUMN region TEXT NOT NULL;",
|
|
28
|
+
fix: {
|
|
29
|
+
description:
|
|
30
|
+
"Give the column a non-NULL default, or add it nullable, backfill, and enforce NOT NULL by rebuilding the table later. In SQLite a default on ADD COLUMN is free: existing rows are not rewritten, the default is read from the schema.",
|
|
31
|
+
example: "ALTER TABLE orders ADD COLUMN region TEXT NOT NULL DEFAULT 'eu';",
|
|
32
|
+
},
|
|
33
|
+
verified: true, // SQLite docs, ALTER TABLE ADD COLUMN: "If a NOT NULL constraint is specified, then the column must have a default value other than NULL."
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const sl002Meta: RuleMeta = {
|
|
37
|
+
id: "SL002",
|
|
38
|
+
version: "1.0.0",
|
|
39
|
+
name: "ADD COLUMN with PRIMARY KEY or UNIQUE",
|
|
40
|
+
tier: 1,
|
|
41
|
+
defaultSeverity: "critical",
|
|
42
|
+
rationale:
|
|
43
|
+
"ALTER TABLE ADD COLUMN cannot carry a PRIMARY KEY or UNIQUE constraint — SQLite rejects the statement outright (\"Cannot add a PRIMARY KEY column\", \"Cannot add a UNIQUE column\"). The migration fails, and the constraint it wanted still does not exist.",
|
|
44
|
+
example: "ALTER TABLE orders ADD COLUMN external_id TEXT UNIQUE;",
|
|
45
|
+
fix: {
|
|
46
|
+
description:
|
|
47
|
+
"Add the column plain, then create the unique index as its own statement — that is what UNIQUE would have built anyway. A new primary key needs the table rebuilt: create the new table, copy, drop, rename.",
|
|
48
|
+
example: "ALTER TABLE orders ADD COLUMN external_id TEXT;\nCREATE UNIQUE INDEX orders_external_id ON orders (external_id);",
|
|
49
|
+
},
|
|
50
|
+
verified: true, // SQLite docs, ALTER TABLE ADD COLUMN: "The column may not have a PRIMARY KEY or UNIQUE constraint."
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const sl003Meta: RuleMeta = {
|
|
54
|
+
id: "SL003",
|
|
55
|
+
version: "1.0.0",
|
|
56
|
+
name: "ADD COLUMN with a non-constant default",
|
|
57
|
+
tier: 1,
|
|
58
|
+
defaultSeverity: "critical",
|
|
59
|
+
rationale:
|
|
60
|
+
"A column added with ALTER TABLE may not default to CURRENT_TIME, CURRENT_DATE, CURRENT_TIMESTAMP or a parenthesised expression: SQLite rejects the statement (\"Cannot add a column with non-constant default\"). The pattern is common in migrations ported from Postgres, where it works.",
|
|
61
|
+
example: "ALTER TABLE orders ADD COLUMN created_at TEXT DEFAULT CURRENT_TIMESTAMP;",
|
|
62
|
+
fix: {
|
|
63
|
+
description:
|
|
64
|
+
"Add the column with a constant default (or none), and set the timestamp from application code or a trigger. If the default must be an expression, the column has to be part of a table rebuild.",
|
|
65
|
+
example: "ALTER TABLE orders ADD COLUMN created_at TEXT;\n-- then, from the application or a trigger:\n-- UPDATE orders SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL;",
|
|
66
|
+
},
|
|
67
|
+
verified: true, // SQLite docs, ALTER TABLE ADD COLUMN: "The column may not have a default value of CURRENT_TIME, CURRENT_DATE, CURRENT_TIMESTAMP, or an expression in parentheses."
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const sl004Meta: RuleMeta = {
|
|
71
|
+
id: "SL004",
|
|
72
|
+
version: "1.0.0",
|
|
73
|
+
name: "ADD COLUMN GENERATED ... STORED",
|
|
74
|
+
tier: 1,
|
|
75
|
+
defaultSeverity: "critical",
|
|
76
|
+
rationale:
|
|
77
|
+
"A STORED generated column cannot be added with ALTER TABLE — SQLite only allows VIRTUAL generated columns there (\"cannot add a STORED column\"). The statement fails; nothing is added.",
|
|
78
|
+
example: "ALTER TABLE orders ADD COLUMN total_cents INTEGER GENERATED ALWAYS AS (qty * unit_cents) STORED;",
|
|
79
|
+
fix: {
|
|
80
|
+
description:
|
|
81
|
+
"Declare the generated column VIRTUAL, which ALTER TABLE accepts and which costs nothing at write time. A STORED column belongs in CREATE TABLE — for an existing table that means a rebuild.",
|
|
82
|
+
example: "ALTER TABLE orders ADD COLUMN total_cents INTEGER GENERATED ALWAYS AS (qty * unit_cents) VIRTUAL;",
|
|
83
|
+
},
|
|
84
|
+
verified: true, // SQLite docs, ALTER TABLE ADD COLUMN: "The column may not be GENERATED ALWAYS ... STORED, though VIRTUAL columns are allowed."
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const sl005Meta: RuleMeta = {
|
|
88
|
+
id: "SL005",
|
|
89
|
+
version: "1.0.0",
|
|
90
|
+
name: "ADD COLUMN REFERENCES with a non-NULL default",
|
|
91
|
+
tier: 1,
|
|
92
|
+
defaultSeverity: "warning",
|
|
93
|
+
rationale:
|
|
94
|
+
"When foreign keys are enforced (PRAGMA foreign_keys=ON, which most applications set on every connection), a column added with a REFERENCES clause must default to NULL — anything else fails with \"Cannot add a REFERENCES column with non-NULL default value\". Whether it fails depends on a per-connection pragma, so the same file passes in one runner and stops in another.",
|
|
95
|
+
example: "ALTER TABLE orders ADD COLUMN warehouse_id INTEGER REFERENCES warehouses(id) DEFAULT 1;",
|
|
96
|
+
fix: {
|
|
97
|
+
description:
|
|
98
|
+
"Add the referencing column with a NULL default, backfill the rows that need a value, and let the application set it from then on.",
|
|
99
|
+
example: "ALTER TABLE orders ADD COLUMN warehouse_id INTEGER REFERENCES warehouses(id);\n-- backfill from the application, in batches",
|
|
100
|
+
},
|
|
101
|
+
verified: true, // SQLite docs, ALTER TABLE ADD COLUMN: "If foreign key constraints are enabled and a column with a REFERENCES clause is added, the column must have a default value of NULL."
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const sl006Meta: RuleMeta = {
|
|
105
|
+
id: "SL006",
|
|
106
|
+
version: "1.0.0",
|
|
107
|
+
name: "DROP COLUMN rewrites the table",
|
|
108
|
+
tier: 1,
|
|
109
|
+
defaultSeverity: "warning",
|
|
110
|
+
rationale:
|
|
111
|
+
"DROP COLUMN edits the schema and then rewrites every row of the table to purge the column's values — one write transaction that holds the database's single write lock for the length of the copy. It also fails outright if anything else in the schema names the column (an index, a foreign key, a view, a trigger, a generated column) and is unsupported before SQLite 3.35.",
|
|
112
|
+
example: "ALTER TABLE orders DROP COLUMN legacy_flag;",
|
|
113
|
+
fix: {
|
|
114
|
+
description:
|
|
115
|
+
"Stop reading the column in code first, let a release soak, and drop it in a scheduled migration — SQLite's write lock means every other writer waits for the rewrite. If the column is indexed or referenced, drop those objects first or the statement fails.",
|
|
116
|
+
example: "-- release N: remove every code reference to legacy_flag\n-- release N+1, scheduled:\n-- DROP INDEX IF EXISTS orders_legacy_flag;\n-- ALTER TABLE orders DROP COLUMN legacy_flag;",
|
|
117
|
+
},
|
|
118
|
+
verified: true, // SQLite docs, ALTER TABLE DROP COLUMN: "the table content is rewritten to purge the data associated with that column"; "only works if the column is not referenced by any other parts of the schema".
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export const sl007Meta: RuleMeta = {
|
|
122
|
+
id: "SL007",
|
|
123
|
+
version: "1.0.0",
|
|
124
|
+
name: "RENAME COLUMN or RENAME TABLE during a deploy",
|
|
125
|
+
tier: 1,
|
|
126
|
+
defaultSeverity: "warning",
|
|
127
|
+
rationale:
|
|
128
|
+
"A rename is instant in SQLite, and that is the problem: the moment it commits, every process still running the previous release fails on the old name. Views and triggers are rewritten to the new name, but application SQL is not.",
|
|
129
|
+
example: "ALTER TABLE orders RENAME COLUMN qty TO quantity;",
|
|
130
|
+
fix: {
|
|
131
|
+
description:
|
|
132
|
+
"Expand and contract: add the new column, dual-write from the application, backfill, switch readers, then drop the old column in a later release. For a table, create the new one, copy, and swap only when no deployed code reads the old name.",
|
|
133
|
+
example: "ALTER TABLE orders ADD COLUMN quantity INTEGER;\n-- dual-write from the application; backfill in batches\n-- later release, after readers moved:\n-- ALTER TABLE orders DROP COLUMN qty;",
|
|
134
|
+
},
|
|
135
|
+
verified: true, // SQLite docs, ALTER TABLE RENAME: renames modify the schema in place; references in views and triggers are rewritten, application SQL is not.
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const sl008Meta: RuleMeta = {
|
|
139
|
+
id: "SL008",
|
|
140
|
+
version: "1.0.0",
|
|
141
|
+
name: "ADD COLUMN with a CHECK constraint scans the table",
|
|
142
|
+
tier: 1,
|
|
143
|
+
defaultSeverity: "note",
|
|
144
|
+
rationale:
|
|
145
|
+
"Adding a column is normally a schema-only change in SQLite. With a CHECK constraint (or NOT NULL on a generated column) the whole table is scanned to verify existing rows first — under the write lock, proportional to the table's size.",
|
|
146
|
+
example: "ALTER TABLE orders ADD COLUMN priority INTEGER DEFAULT 0 CHECK (priority BETWEEN 0 AND 9);",
|
|
147
|
+
fix: {
|
|
148
|
+
description:
|
|
149
|
+
"Add the column without the CHECK when the table is large and the constraint is guaranteed by the default and the application; enforce it in a rebuild later, or accept the scan in a scheduled window.",
|
|
150
|
+
example: "ALTER TABLE orders ADD COLUMN priority INTEGER DEFAULT 0;",
|
|
151
|
+
},
|
|
152
|
+
verified: true, // SQLite docs, ALTER TABLE ADD COLUMN: "if the new column has a CHECK constraint, or a NOT NULL constraint on a generated column, then the entire table is scanned to verify" (3.37.0+).
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export const sl009Meta: RuleMeta = {
|
|
156
|
+
id: "SL009",
|
|
157
|
+
version: "1.0.0",
|
|
158
|
+
name: "Table rebuild with foreign keys still enforced",
|
|
159
|
+
tier: 1,
|
|
160
|
+
defaultSeverity: "warning",
|
|
161
|
+
rationale:
|
|
162
|
+
"This migration rebuilds a table the documented way — create the new table, copy, drop the old, rename — but never turns foreign keys off first. With PRAGMA foreign_keys=ON the DROP TABLE performs an implicit DELETE FROM: rows referencing the table fail the migration with a constraint error, or ON DELETE CASCADE quietly removes them.",
|
|
163
|
+
example: "CREATE TABLE orders_new (id INTEGER PRIMARY KEY, qty INTEGER NOT NULL);\nINSERT INTO orders_new SELECT id, qty FROM orders;\nDROP TABLE orders;\nALTER TABLE orders_new RENAME TO orders;",
|
|
164
|
+
fix: {
|
|
165
|
+
description:
|
|
166
|
+
"Follow the twelve steps from the SQLite manual: PRAGMA foreign_keys=OFF outside the transaction, rebuild inside it, PRAGMA foreign_key_check before COMMIT, and PRAGMA foreign_keys=ON after.",
|
|
167
|
+
example: "PRAGMA foreign_keys = OFF;\nBEGIN;\nCREATE TABLE orders_new (id INTEGER PRIMARY KEY, qty INTEGER NOT NULL);\nINSERT INTO orders_new SELECT id, qty FROM orders;\nDROP TABLE orders;\nALTER TABLE orders_new RENAME TO orders;\nPRAGMA foreign_key_check;\nCOMMIT;\nPRAGMA foreign_keys = ON;",
|
|
168
|
+
},
|
|
169
|
+
verified: true, // SQLite docs, Making Other Kinds Of Table Schema Changes (12 steps) and Foreign Key Support §5: "If foreign key constraints are enabled ... DROP TABLE ... performs an implicit DELETE FROM".
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const sl010Meta: RuleMeta = {
|
|
173
|
+
id: "SL010",
|
|
174
|
+
version: "1.0.0",
|
|
175
|
+
name: "PRAGMA that is a no-op inside a transaction",
|
|
176
|
+
tier: 1,
|
|
177
|
+
defaultSeverity: "warning",
|
|
178
|
+
rationale:
|
|
179
|
+
"PRAGMA foreign_keys and PRAGMA journal_mode do nothing while a transaction is open — SQLite does not error, it silently leaves the setting as it was. A rebuild that switches foreign keys off after BEGIN runs with them on, and everything SL009 warns about applies.",
|
|
180
|
+
example: "BEGIN;\nPRAGMA foreign_keys = OFF;\nDROP TABLE orders;\nCOMMIT;",
|
|
181
|
+
fix: {
|
|
182
|
+
description: "Issue the pragma before BEGIN (and re-enable after COMMIT). If the migration runner wraps every file in a transaction, the pragma has to be set by the runner itself.",
|
|
183
|
+
example: "PRAGMA foreign_keys = OFF;\nBEGIN;\nDROP TABLE orders;\nCOMMIT;\nPRAGMA foreign_keys = ON;",
|
|
184
|
+
},
|
|
185
|
+
verified: true, // SQLite docs, PRAGMA foreign_keys: "This pragma is a no-op within a transaction"; PRAGMA journal_mode: "cannot be changed while a transaction is active".
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export const sl011Meta: RuleMeta = {
|
|
189
|
+
id: "SL011",
|
|
190
|
+
version: "1.0.0",
|
|
191
|
+
name: "Foreign keys switched off and not back on",
|
|
192
|
+
tier: 1,
|
|
193
|
+
defaultSeverity: "warning",
|
|
194
|
+
rationale:
|
|
195
|
+
"PRAGMA foreign_keys=OFF is per connection and does not reset at COMMIT. A migration that turns enforcement off for a rebuild and never turns it on leaves the runner's connection accepting orphans for as long as it lives — every later statement in the run, and any backfill sharing the connection, goes unchecked.",
|
|
196
|
+
example: "PRAGMA foreign_keys = OFF;\nDROP TABLE orders;",
|
|
197
|
+
fix: {
|
|
198
|
+
description: "End the file with PRAGMA foreign_keys=ON, after the transaction that needed it off.",
|
|
199
|
+
example: "PRAGMA foreign_keys = OFF;\nBEGIN;\nDROP TABLE orders;\nCOMMIT;\nPRAGMA foreign_keys = ON;",
|
|
200
|
+
},
|
|
201
|
+
verified: true, // SQLite docs, PRAGMA foreign_keys: the setting is per connection and persists until changed.
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export const sl012Meta: RuleMeta = {
|
|
205
|
+
id: "SL012",
|
|
206
|
+
version: "1.0.0",
|
|
207
|
+
name: "Foreign keys re-enabled without a foreign_key_check",
|
|
208
|
+
tier: 1,
|
|
209
|
+
defaultSeverity: "note",
|
|
210
|
+
rationale:
|
|
211
|
+
"Turning foreign keys back on does not validate what happened while they were off. Step 10 of the SQLite rebuild recipe is PRAGMA foreign_key_check before COMMIT — without it, an orphaned row created during the rebuild is discovered by the first user query that joins on it.",
|
|
212
|
+
example: "PRAGMA foreign_keys = OFF;\nBEGIN;\nDROP TABLE orders;\nALTER TABLE orders_new RENAME TO orders;\nCOMMIT;\nPRAGMA foreign_keys = ON;",
|
|
213
|
+
fix: {
|
|
214
|
+
description: "Run PRAGMA foreign_key_check inside the transaction, before COMMIT, so a violation can still be rolled back.",
|
|
215
|
+
example: "PRAGMA foreign_keys = OFF;\nBEGIN;\nDROP TABLE orders;\nALTER TABLE orders_new RENAME TO orders;\nPRAGMA foreign_key_check;\nCOMMIT;\nPRAGMA foreign_keys = ON;",
|
|
216
|
+
},
|
|
217
|
+
verified: true, // SQLite docs, Making Other Kinds Of Table Schema Changes, step 10: "If foreign key constraints were originally enabled then run PRAGMA foreign_key_check".
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export const sl013Meta: RuleMeta = {
|
|
221
|
+
id: "SL013",
|
|
222
|
+
version: "1.0.0",
|
|
223
|
+
name: "Unbounded UPDATE or DELETE",
|
|
224
|
+
tier: 1,
|
|
225
|
+
defaultSeverity: "warning",
|
|
226
|
+
rationale:
|
|
227
|
+
"An UPDATE or DELETE with no WHERE touches every row in one write transaction. SQLite has one writer at a time: the whole application queues behind it (or hits SQLITE_BUSY) for as long as the statement runs, and the rollback journal or WAL grows by the size of the change.",
|
|
228
|
+
example: "UPDATE orders SET status = 'archived';",
|
|
229
|
+
fix: {
|
|
230
|
+
description:
|
|
231
|
+
"State the predicate, and batch by rowid range from an operational job so each chunk commits on its own and the write lock is released between batches.",
|
|
232
|
+
example: "-- batched job, not a migration statement:\n-- UPDATE orders SET status = 'archived'\n-- WHERE status = 'closed' AND rowid BETWEEN ? AND ?;",
|
|
233
|
+
},
|
|
234
|
+
verified: true, // SQLite docs, File Locking And Concurrency: a single writer holds the lock for the transaction; WAL: readers proceed, writers serialize.
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
export const sl014Meta: RuleMeta = {
|
|
238
|
+
id: "SL014",
|
|
239
|
+
version: "1.0.0",
|
|
240
|
+
name: "VACUUM in a migration",
|
|
241
|
+
tier: 1,
|
|
242
|
+
defaultSeverity: "warning",
|
|
243
|
+
rationale:
|
|
244
|
+
"VACUUM rewrites the entire database file into a temporary copy and swaps it in: it needs up to twice the database's size in free disk space, holds the write lock for the whole rewrite, and fails outright if a transaction is open on the connection — so inside a migration runner's transaction it is an error, and outside one it is an outage-sized pause.",
|
|
245
|
+
example: "VACUUM;",
|
|
246
|
+
fix: {
|
|
247
|
+
description: "Vacuum from an operational job in a maintenance window, never from a migration. If space reclaim must be continuous, PRAGMA auto_vacuum=INCREMENTAL with incremental_vacuum in small steps.",
|
|
248
|
+
example: "-- operational runbook, not a migration:\n-- sqlite3 app.db 'VACUUM;'",
|
|
249
|
+
},
|
|
250
|
+
verified: true, // SQLite docs, VACUUM: "A VACUUM will fail if there is an open transaction"; "can require up to twice the ... disk space".
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
export const sl015Meta: RuleMeta = {
|
|
254
|
+
id: "SL015",
|
|
255
|
+
version: "1.0.0",
|
|
256
|
+
name: "REINDEX of everything",
|
|
257
|
+
tier: 1,
|
|
258
|
+
defaultSeverity: "warning",
|
|
259
|
+
rationale:
|
|
260
|
+
"REINDEX with no argument rebuilds every index in every attached database in one write transaction — the write lock is held for the total, not per table. In a migration it is almost always broader than intended.",
|
|
261
|
+
example: "REINDEX;",
|
|
262
|
+
fix: {
|
|
263
|
+
description: "Name the index or table that actually needs rebuilding — REINDEX is only needed after a collation change.",
|
|
264
|
+
example: "REINDEX orders_status_idx;",
|
|
265
|
+
},
|
|
266
|
+
verified: true, // SQLite docs, REINDEX: "If no arguments ... all indices in all attached databases are rebuilt."
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
export const sl016Meta: RuleMeta = {
|
|
270
|
+
id: "SL016",
|
|
271
|
+
version: "1.0.0",
|
|
272
|
+
name: "Index build on an existing table",
|
|
273
|
+
tier: 1,
|
|
274
|
+
defaultSeverity: "note",
|
|
275
|
+
rationale:
|
|
276
|
+
"SQLite has no CONCURRENTLY: CREATE INDEX scans the table and writes the index under the database's write lock, and every other writer waits (or gets SQLITE_BUSY) for the duration. Fine on a small table; on a large one this is the moment the application stalls.",
|
|
277
|
+
example: "CREATE INDEX orders_status_idx ON orders (status);",
|
|
278
|
+
fix: {
|
|
279
|
+
description: "Build large indexes in a window, with busy_timeout set on the application's connections so a wait is a wait and not an error.",
|
|
280
|
+
example: "-- in the maintenance window, with PRAGMA busy_timeout set on application connections:\n-- CREATE INDEX orders_status_idx ON orders (status);",
|
|
281
|
+
},
|
|
282
|
+
verified: true, // SQLite docs, File Locking And Concurrency / WAL: one writer at a time; CREATE INDEX is a write transaction over the whole table.
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
export const sl017Meta: RuleMeta = {
|
|
286
|
+
id: "SL017",
|
|
287
|
+
version: "1.0.0",
|
|
288
|
+
name: "INT PRIMARY KEY is not a rowid alias",
|
|
289
|
+
tier: 1,
|
|
290
|
+
defaultSeverity: "note",
|
|
291
|
+
rationale:
|
|
292
|
+
"Only a column declared exactly INTEGER PRIMARY KEY becomes an alias for the rowid. INT PRIMARY KEY (or BIGINT, or INTEGER PRIMARY KEY DESC) is an ordinary column with a separate unique index: every lookup by primary key is an extra B-tree hop, the value is not the rowid, and VACUUM may renumber the real rowid underneath it.",
|
|
293
|
+
example: "CREATE TABLE orders (id INT PRIMARY KEY, qty INTEGER);",
|
|
294
|
+
fix: {
|
|
295
|
+
description: "Spell the type INTEGER, exactly, on the primary key column of a rowid table.",
|
|
296
|
+
example: "CREATE TABLE orders (id INTEGER PRIMARY KEY, qty INTEGER);",
|
|
297
|
+
},
|
|
298
|
+
verified: true, // SQLite docs, ROWIDs and the INTEGER PRIMARY KEY: "the declared type must be exactly INTEGER"; INTEGER PRIMARY KEY DESC quirk documented in the same section.
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
export const sl018Meta: RuleMeta = {
|
|
302
|
+
id: "SL018",
|
|
303
|
+
version: "1.0.0",
|
|
304
|
+
name: "AUTOINCREMENT where a plain INTEGER PRIMARY KEY would do",
|
|
305
|
+
tier: 1,
|
|
306
|
+
defaultSeverity: "note",
|
|
307
|
+
rationale:
|
|
308
|
+
"AUTOINCREMENT makes every insert also update the sqlite_sequence table, and the SQLite manual itself says it should be avoided unless strictly needed. Its one guarantee — a deleted rowid is never reused — is rarely what a migration author meant.",
|
|
309
|
+
example: "CREATE TABLE orders (id INTEGER PRIMARY KEY AUTOINCREMENT, qty INTEGER);",
|
|
310
|
+
fix: {
|
|
311
|
+
description: "Drop the keyword; INTEGER PRIMARY KEY already assigns increasing rowids. Keep AUTOINCREMENT only when reuse of a deleted id would be a correctness bug.",
|
|
312
|
+
example: "CREATE TABLE orders (id INTEGER PRIMARY KEY, qty INTEGER);",
|
|
313
|
+
},
|
|
314
|
+
verified: true, // SQLite docs, AUTOINCREMENT: "imposes extra CPU, memory, disk space, and disk I/O overhead and should be avoided if not strictly needed. It is usually not needed."
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
export const sl019Meta: RuleMeta = {
|
|
318
|
+
id: "SL019",
|
|
319
|
+
version: "1.0.0",
|
|
320
|
+
name: "Table rebuild that drops the old table before copying its rows",
|
|
321
|
+
tier: 1,
|
|
322
|
+
defaultSeverity: "critical",
|
|
323
|
+
rationale:
|
|
324
|
+
"The rebuild recipe is create, copy, drop, rename. This migration creates the new table, drops the old one and renames the new one onto its name, but never copies the rows across before the DROP. DROP TABLE discards them; the rename brings back the name with an empty table, and nothing in SQLite fails to say so.",
|
|
325
|
+
example: "CREATE TABLE orders_new (id INTEGER PRIMARY KEY, qty INTEGER NOT NULL);\nDROP TABLE orders;\nALTER TABLE orders_new RENAME TO orders;",
|
|
326
|
+
fix: {
|
|
327
|
+
description:
|
|
328
|
+
"Copy before dropping: INSERT INTO the new table SELECT from the old, inside the same transaction, so a failed copy rolls the whole rebuild back. If the table is meant to come back empty, suppress the rule with a reason that says so.",
|
|
329
|
+
example: "BEGIN;\nCREATE TABLE orders_new (id INTEGER PRIMARY KEY, qty INTEGER NOT NULL);\nINSERT INTO orders_new SELECT id, qty FROM orders;\nDROP TABLE orders;\nALTER TABLE orders_new RENAME TO orders;\nCOMMIT;",
|
|
330
|
+
},
|
|
331
|
+
verified: true, // SQLite docs, Making Other Kinds Of Table Schema Changes: step 5 (copy) precedes step 6 (drop); DROP TABLE removes the table and its content. Re-proven in test/claims.test.ts.
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
export const sl020Meta: RuleMeta = {
|
|
335
|
+
id: "SL020",
|
|
336
|
+
version: "1.0.0",
|
|
337
|
+
name: "CREATE TABLE without STRICT",
|
|
338
|
+
tier: 1,
|
|
339
|
+
defaultSeverity: "note",
|
|
340
|
+
rationale:
|
|
341
|
+
"Without STRICT, SQLite's column types are suggestions: a TEXT value goes into an INTEGER column, a blob into a date, and nothing complains until the application reads it back. STRICT (3.37+) makes the declared type an actual constraint, and it only exists as a CREATE TABLE option — it cannot be added later without a rebuild.",
|
|
342
|
+
example: "CREATE TABLE orders (id INTEGER PRIMARY KEY, qty INTEGER, note TEXT);",
|
|
343
|
+
fix: {
|
|
344
|
+
description: "Add STRICT to every new table. Column types are then limited to INT, INTEGER, REAL, TEXT, BLOB and ANY, and a value of the wrong type is rejected at insert time instead of surfacing later.",
|
|
345
|
+
example: "CREATE TABLE orders (id INTEGER PRIMARY KEY, qty INTEGER, note TEXT) STRICT;",
|
|
346
|
+
},
|
|
347
|
+
verified: true, // SQLite docs, STRICT Tables: "SQLite strives to be flexible regarding the datatype of the content ... The STRICT keyword ... enforces" the declared type. Re-proven in test/claims.test.ts.
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
export const sl021Meta: RuleMeta = {
|
|
351
|
+
id: "SL021",
|
|
352
|
+
version: "1.0.0",
|
|
353
|
+
name: "Foreign key column without an index",
|
|
354
|
+
tier: 1,
|
|
355
|
+
defaultSeverity: "note",
|
|
356
|
+
rationale:
|
|
357
|
+
"SQLite indexes the parent side of a foreign key (the referenced key must be unique) but never the child side. Every DELETE or UPDATE of a parent row then scans the whole child table to look for references — with foreign keys on, deleting one user scans every order. The manual's advice is an index on every child key column.",
|
|
358
|
+
example: "CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES users(id));",
|
|
359
|
+
fix: {
|
|
360
|
+
description: "Create an index on the referencing column (or columns, in the foreign key's order) in the same migration.",
|
|
361
|
+
example: "CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES users(id));\nCREATE INDEX orders_user_id ON orders (user_id);",
|
|
362
|
+
},
|
|
363
|
+
verified: true, // SQLite docs, Foreign Key Support §3: "the child table ... should also have an index ... Usually, the parent key of a foreign key constraint is the primary key ... an index on the child key columns" avoids a full scan on parent DELETE/UPDATE. Re-proven with EXPLAIN QUERY PLAN in test/claims.test.ts.
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
export const sl022Meta: RuleMeta = {
|
|
367
|
+
id: "SL022",
|
|
368
|
+
version: "1.0.0",
|
|
369
|
+
name: "Rename with legacy_alter_table on",
|
|
370
|
+
tier: 1,
|
|
371
|
+
defaultSeverity: "warning",
|
|
372
|
+
rationale:
|
|
373
|
+
"With PRAGMA legacy_alter_table=ON, RENAME TABLE and RENAME COLUMN go back to the pre-3.26 behaviour: references inside views and triggers are not rewritten. The rename succeeds, and the first query through a view that named the old table fails with \"no such table\".",
|
|
374
|
+
example: "PRAGMA legacy_alter_table = ON;\nALTER TABLE orders RENAME TO purchases;",
|
|
375
|
+
fix: {
|
|
376
|
+
description: "Leave legacy_alter_table off (the default) so SQLite rewrites views and triggers with the rename. If the pragma is on for a specific rebuild step, switch it off before any rename.",
|
|
377
|
+
example: "PRAGMA legacy_alter_table = OFF;\nALTER TABLE orders RENAME TO purchases;",
|
|
378
|
+
},
|
|
379
|
+
verified: true, // SQLite docs, PRAGMA legacy_alter_table and ALTER TABLE RENAME: with the legacy behaviour "references to the table within trigger bodies and view definitions are not updated". Re-proven in test/claims.test.ts.
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
export const sl023Meta: RuleMeta = {
|
|
383
|
+
id: "SL023",
|
|
384
|
+
version: "1.0.0",
|
|
385
|
+
name: "Dropping what a view or trigger in this migration depends on",
|
|
386
|
+
tier: 1,
|
|
387
|
+
defaultSeverity: "critical",
|
|
388
|
+
rationale:
|
|
389
|
+
"This migration creates a view or trigger and then drops the table or column it depends on. SQLite does not stop it: the view fails with \"no such column\" the first time it is queried, and a trigger that names the column makes every later write to its table fail. Older releases refuse the DROP COLUMN instead. Either way the failure is guaranteed by the file itself, not by what production holds.",
|
|
390
|
+
example: "CREATE VIEW open_orders AS SELECT id, status FROM orders WHERE status = 'open';\nALTER TABLE orders DROP COLUMN status;",
|
|
391
|
+
fix: {
|
|
392
|
+
description: "Drop or redefine the view or trigger first, then drop the column or table, then recreate the view without the dependency.",
|
|
393
|
+
example: "DROP VIEW IF EXISTS open_orders;\nALTER TABLE orders DROP COLUMN status;\nCREATE VIEW open_orders AS SELECT id FROM orders;",
|
|
394
|
+
},
|
|
395
|
+
verified: true, // SQLite docs, ALTER TABLE DROP COLUMN: "only works if the column is not referenced by any other parts of the schema". Re-proven in test/claims.test.ts on 3.51: the drop goes through and the view / trigger break on first use.
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
export const sl024Meta: RuleMeta = {
|
|
399
|
+
id: "SL024",
|
|
400
|
+
version: "1.0.0",
|
|
401
|
+
name: "PRIMARY KEY column that accepts NULL",
|
|
402
|
+
tier: 1,
|
|
403
|
+
defaultSeverity: "note",
|
|
404
|
+
rationale:
|
|
405
|
+
"In a rowid table, a PRIMARY KEY column that is not INTEGER does not imply NOT NULL — a long-standing SQLite bug kept for compatibility — so NULL keys are accepted and the uniqueness the key promised is gone (every NULL is distinct). In a WITHOUT ROWID table the same NULL is refused at insert time instead, so the schema behaves differently depending on one table option.",
|
|
406
|
+
example: "CREATE TABLE orders (ref TEXT PRIMARY KEY, qty INTEGER);",
|
|
407
|
+
fix: {
|
|
408
|
+
description: "Spell NOT NULL on every non-INTEGER primary key column so the key is a key.",
|
|
409
|
+
example: "CREATE TABLE orders (ref TEXT PRIMARY KEY NOT NULL, qty INTEGER);",
|
|
410
|
+
},
|
|
411
|
+
verified: true, // SQLite docs, CREATE TABLE §PRIMARY KEY: "the value of the PRIMARY KEY column can be NULL ... due to a bug in some early versions" — unless the table is WITHOUT ROWID. Re-proven in test/claims.test.ts.
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
export const sl025Meta: RuleMeta = {
|
|
415
|
+
id: "SL025",
|
|
416
|
+
version: "1.0.0",
|
|
417
|
+
name: "Write migration in a DEFERRED transaction",
|
|
418
|
+
tier: 1,
|
|
419
|
+
defaultSeverity: "note",
|
|
420
|
+
rationale:
|
|
421
|
+
"A plain BEGIN is DEFERRED: it takes no lock until the first write, then tries to upgrade a read lock to a write lock. If another connection holds the write lock at that moment the upgrade fails immediately with SQLITE_BUSY — busy_timeout does not apply to a lock upgrade — and the migration aborts part way through its reads.",
|
|
422
|
+
example: "BEGIN;\nALTER TABLE orders ADD COLUMN region TEXT;\nCOMMIT;",
|
|
423
|
+
fix: {
|
|
424
|
+
description: "Start a migration that writes with BEGIN IMMEDIATE: the write lock is taken up front, busy_timeout applies, and there is no upgrade to fail.",
|
|
425
|
+
example: "BEGIN IMMEDIATE;\nALTER TABLE orders ADD COLUMN region TEXT;\nCOMMIT;",
|
|
426
|
+
},
|
|
427
|
+
verified: true, // SQLite docs, BEGIN TRANSACTION: "DEFERRED ... the transaction does not actually start until the database is first accessed"; a deferred transaction that needs to write after reading "may fail with SQLITE_BUSY" and the busy handler is not invoked for a lock upgrade that would deadlock.
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
export const sl026Meta: RuleMeta = {
|
|
431
|
+
id: "SL026",
|
|
432
|
+
version: "1.0.0",
|
|
433
|
+
name: "BEGIN inside a transaction, or COMMIT without one",
|
|
434
|
+
tier: 1,
|
|
435
|
+
defaultSeverity: "critical",
|
|
436
|
+
rationale:
|
|
437
|
+
"SQLite has no nested transactions: a second BEGIN fails with \"cannot start a transaction within a transaction\", and COMMIT or ROLLBACK with nothing open fails with \"no transaction is active\". Either error stops the migration where it happens — usually because a runner already wraps the file.",
|
|
438
|
+
example: "BEGIN;\nBEGIN;\nALTER TABLE orders ADD COLUMN region TEXT;\nCOMMIT;",
|
|
439
|
+
fix: {
|
|
440
|
+
description: "One BEGIN, one COMMIT, in order — or none at all if the migration runner manages the transaction. Use SAVEPOINT for a nested scope.",
|
|
441
|
+
example: "BEGIN;\nALTER TABLE orders ADD COLUMN region TEXT;\nCOMMIT;",
|
|
442
|
+
},
|
|
443
|
+
verified: true, // SQLite docs, BEGIN TRANSACTION: "Transactions ... cannot be nested" ; "attempting to start a new transaction while one is active is an error". Re-proven in test/claims.test.ts.
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
export const sl027Meta: RuleMeta = {
|
|
447
|
+
id: "SL027",
|
|
448
|
+
version: "1.0.0",
|
|
449
|
+
name: "Unbounded DELETE that cascades to child tables",
|
|
450
|
+
tier: 1,
|
|
451
|
+
defaultSeverity: "warning",
|
|
452
|
+
rationale:
|
|
453
|
+
"A DELETE with no WHERE on a table that other tables reference with ON DELETE CASCADE empties those tables too, in the same statement, with foreign keys on — and does nothing to them with foreign keys off. The blast radius depends on a per-connection pragma, and the statement itself names only one table.",
|
|
454
|
+
example: "CREATE TABLE lines (id INTEGER PRIMARY KEY, order_id INTEGER REFERENCES orders(id) ON DELETE CASCADE);\nDELETE FROM orders;",
|
|
455
|
+
fix: {
|
|
456
|
+
description: "State the predicate and batch the delete; if the intent is to clear the parent and its children, delete the children explicitly first so the migration reads the way it behaves.",
|
|
457
|
+
example: "-- batched job, children first, explicit:\n-- DELETE FROM lines WHERE order_id IN (SELECT id FROM orders WHERE status = 'closed' AND rowid BETWEEN ? AND ?);\n-- DELETE FROM orders WHERE status = 'closed' AND rowid BETWEEN ? AND ?;",
|
|
458
|
+
},
|
|
459
|
+
verified: true, // SQLite docs, Foreign Key Support §4.3: ON DELETE CASCADE "propagates the delete ... to each row in the child table"; enforcement depends on PRAGMA foreign_keys. Re-proven in test/claims.test.ts.
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
export const sl028Meta: RuleMeta = {
|
|
463
|
+
id: "SL028",
|
|
464
|
+
version: "1.0.0",
|
|
465
|
+
name: "Index on a non-deterministic expression",
|
|
466
|
+
tier: 1,
|
|
467
|
+
defaultSeverity: "critical",
|
|
468
|
+
rationale:
|
|
469
|
+
"An index expression must give the same answer for the same row every time, so SQLite refuses random(), changes(), last_insert_rowid() and their kin in CREATE INDEX: \"non-deterministic functions prohibited in index expressions\". The statement fails and the migration stops.",
|
|
470
|
+
example: "CREATE INDEX orders_shuffle ON orders (id + random());",
|
|
471
|
+
fix: {
|
|
472
|
+
description: "Index a deterministic expression — lower(), a column arithmetic, a JSON path — or store the value in a column and index that.",
|
|
473
|
+
example: "CREATE INDEX orders_email_ci ON orders (lower(email));",
|
|
474
|
+
},
|
|
475
|
+
verified: true, // SQLite docs, Indexes On Expressions: "expressions ... may only use deterministic functions". Re-proven in test/claims.test.ts.
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
export const sl029Meta: RuleMeta = {
|
|
479
|
+
id: "SL029",
|
|
480
|
+
version: "1.0.0",
|
|
481
|
+
name: "AUTOINCREMENT on a WITHOUT ROWID table",
|
|
482
|
+
tier: 1,
|
|
483
|
+
defaultSeverity: "critical",
|
|
484
|
+
rationale:
|
|
485
|
+
"AUTOINCREMENT only exists for the rowid, and a WITHOUT ROWID table has none: SQLite refuses the CREATE TABLE with \"AUTOINCREMENT not allowed on WITHOUT ROWID tables\". The table is not created and the migration stops.",
|
|
486
|
+
example: "CREATE TABLE sessions (id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT) WITHOUT ROWID;",
|
|
487
|
+
fix: {
|
|
488
|
+
description: "Drop the keyword — or drop WITHOUT ROWID if a monotonically increasing integer key is the point of the table.",
|
|
489
|
+
example: "CREATE TABLE sessions (id INTEGER PRIMARY KEY, token TEXT);",
|
|
490
|
+
},
|
|
491
|
+
verified: true, // SQLite docs, WITHOUT ROWID tables: "AUTOINCREMENT does not work on WITHOUT ROWID tables"; the CREATE TABLE fails. Re-proven in test/claims.test.ts.
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
export const sl030Meta: RuleMeta = {
|
|
495
|
+
id: "SL030",
|
|
496
|
+
version: "1.0.0",
|
|
497
|
+
name: "ATTACH DATABASE in a migration",
|
|
498
|
+
tier: 1,
|
|
499
|
+
defaultSeverity: "warning",
|
|
500
|
+
rationale:
|
|
501
|
+
"ATTACH opens a second database file on the runner's connection, by a path that only makes sense on one machine, for the life of that connection. In a migration it is almost always an import script that got committed: it fails on any other host, and while it works it can read from or write to a file the migration never declared.",
|
|
502
|
+
example: "ATTACH DATABASE '/var/backups/legacy.db' AS legacy;\nINSERT INTO orders SELECT * FROM legacy.orders;",
|
|
503
|
+
fix: {
|
|
504
|
+
description: "Keep imports out of migrations: run them as an operational job with the file path as a parameter, and leave the migration to the schema.",
|
|
505
|
+
example: "-- operational import job, not a migration:\n-- sqlite3 app.db \"ATTACH '/path/legacy.db' AS legacy; INSERT INTO orders SELECT * FROM legacy.orders; DETACH legacy;\"",
|
|
506
|
+
},
|
|
507
|
+
verified: true, // SQLite docs, ATTACH DATABASE: the attachment is per connection and by file name; no other engine-level claim is made.
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
export const sl031Meta: RuleMeta = {
|
|
511
|
+
id: "SL031",
|
|
512
|
+
version: "1.0.0",
|
|
513
|
+
name: "Redundant index",
|
|
514
|
+
tier: 1,
|
|
515
|
+
defaultSeverity: "note",
|
|
516
|
+
rationale:
|
|
517
|
+
"An index whose columns are a leading prefix of another index on the same table, a duplicate of one, or an index on the INTEGER PRIMARY KEY (the rowid, which is the table's own B-tree) changes no query plan — SQLite already answers those lookups from the wider index or the table itself. It still costs a write on every insert, update and delete, and space in the file.",
|
|
518
|
+
example: "CREATE INDEX orders_user ON orders (user_id);\nCREATE INDEX orders_user_created ON orders (user_id, created_at);",
|
|
519
|
+
fix: {
|
|
520
|
+
description: "Keep the wider index and drop the prefix; never index the INTEGER PRIMARY KEY column or a column that is already UNIQUE.",
|
|
521
|
+
example: "CREATE INDEX orders_user_created ON orders (user_id, created_at);",
|
|
522
|
+
},
|
|
523
|
+
verified: true, // SQLite docs, Query Planning: a multi-column index serves any leading prefix of its columns; ROWIDs: INTEGER PRIMARY KEY is the rowid, the table B-tree's key. Re-proven with EXPLAIN QUERY PLAN in test/claims.test.ts.
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
export const sl032Meta: RuleMeta = {
|
|
527
|
+
id: "SL032",
|
|
528
|
+
version: "1.0.0",
|
|
529
|
+
name: "Indexes built before the bulk copy in a rebuild",
|
|
530
|
+
tier: 1,
|
|
531
|
+
defaultSeverity: "note",
|
|
532
|
+
rationale:
|
|
533
|
+
"In a table rebuild the copy is the expensive step. Creating the new table's indexes before INSERT ... SELECT makes SQLite maintain every index row by row during the copy — random B-tree inserts for each — instead of one sorted build per index afterwards. The manual's own recipe creates the indexes after the copy.",
|
|
534
|
+
example: "CREATE TABLE orders_new (id INTEGER PRIMARY KEY, user_id INTEGER, qty INTEGER) STRICT;\nCREATE INDEX orders_new_user ON orders_new (user_id);\nINSERT INTO orders_new SELECT id, user_id, qty FROM orders;",
|
|
535
|
+
fix: {
|
|
536
|
+
description: "Copy first, then create the indexes on the populated table.",
|
|
537
|
+
example: "CREATE TABLE orders_new (id INTEGER PRIMARY KEY, user_id INTEGER, qty INTEGER) STRICT;\nINSERT INTO orders_new SELECT id, user_id, qty FROM orders;\nCREATE INDEX orders_new_user ON orders_new (user_id);",
|
|
538
|
+
},
|
|
539
|
+
verified: true, // SQLite docs, Making Other Kinds Of Table Schema Changes: step 4 creates the new table, step 5 copies, step 6+ recreates indexes — the copy precedes index creation.
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
export const sl033Meta: RuleMeta = {
|
|
543
|
+
id: "SL033",
|
|
544
|
+
version: "1.0.0",
|
|
545
|
+
name: "Text or blob primary key on a rowid table",
|
|
546
|
+
tier: 1,
|
|
547
|
+
defaultSeverity: "note",
|
|
548
|
+
rationale:
|
|
549
|
+
"On a rowid table a TEXT or BLOB primary key is not the table's key: rows live in a B-tree keyed by the hidden rowid, and the primary key is a separate unique index. Every lookup by key walks the index and then the table, and every row is stored twice over (key in the index, key in the row). WITHOUT ROWID makes the primary key the table's own B-tree — the manual recommends it exactly for this shape.",
|
|
550
|
+
example: "CREATE TABLE sessions (token TEXT PRIMARY KEY NOT NULL, user_id INTEGER) STRICT;",
|
|
551
|
+
fix: {
|
|
552
|
+
description: "Declare the table WITHOUT ROWID when the primary key is not an integer and rows are small (under about a twentieth of a page). Keep a rowid table for wide rows or when code depends on rowid.",
|
|
553
|
+
example: "CREATE TABLE sessions (token TEXT PRIMARY KEY NOT NULL, user_id INTEGER) WITHOUT ROWID, STRICT;",
|
|
554
|
+
},
|
|
555
|
+
verified: true, // SQLite docs, The WITHOUT ROWID Optimization: "tables that have non-integer or composite PRIMARY KEYs and small rows" benefit; a rowid table's non-integer PRIMARY KEY is "just a UNIQUE index".
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
export const sl034Meta: RuleMeta = {
|
|
559
|
+
id: "SL034",
|
|
560
|
+
version: "1.0.0",
|
|
561
|
+
name: "Journal mode set to something other than WAL",
|
|
562
|
+
tier: 1,
|
|
563
|
+
defaultSeverity: "note",
|
|
564
|
+
rationale:
|
|
565
|
+
"journal_mode is stored in the database file: a migration that sets DELETE, TRUNCATE or PERSIST changes every future connection, not just the runner's. In those modes a writer blocks readers and readers block the writer for the length of each transaction; WAL lets readers proceed during a write and is the mode the application almost certainly wants.",
|
|
566
|
+
example: "PRAGMA journal_mode = DELETE;",
|
|
567
|
+
fix: {
|
|
568
|
+
description: "Set WAL once, from the application's connection setup or a deliberate operational step — and if a migration must set it, set WAL.",
|
|
569
|
+
example: "PRAGMA journal_mode = WAL;",
|
|
570
|
+
},
|
|
571
|
+
verified: true, // SQLite docs, PRAGMA journal_mode: the mode is persistent for WAL and stored in the file; Write-Ahead Logging: "readers do not block writers and a writer does not block readers". Re-proven across two connections in test/claims.test.ts.
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
export const sl035Meta: RuleMeta = {
|
|
575
|
+
id: "SL035",
|
|
576
|
+
version: "1.0.0",
|
|
577
|
+
name: "Connection-scoped pragma in a migration",
|
|
578
|
+
tier: 1,
|
|
579
|
+
defaultSeverity: "note",
|
|
580
|
+
rationale:
|
|
581
|
+
"cache_size, synchronous, temp_store, mmap_size and busy_timeout live on the connection that set them. In a migration they tune the runner's connection for a few seconds and reach the application never — and synchronous=OFF removes durability for exactly the connection doing the schema change.",
|
|
582
|
+
example: "PRAGMA synchronous = OFF;\nPRAGMA mmap_size = 268435456;\nALTER TABLE orders ADD COLUMN region TEXT;",
|
|
583
|
+
fix: {
|
|
584
|
+
description: "Set connection pragmas where connections are opened, in the application, so every connection gets them. Leave migrations to the schema.",
|
|
585
|
+
example: "ALTER TABLE orders ADD COLUMN region TEXT;\n-- application connection setup: PRAGMA synchronous = NORMAL; PRAGMA mmap_size = 268435456;",
|
|
586
|
+
},
|
|
587
|
+
verified: true, // SQLite docs, PRAGMA synchronous / cache_size / temp_store / mmap_size / busy_timeout: each applies to the current connection only. Re-proven across two connections in test/claims.test.ts.
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
/** Every SL meta, in corpus order. */
|
|
591
|
+
export const sqliteMetas: readonly RuleMeta[] = [
|
|
592
|
+
sl001Meta, sl002Meta, sl003Meta, sl004Meta, sl005Meta, sl006Meta, sl007Meta, sl008Meta, sl009Meta,
|
|
593
|
+
sl010Meta, sl011Meta, sl012Meta, sl013Meta, sl014Meta, sl015Meta, sl016Meta, sl017Meta, sl018Meta, sl019Meta,
|
|
594
|
+
sl020Meta, sl021Meta, sl022Meta, sl023Meta, sl024Meta, sl025Meta, sl026Meta, sl027Meta, sl028Meta, sl029Meta, sl030Meta,
|
|
595
|
+
sl031Meta, sl032Meta, sl033Meta, sl034Meta, sl035Meta,
|
|
596
|
+
];
|
|
597
|
+
|
|
598
|
+
export const SQLITE_RULE_IDS: ReadonlySet<string> = new Set(sqliteMetas.map((meta) => meta.id));
|