@stacksjs/database 0.70.229 → 0.70.230
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/dist/custom/jobs.js +36 -6
- package/dist/drivers/defaults/traits.js +28 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/managed-columns.d.ts +26 -3
- package/dist/managed-columns.js +19 -6
- package/dist/migrations.d.ts +67 -5
- package/dist/migrations.js +268 -20
- package/dist/notification-tables.d.ts +6 -0
- package/dist/notification-tables.js +24 -1
- package/dist/query-logger.d.ts +1 -0
- package/dist/query-logger.js +32 -5
- package/dist/relation-columns.d.ts +19 -0
- package/dist/relation-columns.js +66 -0
- package/dist/seeder.d.ts +29 -0
- package/dist/seeder.js +31 -11
- package/dist/utils.d.ts +24 -0
- package/dist/utils.js +35 -7
- package/package.json +10 -10
package/dist/migrations.js
CHANGED
|
@@ -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
|
|
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
|
|
79
|
-
return {
|
|
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
|
|
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),
|
|
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
|
|
136
|
-
if (
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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}
|
|
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 };
|
package/dist/query-logger.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/query-logger.js
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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
|
-
|
|
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>>>;
|