@stacksjs/database 0.70.258 → 0.70.260

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.
Files changed (50) hide show
  1. package/dist/auth-tables.js +18 -137
  2. package/dist/column.js +1 -26
  3. package/dist/custom/audits.js +20 -54
  4. package/dist/custom/errors.js +16 -46
  5. package/dist/custom/index.js +1 -3
  6. package/dist/custom/jobs.js +13 -137
  7. package/dist/database.js +1 -181
  8. package/dist/datetime-columns.js +2 -79
  9. package/dist/ddl-constraints.js +7 -111
  10. package/dist/defaults.js +1 -48
  11. package/dist/dialect.js +1 -79
  12. package/dist/driver-config.js +1 -172
  13. package/dist/drivers/defaults/index.js +1 -1
  14. package/dist/drivers/defaults/traits.js +1 -29
  15. package/dist/drivers/dynamodb.js +1 -607
  16. package/dist/drivers/helpers.js +1 -206
  17. package/dist/drivers/index.js +1 -9
  18. package/dist/drivers/mysql.js +58 -299
  19. package/dist/drivers/postgres.js +78 -368
  20. package/dist/drivers/sqlite.js +61 -379
  21. package/dist/ensure-database.js +1 -145
  22. package/dist/fk-audit.js +3 -187
  23. package/dist/index.js +1 -64
  24. package/dist/managed-columns.js +1 -59
  25. package/dist/migration-dialect.js +4 -107
  26. package/dist/migration-ledger.js +1 -382
  27. package/dist/migration-lock.js +1 -143
  28. package/dist/migrations.js +15 -1118
  29. package/dist/model-sources.js +1 -76
  30. package/dist/notification-tables.js +4 -49
  31. package/dist/query-logger.js +2 -241
  32. package/dist/query-parser.js +1 -93
  33. package/dist/rbac-tables.js +6 -61
  34. package/dist/relation-columns.js +1 -66
  35. package/dist/replicas.js +1 -74
  36. package/dist/safe-migrations.js +2 -52
  37. package/dist/schema.js +1 -10
  38. package/dist/seeder.js +1 -457
  39. package/dist/sql-helpers.js +1 -50
  40. package/dist/table.js +1 -26
  41. package/dist/tools/setup.js +1 -6
  42. package/dist/trait-tables.js +8 -153
  43. package/dist/transaction-context.js +1 -62
  44. package/dist/types.js +1 -98
  45. package/dist/unique-audit.js +3 -155
  46. package/dist/utils.js +1 -285
  47. package/dist/uuid-columns.js +1 -68
  48. package/dist/validators.js +1 -122
  49. package/dist/vschema.js +2 -121
  50. package/package.json +20 -13
@@ -1,382 +1 @@
1
- import { existsSync, readdirSync, readFileSync } from "node:fs";
2
- import process from "node:process";
3
- import { join } from "node:path";
4
- const SAFE_MIGRATION_FILE = /^[\w.-]+\.sql$/, IDENT = String.raw`["\`\[]?([A-Za-z_]\w*)["\`\]]?`;
5
- export function stripForEffects(sql) {
6
- let out = "", i = 0;
7
- const blank = (text) => text.replace(/[^\n]/g, " ");
8
- while (i < sql.length) {
9
- const rest = sql.slice(i), line = rest.match(/^--[^\n]*/);
10
- if (line) {
11
- out += blank(line[0]);
12
- i += line[0].length;
13
- continue;
14
- }
15
- if (rest.startsWith("/*")) {
16
- const end = rest.indexOf("*/"), chunk = end === -1 ? rest : rest.slice(0, end + 2);
17
- out += blank(chunk);
18
- i += chunk.length;
19
- continue;
20
- }
21
- if (rest[0] === "'") {
22
- let j = 1;
23
- while (j < rest.length && rest[j] !== "'")
24
- j++;
25
- const chunk = rest.slice(0, Math.min(j + 1, rest.length));
26
- out += blank(chunk);
27
- i += chunk.length;
28
- continue;
29
- }
30
- out += sql[i];
31
- i += 1;
32
- }
33
- return out;
34
- }
35
- function statementsOf(sql) {
36
- return stripForEffects(sql).split(";").map((s) => s.trim()).filter((s) => s.length > 0);
37
- }
38
- export function logicalName(file) {
39
- return file.replace(/^\d+[-_]/, "").replace(/\.sql$/i, "");
40
- }
41
- export function migrationEffects(sql) {
42
- const effects = [], seen = new Set, renamedAway = new Set, push = (effect) => {
43
- const key = effectKey(effect);
44
- if (seen.has(key))
45
- return;
46
- seen.add(key);
47
- effects.push(effect);
48
- };
49
- for (const statement of statementsOf(sql)) {
50
- const create = new RegExp(String.raw`^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`, "i").exec(statement);
51
- if (create?.[1]) {
52
- push({ kind: "table", name: create[1] });
53
- continue;
54
- }
55
- const rename = new RegExp(String.raw`^ALTER\s+TABLE\s+${IDENT}\s+RENAME\s+TO\s+${IDENT}`, "i").exec(statement);
56
- if (rename?.[2]) {
57
- if (rename[1])
58
- renamedAway.add(rename[1].toLowerCase());
59
- push({ kind: "table", name: rename[2] });
60
- continue;
61
- }
62
- const index = new RegExp(String.raw`^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`, "i").exec(statement);
63
- if (index?.[1]) {
64
- push({ kind: "index", name: index[1] });
65
- continue;
66
- }
67
- const enumType = new RegExp(String.raw`^CREATE\s+TYPE\s+${IDENT}\s+AS\s+ENUM`, "i").exec(statement);
68
- if (enumType?.[1]) {
69
- push({ kind: "enum", name: enumType[1] });
70
- continue;
71
- }
72
- const alter = new RegExp(String.raw`^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?${IDENT}\s+(.*)$`, "is").exec(statement);
73
- if (!alter?.[1] || !alter[2])
74
- continue;
75
- const table = alter[1], addColumn = new RegExp(String.raw`\bADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`, "gi");
76
- for (const m of alter[2].matchAll(addColumn))
77
- if (m[1])
78
- push({ kind: "column", table, name: m[1] });
79
- const addConstraint = new RegExp(String.raw`\bADD\s+CONSTRAINT\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`, "gi");
80
- for (const m of alter[2].matchAll(addConstraint))
81
- if (m[1])
82
- push({ kind: "constraint", table, name: m[1] });
83
- const addBare = new RegExp(String.raw`\bADD\s+(?!COLUMN\b|CONSTRAINT\b|INDEX\b|KEY\b|PRIMARY\b|UNIQUE\b|FOREIGN\b|FULLTEXT\b|SPATIAL\b|CHECK\b)${IDENT}\s+\w`, "gi");
84
- for (const m of alter[2].matchAll(addBare))
85
- if (m[1])
86
- push({ kind: "column", table, name: m[1] });
87
- }
88
- if (renamedAway.size === 0)
89
- return effects;
90
- return effects.filter((effect) => {
91
- const owner = (effect.kind === "table" ? effect.name : effect.table ?? "").toLowerCase();
92
- return !renamedAway.has(owner);
93
- });
94
- }
95
- function effectKey(effect) {
96
- return `${effect.kind}:${(effect.table ?? "").toLowerCase()}.${effect.name.toLowerCase()}`;
97
- }
98
- export function verifiableEffects(effects, dialect) {
99
- if (dialect === "postgres")
100
- return effects;
101
- if (dialect === "mysql")
102
- return effects.filter((e) => e.kind !== "enum");
103
- return effects.filter((e) => e.kind !== "constraint" && e.kind !== "enum");
104
- }
105
- function emptySchema() {
106
- return { tables: new Set, columns: new Map, indexes: new Set, constraints: new Set, enums: new Set };
107
- }
108
- function rowsOf(result) {
109
- return Array.isArray(result) ? result : [];
110
- }
111
- async function defaultRunner() {
112
- const { db } = await import("./utils");
113
- return async (sql) => rowsOf(await db.unsafe(sql).execute());
114
- }
115
- function pick(row, ...keys) {
116
- for (const key of keys) {
117
- const value = row?.[key] ?? row?.[key.toLowerCase()] ?? row?.[key.toUpperCase()];
118
- if (typeof value === "string" && value.length > 0)
119
- return value;
120
- }
121
- return "";
122
- }
123
- export async function readLiveSchema(dialect, runner) {
124
- const schema = emptySchema(), run = runner ?? await defaultRunner();
125
- if (dialect === "sqlite") {
126
- for (const row of await run("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")) {
127
- const name = pick(row, "name");
128
- if (name)
129
- schema.tables.add(name.toLowerCase());
130
- }
131
- for (const row of await run("SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'")) {
132
- const name = pick(row, "name");
133
- if (name)
134
- schema.indexes.add(name.toLowerCase());
135
- }
136
- for (const table of schema.tables) {
137
- if (!/^[a-z_]\w*$/i.test(table))
138
- continue;
139
- const cols = new Set;
140
- for (const row of await run(`PRAGMA table_info("${table}")`)) {
141
- const name = pick(row, "name");
142
- if (name)
143
- cols.add(name.toLowerCase());
144
- }
145
- schema.columns.set(table, cols);
146
- }
147
- return schema;
148
- }
149
- if (dialect === "mysql") {
150
- for (const row of await run("SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()")) {
151
- const name = pick(row, "name", "TABLE_NAME");
152
- if (name)
153
- schema.tables.add(name.toLowerCase());
154
- }
155
- for (const row of await run("SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()")) {
156
- const table = pick(row, "TABLE_NAME").toLowerCase(), column = pick(row, "COLUMN_NAME").toLowerCase();
157
- if (!table || !column)
158
- continue;
159
- if (!schema.columns.has(table))
160
- schema.columns.set(table, new Set);
161
- schema.columns.get(table).add(column);
162
- }
163
- for (const row of await run("SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE()")) {
164
- const name = pick(row, "name", "INDEX_NAME");
165
- if (name)
166
- schema.indexes.add(name.toLowerCase());
167
- }
168
- for (const row of await run("SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE()")) {
169
- const name = pick(row, "name", "CONSTRAINT_NAME");
170
- if (name)
171
- schema.constraints.add(name.toLowerCase());
172
- }
173
- return schema;
174
- }
175
- for (const row of await run("SELECT tablename AS name FROM pg_tables WHERE schemaname = 'public'")) {
176
- const name = pick(row, "name", "tablename");
177
- if (name)
178
- schema.tables.add(name.toLowerCase());
179
- }
180
- for (const row of await run("SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public'")) {
181
- const table = pick(row, "table_name").toLowerCase(), column = pick(row, "column_name").toLowerCase();
182
- if (!table || !column)
183
- continue;
184
- if (!schema.columns.has(table))
185
- schema.columns.set(table, new Set);
186
- schema.columns.get(table).add(column);
187
- }
188
- for (const row of await run("SELECT indexname AS name FROM pg_indexes WHERE schemaname = 'public'")) {
189
- const name = pick(row, "name", "indexname");
190
- if (name)
191
- schema.indexes.add(name.toLowerCase());
192
- }
193
- for (const row of await run("SELECT c.conname AS name FROM pg_constraint c JOIN pg_namespace n ON n.oid = c.connamespace WHERE n.nspname = 'public'")) {
194
- const name = pick(row, "name", "conname");
195
- if (name)
196
- schema.constraints.add(name.toLowerCase());
197
- }
198
- for (const row of await run("SELECT t.typname AS name FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE t.typtype = 'e' AND n.nspname = 'public'")) {
199
- const name = pick(row, "name", "typname");
200
- if (name)
201
- schema.enums.add(name.toLowerCase());
202
- }
203
- return schema;
204
- }
205
- export function effectPresent(effect, schema) {
206
- const name = effect.name.toLowerCase();
207
- switch (effect.kind) {
208
- case "table":
209
- return schema.tables.has(name);
210
- case "column":
211
- return schema.columns.get((effect.table ?? "").toLowerCase())?.has(name) ?? !1;
212
- case "index":
213
- return schema.indexes.has(name);
214
- case "constraint":
215
- return schema.constraints.has(name);
216
- case "enum":
217
- return schema.enums.has(name);
218
- }
219
- }
220
- export function classifyMigration(recorded, present, absent) {
221
- const verifiable = present.length + absent.length;
222
- if (recorded) {
223
- if (verifiable === 0 || absent.length === 0)
224
- return "applied";
225
- return "reverted";
226
- }
227
- if (verifiable === 0)
228
- return "unverifiable";
229
- if (absent.length === 0)
230
- return "stranded";
231
- if (present.length === 0)
232
- return "pending";
233
- return "partial";
234
- }
235
- function migrationsDir(dir) {
236
- return dir ?? join(process.cwd(), "database", "migrations");
237
- }
238
- function listMigrationFiles(dir) {
239
- if (!existsSync(dir))
240
- return [];
241
- try {
242
- return readdirSync(dir).filter((f) => f.toLowerCase().endsWith(".sql")).sort();
243
- } catch {
244
- return [];
245
- }
246
- }
247
- async function currentDialect() {
248
- const driver = ((await import("@stacksjs/env")).env?.DB_CONNECTION ?? "sqlite").toLowerCase();
249
- if (driver === "sqlite" || driver === "mysql" || driver === "postgres")
250
- return driver;
251
- return "other";
252
- }
253
- export async function readLedger(runner) {
254
- try {
255
- return (await (runner ?? await defaultRunner())("SELECT migration FROM migrations")).map((row) => pick(row, "migration")).filter((name) => name.length > 0).sort();
256
- } catch {
257
- return [];
258
- }
259
- }
260
- export function planLedgerRemap(ledger, diskFiles) {
261
- const onDisk = new Set(diskFiles), byLogical = new Map;
262
- for (const file of diskFiles) {
263
- const key = logicalName(file);
264
- if (!byLogical.has(key))
265
- byLogical.set(key, []);
266
- byLogical.get(key).push(file);
267
- }
268
- const claimed = new Set(ledger.filter((row) => onDisk.has(row))), remap = [], ambiguous = [], dropped = [], targets = new Map;
269
- for (const row of ledger) {
270
- if (onDisk.has(row))
271
- continue;
272
- const candidates = (byLogical.get(logicalName(row)) ?? []).filter((f) => !claimed.has(f));
273
- if (candidates.length === 0) {
274
- dropped.push(row);
275
- continue;
276
- }
277
- if (candidates.length > 1) {
278
- ambiguous.push(row);
279
- continue;
280
- }
281
- const to = candidates[0];
282
- if (!targets.has(to))
283
- targets.set(to, []);
284
- targets.get(to).push(row);
285
- remap.push({ from: row, to });
286
- }
287
- const contested = new Set([...targets.entries()].filter(([, rows]) => rows.length > 1).flatMap(([, rows]) => rows));
288
- if (contested.size === 0)
289
- return { remap, ambiguous, dropped };
290
- return {
291
- remap: remap.filter((r) => !contested.has(r.from)),
292
- ambiguous: [...ambiguous, ...contested].sort(),
293
- dropped
294
- };
295
- }
296
- export async function auditMigrationLedger(options = {}) {
297
- const dir = migrationsDir(options.dir), dialect = options.dialect ?? await currentDialect(), files = listMigrationFiles(dir), counts = {
298
- applied: 0,
299
- stranded: 0,
300
- pending: 0,
301
- partial: 0,
302
- unverifiable: 0,
303
- reverted: 0
304
- }, emptyPlan = { remap: [], ambiguous: [], dropped: [] };
305
- if (dialect === "other")
306
- return { supported: !1, dialect, dir, entries: [], orphans: [], counts, recordedCount: 0, remapPlan: emptyPlan, drift: !1 };
307
- const run = options.run ?? await defaultRunner(), ledger = await readLedger(run), recorded = new Set(ledger), schema = await readLiveSchema(dialect, run), entries = [];
308
- for (const file of files) {
309
- let sql = "";
310
- try {
311
- sql = readFileSync(join(dir, file), "utf8");
312
- } catch {
313
- continue;
314
- }
315
- const effects = verifiableEffects(migrationEffects(sql), dialect), present = effects.filter((effect) => effectPresent(effect, schema)), absent = effects.filter((effect) => !effectPresent(effect, schema)), isRecorded = recorded.has(file), status = classifyMigration(isRecorded, present, absent);
316
- counts[status] += 1;
317
- entries.push({ file, logical: logicalName(file), recorded: isRecorded, status, effects, present, absent });
318
- }
319
- const readable = entries.map((entry) => entry.file), remapPlan = planLedgerRemap(ledger, readable), renamedTo = new Map(remapPlan.remap.map((r) => [r.from, r.to])), orphans = ledger.filter((row) => !readable.includes(row)).map((row) => ({ migration: row, renamedTo: renamedTo.get(row) })), drift = counts.stranded > 0 || counts.partial > 0 || counts.reverted > 0 || orphans.length > 0;
320
- return { supported: !0, dialect, dir, entries, orphans, counts, recordedCount: ledger.length, remapPlan, drift };
321
- }
322
- async function ensureLedgerTable(dialect, run) {
323
- await run(`CREATE TABLE IF NOT EXISTS migrations (${dialect === "postgres" ? "id SERIAL PRIMARY KEY" : dialect === "mysql" ? "id INT AUTO_INCREMENT PRIMARY KEY" : "id INTEGER PRIMARY KEY AUTOINCREMENT"}, migration VARCHAR(255) NOT NULL UNIQUE, executed_at ${dialect === "postgres" ? "TIMESTAMP" : "DATETIME"} DEFAULT CURRENT_TIMESTAMP)`);
324
- }
325
- export async function reconcileMigrationLedger(options = {}) {
326
- const run = options.run ?? await defaultRunner(), audit = await auditMigrationLedger({ dir: options.dir, dialect: options.dialect, run }), result = { remapped: [], recorded: [], skipped: [] };
327
- if (!audit.supported) {
328
- result.skipped.push({ file: "*", reason: `dialect "${audit.dialect}" is not audited` });
329
- return result;
330
- }
331
- const plan = audit.remapPlan;
332
- for (const row of plan.ambiguous)
333
- result.skipped.push({ file: row, reason: "ledger row matches more than one file by logical name" });
334
- for (const row of plan.dropped)
335
- result.skipped.push({ file: row, reason: "recorded migration no longer exists on disk" });
336
- const toRecord = [];
337
- for (const entry of audit.entries) {
338
- if (entry.status === "stranded") {
339
- toRecord.push(entry.file);
340
- continue;
341
- }
342
- if (entry.status === "partial") {
343
- if (options.includePartial) {
344
- toRecord.push(entry.file);
345
- continue;
346
- }
347
- result.skipped.push({
348
- file: entry.file,
349
- reason: `${entry.present.length}/${entry.effects.length} effects present \u2014 resolve by hand, or pass --include-partial`
350
- });
351
- continue;
352
- }
353
- if (entry.status === "reverted")
354
- result.skipped.push({
355
- file: entry.file,
356
- reason: `recorded, but ${entry.absent.length} effect(s) are missing from the schema`
357
- });
358
- }
359
- const unsafe = (file) => !SAFE_MIGRATION_FILE.test(file);
360
- for (const { from, to } of plan.remap.filter((r) => unsafe(r.from) || unsafe(r.to)))
361
- result.skipped.push({ file: unsafe(from) ? from : to, reason: "migration filename is not safe to write to the ledger" });
362
- for (const file of toRecord.filter(unsafe))
363
- result.skipped.push({ file, reason: "migration filename is not safe to write to the ledger" });
364
- const remapped = plan.remap.filter((r) => !unsafe(r.from) && !unsafe(r.to)), recordable = toRecord.filter((file) => !unsafe(file) && !remapped.some((r) => r.to === file));
365
- if (options.dryRun) {
366
- result.remapped = remapped;
367
- result.recorded = recordable;
368
- return result;
369
- }
370
- await ensureLedgerTable(audit.dialect, run);
371
- for (const { from, to } of remapped) {
372
- await run(`UPDATE migrations SET migration = '${to}' WHERE migration = '${from}'`);
373
- result.remapped.push({ from, to });
374
- }
375
- for (const file of recordable) {
376
- if ((await run(`SELECT migration FROM migrations WHERE migration = '${file}'`)).length > 0)
377
- continue;
378
- await run(`INSERT INTO migrations (migration) VALUES ('${file}')`);
379
- result.recorded.push(file);
380
- }
381
- return result;
382
- }
1
+ import{existsSync,readdirSync,readFileSync}from"node:fs";import process from"node:process";import{join}from"node:path";const SAFE_MIGRATION_FILE=/^[\w.-]+\.sql$/,IDENT=String.raw`["\`\[]?([A-Za-z_]\w*)["\`\]]?`;export function stripForEffects(sql){let out="",i=0;const blank=(text)=>text.replace(/[^\n]/g," ");while(i<sql.length){const rest=sql.slice(i),line=rest.match(/^--[^\n]*/);if(line){out+=blank(line[0]);i+=line[0].length;continue}if(rest.startsWith("/*")){const end=rest.indexOf("*/"),chunk=end===-1?rest:rest.slice(0,end+2);out+=blank(chunk);i+=chunk.length;continue}if(rest[0]==="'"){let j=1;while(j<rest.length&&rest[j]!=="'")j++;const chunk=rest.slice(0,Math.min(j+1,rest.length));out+=blank(chunk);i+=chunk.length;continue}out+=sql[i];i+=1}return out}function statementsOf(sql){return stripForEffects(sql).split(";").map((s)=>s.trim()).filter((s)=>s.length>0)}export function logicalName(file){return file.replace(/^\d+[-_]/,"").replace(/\.sql$/i,"")}export function migrationEffects(sql){const effects=[],seen=new Set,renamedAway=new Set,push=(effect)=>{const key=effectKey(effect);if(seen.has(key))return;seen.add(key);effects.push(effect)};for(const statement of statementsOf(sql)){const create=new RegExp(String.raw`^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"i").exec(statement);if(create?.[1]){push({kind:"table",name:create[1]});continue}const rename=new RegExp(String.raw`^ALTER\s+TABLE\s+${IDENT}\s+RENAME\s+TO\s+${IDENT}`,"i").exec(statement);if(rename?.[2]){if(rename[1])renamedAway.add(rename[1].toLowerCase());push({kind:"table",name:rename[2]});continue}const index=new RegExp(String.raw`^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"i").exec(statement);if(index?.[1]){push({kind:"index",name:index[1]});continue}const enumType=new RegExp(String.raw`^CREATE\s+TYPE\s+${IDENT}\s+AS\s+ENUM`,"i").exec(statement);if(enumType?.[1]){push({kind:"enum",name:enumType[1]});continue}const alter=new RegExp(String.raw`^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?${IDENT}\s+(.*)$`,"is").exec(statement);if(!alter?.[1]||!alter[2])continue;const table=alter[1],addColumn=new RegExp(String.raw`\bADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"gi");for(const m of alter[2].matchAll(addColumn))if(m[1])push({kind:"column",table,name:m[1]});const addConstraint=new RegExp(String.raw`\bADD\s+CONSTRAINT\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"gi");for(const m of alter[2].matchAll(addConstraint))if(m[1])push({kind:"constraint",table,name:m[1]});const addBare=new RegExp(String.raw`\bADD\s+(?!COLUMN\b|CONSTRAINT\b|INDEX\b|KEY\b|PRIMARY\b|UNIQUE\b|FOREIGN\b|FULLTEXT\b|SPATIAL\b|CHECK\b)${IDENT}\s+\w`,"gi");for(const m of alter[2].matchAll(addBare))if(m[1])push({kind:"column",table,name:m[1]})}if(renamedAway.size===0)return effects;return effects.filter((effect)=>{const owner=(effect.kind==="table"?effect.name:effect.table??"").toLowerCase();return!renamedAway.has(owner)})}function effectKey(effect){return`${effect.kind}:${(effect.table??"").toLowerCase()}.${effect.name.toLowerCase()}`}export function verifiableEffects(effects,dialect){if(dialect==="postgres")return effects;if(dialect==="mysql")return effects.filter((e)=>e.kind!=="enum");return effects.filter((e)=>e.kind!=="constraint"&&e.kind!=="enum")}function emptySchema(){return{tables:new Set,columns:new Map,indexes:new Set,constraints:new Set,enums:new Set}}function rowsOf(result){return Array.isArray(result)?result:[]}async function defaultRunner(){const{db}=await import("./utils");return async(sql)=>rowsOf(await db.unsafe(sql).execute())}function pick(row,...keys){for(const key of keys){const value=row?.[key]??row?.[key.toLowerCase()]??row?.[key.toUpperCase()];if(typeof value==="string"&&value.length>0)return value}return""}export async function readLiveSchema(dialect,runner){const schema=emptySchema(),run=runner??await defaultRunner();if(dialect==="sqlite"){for(const row of await run("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")){const name=pick(row,"name");if(name)schema.tables.add(name.toLowerCase())}for(const row of await run("SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'")){const name=pick(row,"name");if(name)schema.indexes.add(name.toLowerCase())}for(const table of schema.tables){if(!/^[a-z_]\w*$/i.test(table))continue;const cols=new Set;for(const row of await run(`PRAGMA table_info("${table}")`)){const name=pick(row,"name");if(name)cols.add(name.toLowerCase())}schema.columns.set(table,cols)}return schema}if(dialect==="mysql"){for(const row of await run("SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()")){const name=pick(row,"name","TABLE_NAME");if(name)schema.tables.add(name.toLowerCase())}for(const row of await run("SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()")){const table=pick(row,"TABLE_NAME").toLowerCase(),column=pick(row,"COLUMN_NAME").toLowerCase();if(!table||!column)continue;if(!schema.columns.has(table))schema.columns.set(table,new Set);schema.columns.get(table).add(column)}for(const row of await run("SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE()")){const name=pick(row,"name","INDEX_NAME");if(name)schema.indexes.add(name.toLowerCase())}for(const row of await run("SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE()")){const name=pick(row,"name","CONSTRAINT_NAME");if(name)schema.constraints.add(name.toLowerCase())}return schema}for(const row of await run("SELECT tablename AS name FROM pg_tables WHERE schemaname = 'public'")){const name=pick(row,"name","tablename");if(name)schema.tables.add(name.toLowerCase())}for(const row of await run("SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public'")){const table=pick(row,"table_name").toLowerCase(),column=pick(row,"column_name").toLowerCase();if(!table||!column)continue;if(!schema.columns.has(table))schema.columns.set(table,new Set);schema.columns.get(table).add(column)}for(const row of await run("SELECT indexname AS name FROM pg_indexes WHERE schemaname = 'public'")){const name=pick(row,"name","indexname");if(name)schema.indexes.add(name.toLowerCase())}for(const row of await run("SELECT c.conname AS name FROM pg_constraint c JOIN pg_namespace n ON n.oid = c.connamespace WHERE n.nspname = 'public'")){const name=pick(row,"name","conname");if(name)schema.constraints.add(name.toLowerCase())}for(const row of await run("SELECT t.typname AS name FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE t.typtype = 'e' AND n.nspname = 'public'")){const name=pick(row,"name","typname");if(name)schema.enums.add(name.toLowerCase())}return schema}export function effectPresent(effect,schema){const name=effect.name.toLowerCase();switch(effect.kind){case"table":return schema.tables.has(name);case"column":return schema.columns.get((effect.table??"").toLowerCase())?.has(name)??!1;case"index":return schema.indexes.has(name);case"constraint":return schema.constraints.has(name);case"enum":return schema.enums.has(name)}}export function classifyMigration(recorded,present,absent){const verifiable=present.length+absent.length;if(recorded){if(verifiable===0||absent.length===0)return"applied";return"reverted"}if(verifiable===0)return"unverifiable";if(absent.length===0)return"stranded";if(present.length===0)return"pending";return"partial"}function migrationsDir(dir){return dir??join(process.cwd(),"database","migrations")}function listMigrationFiles(dir){if(!existsSync(dir))return[];try{return readdirSync(dir).filter((f)=>f.toLowerCase().endsWith(".sql")).sort()}catch{return[]}}async function currentDialect(){const driver=((await import("@stacksjs/env")).env?.DB_CONNECTION??"sqlite").toLowerCase();if(driver==="sqlite"||driver==="mysql"||driver==="postgres")return driver;return"other"}export async function readLedger(runner){try{return(await(runner??await defaultRunner())("SELECT migration FROM migrations")).map((row)=>pick(row,"migration")).filter((name)=>name.length>0).sort()}catch{return[]}}export function planLedgerRemap(ledger,diskFiles){const onDisk=new Set(diskFiles),byLogical=new Map;for(const file of diskFiles){const key=logicalName(file);if(!byLogical.has(key))byLogical.set(key,[]);byLogical.get(key).push(file)}const claimed=new Set(ledger.filter((row)=>onDisk.has(row))),remap=[],ambiguous=[],dropped=[],targets=new Map;for(const row of ledger){if(onDisk.has(row))continue;const candidates=(byLogical.get(logicalName(row))??[]).filter((f)=>!claimed.has(f));if(candidates.length===0){dropped.push(row);continue}if(candidates.length>1){ambiguous.push(row);continue}const to=candidates[0];if(!targets.has(to))targets.set(to,[]);targets.get(to).push(row);remap.push({from:row,to})}const contested=new Set([...targets.entries()].filter(([,rows])=>rows.length>1).flatMap(([,rows])=>rows));if(contested.size===0)return{remap,ambiguous,dropped};return{remap:remap.filter((r)=>!contested.has(r.from)),ambiguous:[...ambiguous,...contested].sort(),dropped}}export async function auditMigrationLedger(options={}){const dir=migrationsDir(options.dir),dialect=options.dialect??await currentDialect(),files=listMigrationFiles(dir),counts={applied:0,stranded:0,pending:0,partial:0,unverifiable:0,reverted:0},emptyPlan={remap:[],ambiguous:[],dropped:[]};if(dialect==="other")return{supported:!1,dialect,dir,entries:[],orphans:[],counts,recordedCount:0,remapPlan:emptyPlan,drift:!1};const run=options.run??await defaultRunner(),ledger=await readLedger(run),recorded=new Set(ledger),schema=await readLiveSchema(dialect,run),entries=[];for(const file of files){let sql="";try{sql=readFileSync(join(dir,file),"utf8")}catch{continue}const effects=verifiableEffects(migrationEffects(sql),dialect),present=effects.filter((effect)=>effectPresent(effect,schema)),absent=effects.filter((effect)=>!effectPresent(effect,schema)),isRecorded=recorded.has(file),status=classifyMigration(isRecorded,present,absent);counts[status]+=1;entries.push({file,logical:logicalName(file),recorded:isRecorded,status,effects,present,absent})}const readable=entries.map((entry)=>entry.file),remapPlan=planLedgerRemap(ledger,readable),renamedTo=new Map(remapPlan.remap.map((r)=>[r.from,r.to])),orphans=ledger.filter((row)=>!readable.includes(row)).map((row)=>({migration:row,renamedTo:renamedTo.get(row)})),drift=counts.stranded>0||counts.partial>0||counts.reverted>0||orphans.length>0;return{supported:!0,dialect,dir,entries,orphans,counts,recordedCount:ledger.length,remapPlan,drift}}async function ensureLedgerTable(dialect,run){await run(`CREATE TABLE IF NOT EXISTS migrations (${dialect==="postgres"?"id SERIAL PRIMARY KEY":dialect==="mysql"?"id INT AUTO_INCREMENT PRIMARY KEY":"id INTEGER PRIMARY KEY AUTOINCREMENT"}, migration VARCHAR(255) NOT NULL UNIQUE, executed_at ${dialect==="postgres"?"TIMESTAMP":"DATETIME"} DEFAULT CURRENT_TIMESTAMP)`)}export async function reconcileMigrationLedger(options={}){const run=options.run??await defaultRunner(),audit=await auditMigrationLedger({dir:options.dir,dialect:options.dialect,run}),result={remapped:[],recorded:[],skipped:[]};if(!audit.supported){result.skipped.push({file:"*",reason:`dialect "${audit.dialect}" is not audited`});return result}const plan=audit.remapPlan;for(const row of plan.ambiguous)result.skipped.push({file:row,reason:"ledger row matches more than one file by logical name"});for(const row of plan.dropped)result.skipped.push({file:row,reason:"recorded migration no longer exists on disk"});const toRecord=[];for(const entry of audit.entries){if(entry.status==="stranded"){toRecord.push(entry.file);continue}if(entry.status==="partial"){if(options.includePartial){toRecord.push(entry.file);continue}result.skipped.push({file:entry.file,reason:`${entry.present.length}/${entry.effects.length} effects present \u2014 resolve by hand, or pass --include-partial`});continue}if(entry.status==="reverted")result.skipped.push({file:entry.file,reason:`recorded, but ${entry.absent.length} effect(s) are missing from the schema`})}const unsafe=(file)=>!SAFE_MIGRATION_FILE.test(file);for(const{from,to}of plan.remap.filter((r)=>unsafe(r.from)||unsafe(r.to)))result.skipped.push({file:unsafe(from)?from:to,reason:"migration filename is not safe to write to the ledger"});for(const file of toRecord.filter(unsafe))result.skipped.push({file,reason:"migration filename is not safe to write to the ledger"});const remapped=plan.remap.filter((r)=>!unsafe(r.from)&&!unsafe(r.to)),recordable=toRecord.filter((file)=>!unsafe(file)&&!remapped.some((r)=>r.to===file));if(options.dryRun){result.remapped=remapped;result.recorded=recordable;return result}await ensureLedgerTable(audit.dialect,run);for(const{from,to}of remapped){await run(`UPDATE migrations SET migration = '${to}' WHERE migration = '${from}'`);result.remapped.push({from,to})}for(const file of recordable){if((await run(`SELECT migration FROM migrations WHERE migration = '${file}'`)).length>0)continue;await run(`INSERT INTO migrations (migration) VALUES ('${file}')`);result.recorded.push(file)}return result}
@@ -1,143 +1 @@
1
- import { Buffer } from "node:buffer";
2
- import { createHash } from "node:crypto";
3
- import { closeSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
- import process from "node:process";
5
- import { userDatabasePath } from "@stacksjs/path";
6
- const DEFAULT_TIMEOUT_MS = 30000, INITIAL_BACKOFF_MS = 100, MAX_BACKOFF_MS = 2000, STALE_LOCK_MS = 60000, LOCK_NAME = "stacks_migrations";
7
- export async function acquireMigrationLock(dialect, adminDb, opts = {}) {
8
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
9
- if (dialect !== "sqlite" && dialect !== "postgres" && dialect !== "mysql")
10
- throw Error(`[migration-lock] unknown dialect: ${String(dialect)}`);
11
- if (dialect === "sqlite")
12
- return acquireSqliteLock(opts.sqliteLockPath, timeoutMs);
13
- if (!adminDb)
14
- throw Error(`[migration-lock] ${dialect} requires a database connection to acquire the lock`);
15
- if (dialect === "postgres")
16
- return acquirePostgresLock(adminDb, timeoutMs);
17
- return acquireMySqlLock(adminDb, timeoutMs);
18
- }
19
- function lockKeysForPostgres() {
20
- const hash = createHash("sha256").update(LOCK_NAME).digest(), key1 = hash.readInt32BE(0), key2 = hash.readInt32BE(4);
21
- return { key1, key2 };
22
- }
23
- async function acquirePostgresLock(adminDb, timeoutMs) {
24
- const { key1, key2 } = lockKeysForPostgres(), start = Date.now();
25
- let backoff = INITIAL_BACKOFF_MS;
26
- while (!0) {
27
- const result = await adminDb.unsafe(`SELECT pg_try_advisory_lock(${key1}, ${key2}) AS acquired`);
28
- if (extractFirstBool(result, "acquired"))
29
- return {
30
- release: async () => {
31
- try {
32
- await adminDb.unsafe(`SELECT pg_advisory_unlock(${key1}, ${key2})`);
33
- } catch {}
34
- }
35
- };
36
- if (Date.now() - start >= timeoutMs)
37
- throw Error("[migration-lock] another migration is in progress \u2014 could not acquire postgres advisory lock within timeout");
38
- await sleepWithJitter(backoff);
39
- backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
40
- }
41
- }
42
- async function acquireMySqlLock(adminDb, timeoutMs) {
43
- const start = Date.now();
44
- let backoff = INITIAL_BACKOFF_MS;
45
- while (!0) {
46
- const result = await adminDb.unsafe(`SELECT GET_LOCK('${LOCK_NAME}', 0) AS acquired`);
47
- if (extractFirstInt(result, "acquired") === 1)
48
- return {
49
- release: async () => {
50
- try {
51
- await adminDb.unsafe(`SELECT RELEASE_LOCK('${LOCK_NAME}')`);
52
- } catch {}
53
- }
54
- };
55
- if (Date.now() - start >= timeoutMs)
56
- throw Error("[migration-lock] another migration is in progress \u2014 could not acquire MySQL named lock within timeout");
57
- await sleepWithJitter(backoff);
58
- backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
59
- }
60
- }
61
- function defaultSqliteLockPath() {
62
- return userDatabasePath(".migration.lock");
63
- }
64
- async function acquireSqliteLock(lockPath, timeoutMs) {
65
- const path = lockPath ?? defaultSqliteLockPath(), start = Date.now();
66
- let backoff = INITIAL_BACKOFF_MS;
67
- while (!0) {
68
- if (tryCreateLockFile(path)) {
69
- let released = !1;
70
- return {
71
- release: async () => {
72
- if (released)
73
- return;
74
- released = !0;
75
- try {
76
- unlinkSync(path);
77
- } catch {}
78
- }
79
- };
80
- }
81
- reclaimIfStale(path);
82
- if (Date.now() - start >= timeoutMs)
83
- throw Error(`[migration-lock] another migration is in progress \u2014 lock file ${path} held within timeout`);
84
- await sleepWithJitter(backoff);
85
- backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
86
- }
87
- }
88
- function tryCreateLockFile(path) {
89
- try {
90
- const fd = openSync(path, "wx");
91
- try {
92
- const payload = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
93
- writeFileSync(fd, Buffer.from(payload, "utf8"));
94
- } finally {
95
- closeSync(fd);
96
- }
97
- return !0;
98
- } catch (e) {
99
- if (e.code === "EEXIST")
100
- return !1;
101
- throw e;
102
- }
103
- }
104
- function reclaimIfStale(path) {
105
- try {
106
- const st = statSync(path);
107
- if (Date.now() - st.mtimeMs > STALE_LOCK_MS)
108
- try {
109
- unlinkSync(path);
110
- } catch {}
111
- } catch {}
112
- }
113
- function sleepWithJitter(ms) {
114
- const jittered = ms * (1 + Math.random() * 0.25);
115
- return new Promise((resolve) => setTimeout(resolve, jittered));
116
- }
117
- function extractFirstBool(result, column) {
118
- const row = pluckFirstRow(result);
119
- if (!row)
120
- return !1;
121
- const value = row[column];
122
- return value === !0 || value === 1 || value === "1" || value === "t";
123
- }
124
- function extractFirstInt(result, column) {
125
- const row = pluckFirstRow(result);
126
- if (!row)
127
- return null;
128
- const value = row[column];
129
- if (typeof value === "number")
130
- return value;
131
- if (typeof value === "string" && /^-?\d+$/.test(value))
132
- return Number.parseInt(value, 10);
133
- return null;
134
- }
135
- function pluckFirstRow(result) {
136
- if (!result)
137
- return null;
138
- if (Array.isArray(result))
139
- return result[0];
140
- if (typeof result === "object" && "rows" in result && Array.isArray(result.rows))
141
- return result.rows[0];
142
- return null;
143
- }
1
+ import{Buffer}from"node:buffer";import{createHash}from"node:crypto";import{closeSync,openSync,readFileSync,statSync,unlinkSync,writeFileSync}from"node:fs";import process from"node:process";import{userDatabasePath}from"@stacksjs/path";const DEFAULT_TIMEOUT_MS=30000,INITIAL_BACKOFF_MS=100,MAX_BACKOFF_MS=2000,STALE_LOCK_MS=60000,LOCK_NAME="stacks_migrations";export async function acquireMigrationLock(dialect,adminDb,opts={}){const timeoutMs=opts.timeoutMs??DEFAULT_TIMEOUT_MS;if(dialect!=="sqlite"&&dialect!=="postgres"&&dialect!=="mysql")throw Error(`[migration-lock] unknown dialect: ${String(dialect)}`);if(dialect==="sqlite")return acquireSqliteLock(opts.sqliteLockPath,timeoutMs);if(!adminDb)throw Error(`[migration-lock] ${dialect} requires a database connection to acquire the lock`);if(dialect==="postgres")return acquirePostgresLock(adminDb,timeoutMs);return acquireMySqlLock(adminDb,timeoutMs)}function lockKeysForPostgres(){const hash=createHash("sha256").update(LOCK_NAME).digest(),key1=hash.readInt32BE(0),key2=hash.readInt32BE(4);return{key1,key2}}async function acquirePostgresLock(adminDb,timeoutMs){const{key1,key2}=lockKeysForPostgres(),start=Date.now();let backoff=INITIAL_BACKOFF_MS;while(!0){const result=await adminDb.unsafe(`SELECT pg_try_advisory_lock(${key1}, ${key2}) AS acquired`);if(extractFirstBool(result,"acquired"))return{release:async()=>{try{await adminDb.unsafe(`SELECT pg_advisory_unlock(${key1}, ${key2})`)}catch{}}};if(Date.now()-start>=timeoutMs)throw Error("[migration-lock] another migration is in progress \u2014 could not acquire postgres advisory lock within timeout");await sleepWithJitter(backoff);backoff=Math.min(backoff*2,MAX_BACKOFF_MS)}}async function acquireMySqlLock(adminDb,timeoutMs){const start=Date.now();let backoff=INITIAL_BACKOFF_MS;while(!0){const result=await adminDb.unsafe(`SELECT GET_LOCK('${LOCK_NAME}', 0) AS acquired`);if(extractFirstInt(result,"acquired")===1)return{release:async()=>{try{await adminDb.unsafe(`SELECT RELEASE_LOCK('${LOCK_NAME}')`)}catch{}}};if(Date.now()-start>=timeoutMs)throw Error("[migration-lock] another migration is in progress \u2014 could not acquire MySQL named lock within timeout");await sleepWithJitter(backoff);backoff=Math.min(backoff*2,MAX_BACKOFF_MS)}}function defaultSqliteLockPath(){return userDatabasePath(".migration.lock")}async function acquireSqliteLock(lockPath,timeoutMs){const path=lockPath??defaultSqliteLockPath(),start=Date.now();let backoff=INITIAL_BACKOFF_MS;while(!0){if(tryCreateLockFile(path)){let released=!1;return{release:async()=>{if(released)return;released=!0;try{unlinkSync(path)}catch{}}}}reclaimIfStale(path);if(Date.now()-start>=timeoutMs)throw Error(`[migration-lock] another migration is in progress \u2014 lock file ${path} held within timeout`);await sleepWithJitter(backoff);backoff=Math.min(backoff*2,MAX_BACKOFF_MS)}}function tryCreateLockFile(path){try{const fd=openSync(path,"wx");try{const payload=JSON.stringify({pid:process.pid,startedAt:new Date().toISOString()});writeFileSync(fd,Buffer.from(payload,"utf8"))}finally{closeSync(fd)}return!0}catch(e){if(e.code==="EEXIST")return!1;throw e}}function reclaimIfStale(path){try{const st=statSync(path);if(Date.now()-st.mtimeMs>STALE_LOCK_MS)try{unlinkSync(path)}catch{}}catch{}}function sleepWithJitter(ms){const jittered=ms*(1+Math.random()*0.25);return new Promise((resolve)=>setTimeout(resolve,jittered))}function extractFirstBool(result,column){const row=pluckFirstRow(result);if(!row)return!1;const value=row[column];return value===!0||value===1||value==="1"||value==="t"}function extractFirstInt(result,column){const row=pluckFirstRow(result);if(!row)return null;const value=row[column];if(typeof value==="number")return value;if(typeof value==="string"&&/^-?\d+$/.test(value))return Number.parseInt(value,10);return null}function pluckFirstRow(result){if(!result)return null;if(Array.isArray(result))return result[0];if(typeof result==="object"&&"rows"in result&&Array.isArray(result.rows))return result.rows[0];return null}