@stacksjs/database 0.70.229 → 0.70.231

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.
@@ -1,5 +1,5 @@
1
1
  var {require}=import.meta;import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
2
+ import { dirname, isAbsolute, join } from "node:path";
3
3
  import { log as _log } from "@stacksjs/logging";
4
4
  const log = {
5
5
  info: (...args) => typeof _log?.info === "function" ? _log.info(...args) : console.log(...args),
@@ -10,6 +10,7 @@ const log = {
10
10
  };
11
11
  import { err, handleError, ok } from "@stacksjs/error-handling";
12
12
  import { path } from "@stacksjs/path";
13
+ import { defaultModelsPath } from "./seeder";
13
14
  import {
14
15
  createQueryBuilder,
15
16
  executeMigration as qbExecuteMigration,
@@ -32,9 +33,18 @@ import {
32
33
  import { resolveModelSources } from "./model-sources";
33
34
  import { frameworkManagedColumns, withoutManagedColumnDrops, withoutManagedColumnDropSql } from "./managed-columns";
34
35
  import { acquireMigrationLock } from "./migration-lock";
36
+ import { migrateNotificationTables } from "./notification-tables";
35
37
  import { env as envVars } from "@stacksjs/env";
36
38
  import { getConnectionDefaults } from "./defaults";
37
- const dbDriver = envVars.DB_CONNECTION || "sqlite", sqliteDefaults = getConnectionDefaults("sqlite", envVars), mysqlDefaults = getConnectionDefaults("mysql", envVars), postgresDefaults = getConnectionDefaults("postgres", envVars), dbConfig = {
39
+ const databaseEnv = {
40
+ DB_CONNECTION: process.env.DB_CONNECTION || envVars.DB_CONNECTION,
41
+ DB_DATABASE_PATH: process.env.DB_DATABASE_PATH || envVars.DB_DATABASE_PATH,
42
+ DB_DATABASE: process.env.DB_DATABASE || envVars.DB_DATABASE,
43
+ DB_HOST: process.env.DB_HOST || envVars.DB_HOST,
44
+ DB_PORT: process.env.DB_PORT ? Number(process.env.DB_PORT) : envVars.DB_PORT,
45
+ DB_USERNAME: process.env.DB_USERNAME || envVars.DB_USERNAME,
46
+ DB_PASSWORD: process.env.DB_PASSWORD || envVars.DB_PASSWORD
47
+ }, dbDriver = databaseEnv.DB_CONNECTION || "sqlite", sqliteDefaults = getConnectionDefaults("sqlite", databaseEnv), mysqlDefaults = getConnectionDefaults("mysql", databaseEnv), postgresDefaults = getConnectionDefaults("postgres", databaseEnv), dbConfig = {
38
48
  default: dbDriver,
39
49
  connections: {
40
50
  sqlite: { database: sqliteDefaults.database, prefix: "" },
@@ -42,6 +52,10 @@ const dbDriver = envVars.DB_CONNECTION || "sqlite", sqliteDefaults = getConnecti
42
52
  postgres: { name: postgresDefaults.database, host: postgresDefaults.host, username: postgresDefaults.username, password: postgresDefaults.password, port: postgresDefaults.port, prefix: "" }
43
53
  }
44
54
  };
55
+ function sqliteDatabasePath() {
56
+ const configured = dbConfig.connections.sqlite.database || "stacks.db";
57
+ return isAbsolute(configured) ? configured : join(process.cwd(), configured);
58
+ }
45
59
  function getDriver() {
46
60
  return dbConfig.default || "sqlite";
47
61
  }
@@ -74,9 +88,17 @@ function configureQueryBuilder() {
74
88
  });
75
89
  resetConnection();
76
90
  }
77
- function prepareMigrationModelsDir() {
78
- const userModelsDir = path.userModelsPath();
79
- return { modelsDir: userModelsDir, skip: !existsSync(userModelsDir) };
91
+ export function prepareMigrationModelsDir() {
92
+ const sources = resolveModelSources();
93
+ return {
94
+ modelsDir: sources?.dir ?? path.userModelsPath(),
95
+ skip: !sources
96
+ };
97
+ }
98
+ export function sqlStatementsOf(content) {
99
+ return content.split(`
100
+ `).map((line) => line.replace(/--.*$/, "")).join(`
101
+ `).split(";").map((s) => s.trim()).filter((s) => s.length > 0);
80
102
  }
81
103
  export function preprocessSqliteMigrations() {
82
104
  const migrationsDir = join(process.cwd(), "database", "migrations");
@@ -95,7 +117,7 @@ export function preprocessSqliteMigrations() {
95
117
  unlinkSync(filePath);
96
118
  } catch {}
97
119
  droppedMigrations.push(file);
98
- }, replayMigrations = [], addConstraintPattern = /^\s*ALTER\s+TABLE\s+.+\s+ADD\s+CONSTRAINT\s+/i, createTypePattern = /^\s*CREATE\s+TYPE\s+/i, createUniqueIndexPattern = /^\s*CREATE\s+UNIQUE\s+INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i, dropColumnPattern = /^\s*ALTER\s+TABLE\s+["']?(\w+)["']?\s+DROP\s+COLUMN\s+["']?(\w+)["']?\s*$/i, createTablePattern = /^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(\w+)["']?/i, createTableEarliest = new Map;
120
+ }, replayMigrations = [], addConstraintPattern = /^\s*ALTER\s+TABLE\s+.+\s+ADD\s+CONSTRAINT\s+/i, createTypePattern = /^\s*CREATE\s+TYPE\s+/i, createUniqueIndexPattern = /^\s*CREATE\s+UNIQUE\s+INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i, dropColumnPattern = /^\s*ALTER\s+TABLE\s+["']?(\w+)["']?\s+DROP\s+COLUMN\s+["']?(\w+)["']?\s*$/i, addColumnPattern = /^\s*ALTER\s+TABLE\s+["']?(\w+)["']?\s+ADD\s+COLUMN\s+["'`]?(\w+)["'`]?/i, createTablePattern = /^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(\w+)["']?/i, createTableEarliest = new Map;
99
121
  for (const file of files) {
100
122
  const m = file.match(/^\d+-create-(\w+)-table\.sql$/);
101
123
  if (!m || !m[1])
@@ -104,18 +126,51 @@ export function preprocessSqliteMigrations() {
104
126
  if (!existing || file < existing)
105
127
  createTableEarliest.set(tableName, file);
106
128
  }
107
- const sqliteDbPath = join(process.cwd(), dbConfig.connections.sqlite.database || "stacks.db");
129
+ const earlierCreateDefinesColumn = (migrationFile, table, column) => {
130
+ const createFile = createTableEarliest.get(table);
131
+ if (!createFile || createFile >= migrationFile)
132
+ return !1;
133
+ try {
134
+ const createContent = readFileSync(join(migrationsDir, createFile), "utf8"), createStatement = sqlStatementsOf(createContent).find((statement) => statement.match(createTablePattern)?.[1] === table);
135
+ if (!createStatement)
136
+ return !1;
137
+ const escapedColumn = column.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
138
+ return new RegExp(`(?:^|[,(])\\s*["'\`]?${escapedColumn}["'\`]?\\s+`, "i").test(createStatement);
139
+ } catch {
140
+ return !1;
141
+ }
142
+ }, sqliteDbPath = sqliteDatabasePath();
108
143
  let sqliteDb = null;
109
144
  if (existsSync(sqliteDbPath))
110
145
  try {
111
146
  const { Database } = require("bun:sqlite");
112
147
  sqliteDb = new Database(sqliteDbPath, { readonly: !0 });
113
148
  } catch {}
149
+ const migrationWasRecorded = (file) => {
150
+ if (!sqliteDb)
151
+ return !1;
152
+ try {
153
+ return Boolean(sqliteDb.prepare("SELECT 1 FROM migrations WHERE migration = ? LIMIT 1").get(file));
154
+ } catch {
155
+ return !1;
156
+ }
157
+ };
114
158
  for (const file of files) {
115
159
  log.debug(`[migration] Running: ${file}`);
116
- const filePath = join(migrationsDir, file), statements = readFileSync(filePath, "utf-8").split(";").map((s) => s.trim()).filter((s) => s.length > 0 && !s.startsWith("--"));
160
+ const filePath = join(migrationsDir, file), content = readFileSync(filePath, "utf-8"), statements = sqlStatementsOf(content);
117
161
  if (statements.length === 0)
118
162
  continue;
163
+ const uniqueIndexNames = statements.map((s) => s.match(createUniqueIndexPattern)?.[1]).filter((name) => Boolean(name));
164
+ if (sqliteDb && uniqueIndexNames.length === statements.length) {
165
+ const indexExists = sqliteDb.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?");
166
+ if (uniqueIndexNames.filter((name) => !indexExists.get(name)).length > 0) {
167
+ log.info(`Re-queueing unique-index migration (index missing from database): ${file}`);
168
+ replayMigrations.push(file);
169
+ }
170
+ continue;
171
+ }
172
+ if (migrationWasRecorded(file))
173
+ continue;
119
174
  const firstStatement = statements[0], createTableMatch = firstStatement ? firstStatement.match(createTablePattern) : null;
120
175
  if (createTableMatch && createTableMatch[1]) {
121
176
  const tableName = createTableMatch[1], earliest = createTableEarliest.get(tableName);
@@ -132,14 +187,28 @@ export function preprocessSqliteMigrations() {
132
187
  skipMigration(file, "SQLite does not support CREATE TYPE (enum types)");
133
188
  continue;
134
189
  }
135
- const uniqueIndexNames = statements.map((s) => s.match(createUniqueIndexPattern)?.[1]).filter((name) => Boolean(name));
136
- if (sqliteDb && uniqueIndexNames.length === statements.length) {
137
- const indexExists = sqliteDb.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?");
138
- if (uniqueIndexNames.filter((name) => !indexExists.get(name)).length > 0) {
139
- log.info(`Re-queueing unique-index migration (index missing from database): ${file}`);
140
- replayMigrations.push(file);
190
+ const addColumnTargets = statements.map((s) => s.match(addColumnPattern)).filter((m) => Boolean(m?.[1] && m[2])).map((m) => ({ table: m[1], column: m[2] }));
191
+ if (addColumnTargets.length > 0 && addColumnTargets.length === statements.length) {
192
+ const satisfied = addColumnTargets.filter(({ table, column }) => {
193
+ try {
194
+ if (sqliteDb) {
195
+ const safeTableName = table.replace(/[^a-zA-Z0-9_]/g, ""), columns = sqliteDb.prepare(`PRAGMA table_info("${safeTableName}")`).all();
196
+ if (columns.some((col) => col.name === column))
197
+ return !0;
198
+ if (columns.length > 0)
199
+ return !1;
200
+ }
201
+ return earlierCreateDefinesColumn(file, table, column);
202
+ } catch {
203
+ return !1;
204
+ }
205
+ });
206
+ if (satisfied.length === addColumnTargets.length) {
207
+ skipMigration(file, "every column it adds already exists or is defined by an earlier create-table migration");
208
+ continue;
141
209
  }
142
- continue;
210
+ if (satisfied.length > 0)
211
+ log.warn(`[migration] ${file} is partially applied: ${satisfied.map((p) => `${p.table}.${p.column}`).join(", ")} already exist${satisfied.length === 1 ? "s" : ""} or will be created earlier, the rest do not. SQLite cannot skip a single ADD COLUMN, so this file will fail. Add the remaining columns by hand, or split the file so the applied statements sit in their own migration.`);
143
212
  }
144
213
  if (statements.some((s) => dropColumnPattern.test(s))) {
145
214
  let modified = !1;
@@ -149,6 +218,10 @@ export function preprocessSqliteMigrations() {
149
218
  if (dropColMatch && dropColMatch[1] && dropColMatch[2]) {
150
219
  const tableName = dropColMatch[1], columnName = dropColMatch[2];
151
220
  if (!sqliteDb) {
221
+ if (earlierCreateDefinesColumn(file, tableName, columnName)) {
222
+ filteredStatements.push(stmt);
223
+ continue;
224
+ }
152
225
  log.info(`Skipping DROP COLUMN "${columnName}" \u2014 no database exists yet: ${file}`);
153
226
  modified = !0;
154
227
  continue;
@@ -156,6 +229,10 @@ export function preprocessSqliteMigrations() {
156
229
  try {
157
230
  const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, ""), columns = sqliteDb.prepare(`PRAGMA table_info("${safeTableName}")`).all();
158
231
  if (columns.length === 0) {
232
+ if (earlierCreateDefinesColumn(file, tableName, columnName)) {
233
+ filteredStatements.push(stmt);
234
+ continue;
235
+ }
159
236
  log.info(`Skipping DROP COLUMN "${columnName}" \u2014 table "${tableName}" does not exist yet: ${file}`);
160
237
  modified = !0;
161
238
  continue;
@@ -166,6 +243,10 @@ export function preprocessSqliteMigrations() {
166
243
  continue;
167
244
  }
168
245
  } catch {
246
+ if (earlierCreateDefinesColumn(file, tableName, columnName)) {
247
+ filteredStatements.push(stmt);
248
+ continue;
249
+ }
169
250
  log.info(`Skipping DROP COLUMN "${columnName}" \u2014 table "${tableName}" not found: ${file}`);
170
251
  modified = !0;
171
252
  continue;
@@ -190,7 +271,7 @@ export function preprocessSqliteMigrations() {
190
271
  } catch {}
191
272
  if (droppedMigrations.length > 0 || replayMigrations.length > 0)
192
273
  try {
193
- const dbPath = join(process.cwd(), dbConfig.connections.sqlite.database || "stacks.db");
274
+ const dbPath = sqliteDatabasePath();
194
275
  mkdirSync(dirname(dbPath), { recursive: !0 });
195
276
  const { Database } = require("bun:sqlite"), writeDb = new Database(dbPath);
196
277
  try {
@@ -275,7 +356,7 @@ async function hideDisabledFeatureMigrations() {
275
356
  const disabledFeatures = new Set(FEATURE_NAMES.filter((f) => !isFeatureEnabled(f)));
276
357
  if (disabledFeatures.size === 0)
277
358
  return hidden;
278
- const files = readdirSync(migrationsDir).filter((f) => f.endsWith(".sql"));
359
+ const files = readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")), gatedTables = new Set;
279
360
  for (const file of files) {
280
361
  const owner = migrationFeature(file);
281
362
  if (!owner || !disabledFeatures.has(owner))
@@ -283,10 +364,24 @@ async function hideDisabledFeatureMigrations() {
283
364
  const table = migrationTable(file);
284
365
  if (table && appModelClaimsTable(table))
285
366
  continue;
367
+ if (table)
368
+ gatedTables.add(table.toLowerCase());
286
369
  const original = join(migrationsDir, file), hiddenPath = `${original}.disabled`;
287
370
  await fs.rename(original, hiddenPath);
288
371
  hidden.push({ original, hidden: hiddenPath, feature: owner });
289
372
  }
373
+ for (const file of files) {
374
+ const filePath = join(migrationsDir, file);
375
+ if (!existsSync(filePath))
376
+ continue;
377
+ const sql = readFileSync(filePath, "utf8"), filtered = withoutGatedStatements(sql, gatedTables);
378
+ if (filtered === sql)
379
+ continue;
380
+ const backup = `${filePath}.ungated`;
381
+ await fs.rename(filePath, backup);
382
+ writeFileSync(filePath, filtered);
383
+ hidden.push({ original: filePath, hidden: backup, feature: "mixed" });
384
+ }
290
385
  if (hidden.length > 0) {
291
386
  const summary = Object.entries(hidden.reduce((acc, h) => {
292
387
  acc[h.feature] = (acc[h.feature] ?? 0) + 1;
@@ -297,10 +392,39 @@ async function hideDisabledFeatureMigrations() {
297
392
  } catch {}
298
393
  return hidden;
299
394
  }
395
+ export function statementTable(statement) {
396
+ const patterns = [
397
+ /^\s*ALTER\s+TABLE\s+["`]?([a-z0-9_]+)["`]?/i,
398
+ /^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?([a-z0-9_]+)["`]?/i,
399
+ /^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["`]?([a-z0-9_]+)["`]?/i,
400
+ /^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?[a-z0-9_]+["`]?\s+ON\s+["`]?([a-z0-9_]+)["`]?/i
401
+ ];
402
+ for (const pattern of patterns) {
403
+ const match = pattern.exec(statement);
404
+ if (match)
405
+ return match[1].toLowerCase();
406
+ }
407
+ return null;
408
+ }
409
+ export function withoutGatedStatements(sql, gated) {
410
+ if (gated.size === 0)
411
+ return sql;
412
+ const statements = sql.split(";").map((s) => s.trim()).filter(Boolean), kept = statements.filter((statement) => {
413
+ const table = statementTable(statement);
414
+ return !table || !gated.has(table);
415
+ });
416
+ if (kept.length === statements.length)
417
+ return sql;
418
+ return kept.length === 0 ? "" : `${kept.join(`;
419
+ `)};
420
+ `;
421
+ }
300
422
  async function restoreHiddenMigrations(hidden) {
301
423
  const fs = await import("node:fs/promises");
302
424
  for (const { original, hidden: h } of hidden)
303
425
  try {
426
+ if (h.endsWith(".ungated"))
427
+ await fs.rm(original, { force: !0 });
304
428
  await fs.rename(h, original);
305
429
  } catch {}
306
430
  }
@@ -379,6 +503,9 @@ export async function runDatabaseMigration() {
379
503
  log.debug("Migrating database...");
380
504
  await ensureDatabaseReady();
381
505
  configureQueryBuilder();
506
+ const notificationTables = await migrateNotificationTables();
507
+ if (!notificationTables.success)
508
+ throw Error(notificationTables.error || "Failed to prepare notification tables");
382
509
  const dialect = getDialect(), lockDb = dialect === "sqlite" ? null : createQueryBuilder();
383
510
  lockHandle = await acquireMigrationLock(dialect, lockDb);
384
511
  if (dialect === "sqlite")
@@ -425,11 +552,43 @@ export async function resetDatabase() {
425
552
  await qbResetDatabase(modelsDir, { dialect });
426
553
  else
427
554
  log.debug(`No models directory at ${modelsDir}; skipping model table drops.`);
555
+ const defaultsDir = defaultModelsPath();
556
+ if (existsSync(defaultsDir))
557
+ await qbResetDatabase(defaultsDir, { dialect });
558
+ else
559
+ log.debug(`No framework default models directory at ${defaultsDir}; skipping.`);
560
+ if (dialect === "postgres")
561
+ await dropOrphanedEnumTypes();
428
562
  return ok("All tables dropped successfully!");
429
563
  } catch (error) {
430
564
  return err(handleError("Database reset failed", error));
431
565
  }
432
566
  }
567
+ async function dropOrphanedEnumTypes() {
568
+ try {
569
+ const rows = await db.unsafe(`
570
+ SELECT t.typname AS name
571
+ FROM pg_type t
572
+ JOIN pg_namespace n ON n.oid = t.typnamespace
573
+ WHERE t.typtype = 'e'
574
+ AND n.nspname = current_schema()
575
+ AND NOT EXISTS (
576
+ SELECT 1 FROM pg_attribute a
577
+ JOIN pg_class c ON c.oid = a.attrelid
578
+ WHERE a.atttypid = t.oid AND c.relkind = 'r' AND NOT a.attisdropped
579
+ )
580
+ `).execute(), names = (Array.isArray(rows) ? rows : rows?.rows ?? []).map((row) => String(row.name ?? row.typname ?? "")).filter(Boolean);
581
+ for (const name of names)
582
+ try {
583
+ await db.unsafe(`DROP TYPE IF EXISTS "${name}" CASCADE`).execute();
584
+ log.debug(`Dropped orphaned enum type: ${name}`);
585
+ } catch (error) {
586
+ log.warn(`Could not drop enum type ${name}: ${error instanceof Error ? error.message : String(error)}`);
587
+ }
588
+ } catch (error) {
589
+ log.warn(`Could not list enum types to drop: ${error instanceof Error ? error.message : String(error)}`);
590
+ }
591
+ }
433
592
  async function dropFrameworkTables(dialect) {
434
593
  if (dialect === "mysql")
435
594
  try {
@@ -499,6 +658,52 @@ function snapshotDirLabel() {
499
658
  function resolveSnapshotDir() {
500
659
  return join(process.cwd(), snapshotDirLabel());
501
660
  }
661
+ function snapshotPathFor(dialect) {
662
+ return join(resolveSnapshotDir(), `model-snapshot.${dialect}.json`);
663
+ }
664
+ function readStoredMigrationPlan(dialect) {
665
+ const snapshotPath = snapshotPathFor(dialect);
666
+ if (!existsSync(snapshotPath))
667
+ return;
668
+ let parsed;
669
+ try {
670
+ parsed = JSON.parse(readFileSync(snapshotPath, "utf8"));
671
+ } catch (error) {
672
+ throw Error(`The migration snapshot is not valid JSON: ${snapshotPath}. Repair or regenerate it before creating migrations.`, { cause: error });
673
+ }
674
+ const candidate = parsed && typeof parsed === "object" && "plan" in parsed ? parsed.plan : parsed;
675
+ if (!candidate || typeof candidate !== "object" || !Array.isArray(candidate.tables))
676
+ throw TypeError(`The migration snapshot has an invalid structure: ${snapshotPath}. Repair or regenerate it before creating migrations.`);
677
+ return candidate;
678
+ }
679
+ export function preserveMigrationPlanTableOrder(next, previous) {
680
+ const orderedByPrevious = (values, previousValues, keyFor) => {
681
+ if (!previousValues)
682
+ return [...values];
683
+ const previousOrder = new Map(previousValues.map((value, index) => [keyFor(value), index])), nextOrder = new Map(values.map((value, index) => [keyFor(value), index])), previousCount = previousValues.length;
684
+ return [...values].sort((left, right) => {
685
+ const leftKey = keyFor(left), rightKey = keyFor(right), leftOrder = previousOrder.get(leftKey) ?? previousCount + nextOrder.get(leftKey), rightOrder = previousOrder.get(rightKey) ?? previousCount + nextOrder.get(rightKey);
686
+ return leftOrder - rightOrder;
687
+ });
688
+ }, previousTables = new Map(previous?.tables.map((table) => [table.table, table]) ?? []), tables = next.tables.map((table) => {
689
+ const previousTable = previousTables.get(table.table), previousColumns = new Map(previousTable?.columns.map((column) => [column.name, column]) ?? []);
690
+ return {
691
+ ...table,
692
+ columns: orderedByPrevious(table.columns.map((column) => {
693
+ if (next.dialect !== "sqlite" || !column.enumTypeName || previousColumns.get(column.name)?.enumTypeName)
694
+ return column;
695
+ const portableColumn = { ...column };
696
+ delete portableColumn.enumTypeName;
697
+ return portableColumn;
698
+ }), previousTable?.columns, (column) => column.name),
699
+ indexes: orderedByPrevious(table.indexes, previousTable?.indexes, (index) => index.name)
700
+ };
701
+ });
702
+ return {
703
+ ...next,
704
+ tables: orderedByPrevious(tables, previous?.tables, (table) => table.table)
705
+ };
706
+ }
502
707
  function detectSnapshotDialectMismatch(dialect) {
503
708
  const qbDir = resolveSnapshotDir();
504
709
  let files;
@@ -526,7 +731,7 @@ export async function generateMigrations(options = {}) {
526
731
  const snapshotDir = snapshotDirLabel();
527
732
  return err(Error(`Refusing to generate migrations: resolved dialect "${dialect}" has no snapshot in ${snapshotDir}/, but "${mismatch}" does. DB_CONNECTION is likely unset or wrong ` + "(missing .env?) \u2014 generating now would write a full duplicate migration set in the " + `wrong dialect. Set DB_CONNECTION=${mismatch} (or your intended dialect) and retry. To intentionally start a new dialect from scratch, remove ${snapshotDir}/model-snapshot.${mismatch}.json first.`));
528
733
  }
529
- const { modelsDir, skip } = prepareMigrationModelsDir();
734
+ const storedPlan = readStoredMigrationPlan(getQbDialect()), { modelsDir, skip } = prepareMigrationModelsDir();
530
735
  if (skip) {
531
736
  log.debug("No app/Models directory found; using committed framework migrations");
532
737
  return ok("Migrations generated");
@@ -535,12 +740,22 @@ export async function generateMigrations(options = {}) {
535
740
  log.debug(`[migration] Generating migrations for dialect: ${dialect}, models: ${modelsDir}`);
536
741
  const result = await qbGenerateMigration(modelsDir, { dialect: getQbDialect(), dryRun: !0, applyRenames, fromDb });
537
742
  let sqlStatements = result.sqlStatements ?? [];
743
+ if (dialect === "sqlite")
744
+ sqlStatements = inlineSqliteAddedColumnReferences(sqlStatements, result.plan);
538
745
  if (result.hasChanges && sqlStatements.length > 0) {
539
746
  const filtered = withoutManagedColumnDropSql(sqlStatements, await frameworkManagedColumns(), result.operations ?? []);
540
747
  if (filtered.removed.length > 0)
541
748
  log.debug(`[migration] Skipped ${filtered.removed.length} generated drop(s) of framework-managed column(s) (stacksjs/stacks#2075)`);
542
749
  sqlStatements = filtered.statements;
543
750
  }
751
+ if (result.hasChanges && sqlStatements.length > 0) {
752
+ const dangling = findDanglingTypeReferences(sqlStatements);
753
+ if (dangling.length > 0) {
754
+ const before = sqlStatements.length;
755
+ sqlStatements = sqlStatements.filter((statement) => !referencesUndefinedType(statement, dangling));
756
+ log.warn(`[migration] Skipped ${before - sqlStatements.length} generated statement(s) referencing enum type(s) nothing creates (${dangling.slice(0, 3).join(", ")}${dangling.length > 3 ? ", \u2026" : ""}). This is a bug in the migration generator, not in your models.`);
757
+ }
758
+ }
544
759
  if (result.hasChanges) {
545
760
  const written = persistGeneratedMigrations(sqlStatements);
546
761
  if (written > 0)
@@ -549,12 +764,19 @@ export async function generateMigrations(options = {}) {
549
764
  log.debug("Migration generation produced no new files (already up to date)");
550
765
  } else
551
766
  log.debug("No changes detected");
552
- saveMigrationSnapshot(result.plan, { dialect: getQbDialect() });
767
+ const stablePlan = preserveMigrationPlanTableOrder(result.plan, storedPlan);
768
+ if (!storedPlan || JSON.stringify(stablePlan) !== JSON.stringify(storedPlan))
769
+ saveMigrationSnapshot(stablePlan, { dialect: getQbDialect() });
770
+ else
771
+ log.debug("Model snapshot unchanged");
553
772
  return ok("Migrations generated");
554
773
  } catch (error) {
555
774
  return err(handleError("Migration generation failed", error));
556
775
  }
557
776
  }
777
+ export function referencesUndefinedType(statement, dangling) {
778
+ return dangling.some((name) => statement.includes(`"${name}"`));
779
+ }
558
780
  export function findDanglingTypeReferences(statements) {
559
781
  const defined = new Set, referenced = new Set;
560
782
  for (const statement of statements) {
@@ -565,6 +787,32 @@ export function findDanglingTypeReferences(statements) {
565
787
  }
566
788
  return [...referenced].filter((name) => !defined.has(name)).sort();
567
789
  }
790
+ export function inlineSqliteAddedColumnReferences(statements, plan) {
791
+ const references = new Map;
792
+ for (const table of plan?.tables ?? []) {
793
+ if (!table.table)
794
+ continue;
795
+ for (const column of table.columns ?? []) {
796
+ const reference = column.references;
797
+ if (!column.name || !reference?.table || !reference.column)
798
+ continue;
799
+ references.set(`${table.table}.${column.name}`, {
800
+ table: reference.table,
801
+ column: reference.column
802
+ });
803
+ }
804
+ }
805
+ if (references.size === 0)
806
+ return statements;
807
+ return statements.map((statement) => {
808
+ if (/\bREFERENCES\b/i.test(statement))
809
+ return statement;
810
+ const match = statement.match(/^(\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+COLUMN\s+["`]?(\w+)["`]?\s+)([\s\S]*?)(;?\s*)$/i), reference = match?.[2] && match[3] ? references.get(`${match[2]}.${match[3]}`) : void 0;
811
+ if (!match || !reference)
812
+ return statement;
813
+ return `${match[1]}${match[4].trimEnd()} REFERENCES "${reference.table}"("${reference.column}")${match[5]}`;
814
+ });
815
+ }
568
816
  export async function regenerateMigrationCorpus(options = {}) {
569
817
  try {
570
818
  configureQueryBuilder();
@@ -735,7 +983,7 @@ export function groupGeneratedStatements(sqlStatements) {
735
983
  const alter = stmt.match(/^\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+(?:ADD\s+COLUMN\s+["`]?(\w+)["`]?|DROP\s+COLUMN\s+["`]?(\w+)["`]?|ADD\s+CONSTRAINT)/i), alterTable = alter?.[1];
736
984
  if (alter && alterTable) {
737
985
  const isCreateTimeConstraint = createdTables.has(alterTable) && !alter[2] && !alter[3];
738
- push(isCreateTimeConstraint ? "create-foreign-key-constraints" : `alter-${alterTable}-${alter[2] || alter[3] || "constraint"}`, stmt);
986
+ push(isCreateTimeConstraint ? "create-foreign-key-constraints" : `alter-${alterTable}-columns`, stmt);
739
987
  continue;
740
988
  }
741
989
  const idx = stmt.match(/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?\s+ON\s+["`]?(\w+)["`]?/i), idxName = idx?.[1], idxTable = idx?.[2];
@@ -12,6 +12,12 @@ export declare function notificationsTableSql(sql: SqlHelpers): string;
12
12
  * preference upsert safe — matches `NotificationPreferenceRow`.
13
13
  */
14
14
  export declare function notificationPreferencesTableSql(sql: SqlHelpers): string;
15
+ /**
16
+ * `CREATE TABLE IF NOT EXISTS notification_deliveries`. This table records
17
+ * every transport attempt made by `notify()` without mixing outbound delivery
18
+ * state into the database inbox table.
19
+ */
20
+ export declare function notificationDeliveriesTableSql(sql: SqlHelpers): string;
15
21
  /**
16
22
  * Create the notification + notification_preferences tables. Idempotent
17
23
  * (`IF NOT EXISTS`), so it's safe to run on every `buddy migrate`.
@@ -3,7 +3,7 @@ import { env as envVars } from "@stacksjs/env";
3
3
  import { db } from "./utils";
4
4
  import { sqlHelpers } from "./sql-helpers";
5
5
  function getDbDriver() {
6
- return envVars.DB_CONNECTION || "sqlite";
6
+ return process.env.DB_CONNECTION || envVars.DB_CONNECTION || "sqlite";
7
7
  }
8
8
  export function notificationsTableSql(sql) {
9
9
  const { pkColumn, nullableTimestamp } = sql;
@@ -13,6 +13,7 @@ export function notificationsTableSql(sql) {
13
13
  type VARCHAR(255) NOT NULL,
14
14
  data TEXT NOT NULL,
15
15
  read_at ${nullableTimestamp},
16
+ uuid VARCHAR(36),
16
17
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
17
18
  updated_at ${nullableTimestamp}
18
19
  )`;
@@ -30,6 +31,23 @@ export function notificationPreferencesTableSql(sql) {
30
31
  UNIQUE (user_id, channel, category)
31
32
  )`;
32
33
  }
34
+ export function notificationDeliveriesTableSql(sql) {
35
+ const { pkColumn, nullableTimestamp } = sql;
36
+ return `CREATE TABLE IF NOT EXISTS notification_deliveries (
37
+ ${pkColumn},
38
+ user_id INTEGER,
39
+ channel VARCHAR(50) NOT NULL,
40
+ recipient VARCHAR(1000) NOT NULL,
41
+ subject VARCHAR(255),
42
+ body TEXT NOT NULL,
43
+ status VARCHAR(50) NOT NULL DEFAULT 'pending',
44
+ error TEXT,
45
+ metadata TEXT,
46
+ sent_at ${nullableTimestamp},
47
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
48
+ updated_at ${nullableTimestamp}
49
+ )`;
50
+ }
33
51
  export async function migrateNotificationTables(options = {}) {
34
52
  const dbDriver = getDbDriver(), sql = sqlHelpers(dbDriver);
35
53
  if (options.verbose)
@@ -43,6 +61,11 @@ export async function migrateNotificationTables(options = {}) {
43
61
  log.info("Creating notification_preferences table...");
44
62
  await db.unsafe(notificationPreferencesTableSql(sql)).execute();
45
63
  await db.unsafe("CREATE INDEX IF NOT EXISTS idx_notification_preferences_user ON notification_preferences (user_id)").execute();
64
+ if (options.verbose)
65
+ log.info("Creating notification deliveries table...");
66
+ await db.unsafe(notificationDeliveriesTableSql(sql)).execute();
67
+ await db.unsafe("CREATE INDEX IF NOT EXISTS idx_notification_deliveries_channel ON notification_deliveries (channel)").execute();
68
+ await db.unsafe("CREATE INDEX IF NOT EXISTS idx_notification_deliveries_status ON notification_deliveries (status)").execute();
46
69
  if (options.verbose)
47
70
  log.success("Notification tables created");
48
71
  return { success: !0 };
@@ -3,6 +3,7 @@ export declare function setQueryTracker(fn: QueryTracker): void;
3
3
  * Process an executed query and store it in the database
4
4
  */
5
5
  export declare function logQuery(event: LogEvent): Promise<void>;
6
+ export declare function isExcludedQuery(query: string, excludedQueries: readonly string[]): boolean;
6
7
  /**
7
8
  * Query log event type - compatible with bun-query-builder hooks
8
9
  */
@@ -13,11 +13,15 @@ export async function logQuery(event) {
13
13
  return;
14
14
  try {
15
15
  const { query, durationMs, error, bindings } = extractQueryInfo(event);
16
- try {
17
- trackQuery(query, durationMs, config.database?.default || "unknown");
18
- } catch {}
16
+ if (query)
17
+ try {
18
+ trackQuery(query, durationMs, config.database?.default || "unknown");
19
+ } catch {}
19
20
  if (!config.database?.queryLogging?.enabled)
20
21
  return;
22
+ const excludedQueries = config.database?.queryLogging?.excludedQueries;
23
+ if (!query.trim() || isExcludedQuery(query, Array.isArray(excludedQueries) ? excludedQueries : []))
24
+ return;
21
25
  const status = determineQueryStatus(durationMs, error), logRecord = await createQueryLogRecord(query, durationMs, status, error, bindings);
22
26
  if (config.database?.queryLogging?.analysis?.enabled && (status === "slow" || config.database.queryLogging.analysis.analyzeAll))
23
27
  await enhanceWithQueryAnalysis(logRecord);
@@ -38,8 +42,27 @@ export async function logQuery(event) {
38
42
  log.error("Failed to log query:", err);
39
43
  }
40
44
  }
45
+ export function isExcludedQuery(query, excludedQueries) {
46
+ const normalizedQuery = query.toLowerCase();
47
+ return excludedQueries.some((pattern) => {
48
+ const normalizedPattern = pattern.trim().toLowerCase();
49
+ return normalizedPattern.length > 0 && normalizedQuery.includes(normalizedPattern);
50
+ });
51
+ }
52
+ function queryText(sql) {
53
+ if (typeof sql === "string")
54
+ return usableQueryText(sql);
55
+ if (sql && typeof sql === "object" && typeof sql.then === "function")
56
+ return "";
57
+ if (sql && typeof sql.toString === "function")
58
+ return usableQueryText(String(sql));
59
+ return "";
60
+ }
61
+ function usableQueryText(text) {
62
+ return text.startsWith("[object ") ? "" : text;
63
+ }
41
64
  function extractQueryInfo(event) {
42
- const query = event.query?.sql || "", durationMs = event.queryDurationMillis || 0, error = event.error;
65
+ const query = queryText(event.query?.sql), durationMs = event.queryDurationMillis || 0, error = event.error;
43
66
  let bindings;
44
67
  if (event.query?.parameters)
45
68
  try {
@@ -208,6 +231,10 @@ async function storeQueryLog(logRecord) {
208
231
  try {
209
232
  await db.insertInto("query_logs").values(logRecord).execute();
210
233
  } catch (error) {
211
- log.error("Failed to store query log:", error);
234
+ const message = error instanceof Error ? error.message : String(error);
235
+ if (/no such table|does not exist|doesn't exist/i.test(message) && /query_logs/i.test(message))
236
+ log.debug("Query logging will start after the query_logs table is migrated.");
237
+ else
238
+ log.error("Failed to store query log:", error);
212
239
  }
213
240
  }
@@ -0,0 +1,19 @@
1
+ import type { Model } from '@stacksjs/types';
2
+ /**
3
+ * The foreign key a single `belongsTo` entry puts on the declaring model's
4
+ * table. Accepts both forms the type allows: a bare model name, and the
5
+ * object form with an explicit `foreignKey`.
6
+ */
7
+ export declare function belongsToColumn(entry: unknown): string | null;
8
+ /** Every foreign key column a model's `belongsTo` declarations imply. */
9
+ export declare function belongsToColumnsOf(model: Model): string[];
10
+ /**
11
+ * Resolve `table -> relation foreign key columns` across userland and
12
+ * framework-default models.
13
+ *
14
+ * A column that IS declared in `attributes` needs no protection — the differ
15
+ * already expects it — but including it changes nothing, since the guards only
16
+ * ever suppress drops of columns in this set and the differ never proposes
17
+ * dropping a column it expects.
18
+ */
19
+ export declare function findRelationForeignKeys(): Promise<Map<string, Set<string>>>;