@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.
- package/dist/auth-tables.js +18 -137
- package/dist/column.js +1 -26
- package/dist/custom/audits.js +20 -54
- package/dist/custom/errors.js +16 -46
- package/dist/custom/index.js +1 -3
- package/dist/custom/jobs.js +13 -137
- package/dist/database.js +1 -181
- package/dist/datetime-columns.js +2 -79
- package/dist/ddl-constraints.js +7 -111
- package/dist/defaults.js +1 -48
- package/dist/dialect.js +1 -79
- package/dist/driver-config.js +1 -172
- package/dist/drivers/defaults/index.js +1 -1
- package/dist/drivers/defaults/traits.js +1 -29
- package/dist/drivers/dynamodb.js +1 -607
- package/dist/drivers/helpers.js +1 -206
- package/dist/drivers/index.js +1 -9
- package/dist/drivers/mysql.js +58 -299
- package/dist/drivers/postgres.js +78 -368
- package/dist/drivers/sqlite.js +61 -379
- package/dist/ensure-database.js +1 -145
- package/dist/fk-audit.js +3 -187
- package/dist/index.js +1 -64
- package/dist/managed-columns.js +1 -59
- package/dist/migration-dialect.js +4 -107
- package/dist/migration-ledger.js +1 -382
- package/dist/migration-lock.js +1 -143
- package/dist/migrations.js +15 -1118
- package/dist/model-sources.js +1 -76
- package/dist/notification-tables.js +4 -49
- package/dist/query-logger.js +2 -241
- package/dist/query-parser.js +1 -93
- package/dist/rbac-tables.js +6 -61
- package/dist/relation-columns.js +1 -66
- package/dist/replicas.js +1 -74
- package/dist/safe-migrations.js +2 -52
- package/dist/schema.js +1 -10
- package/dist/seeder.js +1 -457
- package/dist/sql-helpers.js +1 -50
- package/dist/table.js +1 -26
- package/dist/tools/setup.js +1 -6
- package/dist/trait-tables.js +8 -153
- package/dist/transaction-context.js +1 -62
- package/dist/types.js +1 -98
- package/dist/unique-audit.js +3 -155
- package/dist/utils.js +1 -285
- package/dist/uuid-columns.js +1 -68
- package/dist/validators.js +1 -122
- package/dist/vschema.js +2 -121
- package/package.json +20 -13
package/dist/migrations.js
CHANGED
|
@@ -1,648 +1,18 @@
|
|
|
1
|
-
var {require}=import.meta;import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const log = {
|
|
5
|
-
info: (...args) => typeof _log?.info === "function" ? _log.info(...args) : console.log(...args),
|
|
6
|
-
success: (msg) => typeof _log?.success === "function" ? _log.success(msg) : console.log(msg),
|
|
7
|
-
warn: (msg) => typeof _log?.warn === "function" ? _log.warn(msg) : console.warn(msg),
|
|
8
|
-
error: (...args) => typeof _log?.error === "function" ? _log.error(...args) : console.error(...args),
|
|
9
|
-
debug: (...args) => typeof _log?.debug === "function" ? _log.debug(...args) : console.debug(...args)
|
|
10
|
-
};
|
|
11
|
-
import { err, handleError, ok } from "@stacksjs/error-handling";
|
|
12
|
-
import { path } from "@stacksjs/path";
|
|
13
|
-
import { defaultModelsPath } from "./seeder";
|
|
14
|
-
import {
|
|
15
|
-
createQueryBuilder,
|
|
16
|
-
executeMigration as qbExecuteMigration,
|
|
17
|
-
generateMigration as qbGenerateMigration,
|
|
18
|
-
resetConnection,
|
|
19
|
-
resetDatabase as qbResetDatabase,
|
|
20
|
-
config as qbConfig,
|
|
21
|
-
saveMigrationSnapshot,
|
|
22
|
-
setConfig
|
|
23
|
-
} from "@stacksjs/query-builder";
|
|
24
|
-
import { db, QB_SNAPSHOT_DIR } from "./utils";
|
|
25
|
-
import {
|
|
26
|
-
classifyConnectionError,
|
|
27
|
-
createDatabase,
|
|
28
|
-
describeTarget,
|
|
29
|
-
manualCreateHint,
|
|
30
|
-
probeTargetDatabase,
|
|
31
|
-
resolveConnectionTarget
|
|
32
|
-
} from "./ensure-database";
|
|
33
|
-
import { resolveModelSources } from "./model-sources";
|
|
34
|
-
import { frameworkManagedColumns, withoutManagedColumnDrops, withoutManagedColumnDropSql } from "./managed-columns";
|
|
35
|
-
import { acquireMigrationLock } from "./migration-lock";
|
|
36
|
-
import { migrateNotificationTables } from "./notification-tables";
|
|
37
|
-
import { env as envVars } from "@stacksjs/env";
|
|
38
|
-
import { getConnectionDefaults } from "./defaults";
|
|
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 = {
|
|
48
|
-
default: dbDriver,
|
|
49
|
-
connections: {
|
|
50
|
-
sqlite: { database: sqliteDefaults.database, prefix: "" },
|
|
51
|
-
mysql: { name: mysqlDefaults.database, host: mysqlDefaults.host, username: mysqlDefaults.username, password: mysqlDefaults.password, port: mysqlDefaults.port, prefix: "" },
|
|
52
|
-
postgres: { name: postgresDefaults.database, host: postgresDefaults.host, username: postgresDefaults.username, password: postgresDefaults.password, port: postgresDefaults.port, prefix: "" }
|
|
53
|
-
}
|
|
54
|
-
};
|
|
55
|
-
function sqliteDatabasePath() {
|
|
56
|
-
const configured = dbConfig.connections.sqlite.database || "stacks.db";
|
|
57
|
-
return isAbsolute(configured) ? configured : join(process.cwd(), configured);
|
|
58
|
-
}
|
|
59
|
-
function getDriver() {
|
|
60
|
-
return dbConfig.default || "sqlite";
|
|
61
|
-
}
|
|
62
|
-
function getDialect() {
|
|
63
|
-
const driver = getDriver();
|
|
64
|
-
if (driver === "sqlite" || driver === "mysql" || driver === "postgres")
|
|
65
|
-
return driver;
|
|
66
|
-
if (driver === "singlestore")
|
|
67
|
-
return "mysql";
|
|
68
|
-
if (driver === "dynamodb")
|
|
69
|
-
throw Error("[database] DB_CONNECTION=dynamodb is not compatible with the SQL migration runner. " + "DynamoDB has no schema-migration concept \u2014 use the entity-style `dynamo.entity(...)` " + "API from @stacksjs/database directly. To run SQL migrations, set DB_CONNECTION to one of: sqlite, mysql, postgres.");
|
|
70
|
-
throw Error(`[database] Unknown DB_CONNECTION "${driver}". Allowed values: sqlite, mysql, postgres, dynamodb.`);
|
|
71
|
-
}
|
|
72
|
-
function getQbDialect() {
|
|
73
|
-
return getDriver() === "singlestore" ? "singlestore" : getDialect();
|
|
74
|
-
}
|
|
75
|
-
function configureQueryBuilder() {
|
|
76
|
-
const dialect = getDialect(), connectionConfig = dbConfig.connections[dialect];
|
|
77
|
-
setConfig({
|
|
78
|
-
dialect,
|
|
79
|
-
verbose: !1,
|
|
80
|
-
snapshotDir: QB_SNAPSHOT_DIR,
|
|
81
|
-
database: {
|
|
82
|
-
database: connectionConfig?.name || connectionConfig?.database || "stacks",
|
|
83
|
-
host: connectionConfig?.host || "localhost",
|
|
84
|
-
port: connectionConfig?.port || (dialect === "postgres" ? 5432 : dialect === "mysql" ? 3306 : 0),
|
|
85
|
-
username: connectionConfig?.username || "",
|
|
86
|
-
password: connectionConfig?.password || ""
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
resetConnection();
|
|
90
|
-
}
|
|
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
|
-
const statements = [];
|
|
100
|
-
let current = "", quote = null, dollarTag = null;
|
|
101
|
-
for (let i = 0;i < content.length; i++) {
|
|
102
|
-
const char = content[i];
|
|
103
|
-
if (dollarTag) {
|
|
104
|
-
current += char;
|
|
105
|
-
if (char === "$" && content.startsWith(dollarTag, i)) {
|
|
106
|
-
current += content.slice(i + 1, i + dollarTag.length);
|
|
107
|
-
i += dollarTag.length - 1;
|
|
108
|
-
dollarTag = null;
|
|
109
|
-
}
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
if (quote) {
|
|
113
|
-
current += char;
|
|
114
|
-
if (quote === "single" && char === "'" || quote === "double" && char === '"')
|
|
115
|
-
quote = null;
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
if (char === "-" && content[i + 1] === "-") {
|
|
119
|
-
const newline = content.indexOf(`
|
|
120
|
-
`, i);
|
|
121
|
-
if (newline === -1)
|
|
122
|
-
break;
|
|
123
|
-
i = newline - 1;
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
const dollar = char === "$" ? /^\$[A-Za-z_]*\$/.exec(content.slice(i)) : null;
|
|
127
|
-
if (dollar) {
|
|
128
|
-
dollarTag = dollar[0];
|
|
129
|
-
current += dollarTag;
|
|
130
|
-
i += dollarTag.length - 1;
|
|
131
|
-
continue;
|
|
132
|
-
}
|
|
133
|
-
if (char === "'") {
|
|
134
|
-
quote = "single";
|
|
135
|
-
current += char;
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
if (char === '"') {
|
|
139
|
-
quote = "double";
|
|
140
|
-
current += char;
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
if (char === ";") {
|
|
144
|
-
const trimmed = current.trim();
|
|
145
|
-
if (trimmed.length > 0)
|
|
146
|
-
statements.push(trimmed);
|
|
147
|
-
current = "";
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
current += char;
|
|
151
|
-
}
|
|
152
|
-
const trailing = current.trim();
|
|
153
|
-
if (trailing.length > 0)
|
|
154
|
-
statements.push(trailing);
|
|
155
|
-
return statements;
|
|
156
|
-
}
|
|
157
|
-
export function orderPostgresColumnTypeChanges(sql) {
|
|
158
|
-
const lines = sql.split(`
|
|
159
|
-
`), output = [];
|
|
160
|
-
for (const line of lines) {
|
|
161
|
-
const match = /^(\s*)ALTER\s+TABLE\s+("?[\w.]+"?)\s+ALTER\s+COLUMN\s+("?[\w]+"?)\s+TYPE\s/i.exec(line);
|
|
162
|
-
if (match) {
|
|
163
|
-
const [, indent, table, column] = match, drop = `${indent}ALTER TABLE ${table} ALTER COLUMN ${column} DROP DEFAULT;`;
|
|
164
|
-
if ((output.length > 0 ? output[output.length - 1].trim() : "") !== drop.trim())
|
|
165
|
-
output.push(drop);
|
|
166
|
-
}
|
|
167
|
-
output.push(line);
|
|
168
|
-
}
|
|
169
|
-
return output.join(`
|
|
170
|
-
`);
|
|
171
|
-
}
|
|
172
|
-
export function guardPostgresEnumTypes(sql) {
|
|
173
|
-
return sql.replace(/CREATE\s+TYPE\s+("?[\w.]+"?)\s+AS\s+ENUM\s*\(([^)]*)\)/gi, (match, name, members, offset, whole) => {
|
|
174
|
-
if (/\bBEGIN\s*$/i.test(whole.slice(Math.max(0, offset - 40), offset)))
|
|
175
|
-
return match;
|
|
176
|
-
return `DO $stacks$ BEGIN CREATE TYPE ${name} AS ENUM (${members}); EXCEPTION WHEN duplicate_object THEN null; END $stacks$`;
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
export function preprocessSqliteMigrations() {
|
|
180
|
-
const migrationsDir = join(process.cwd(), "database", "migrations");
|
|
181
|
-
let files;
|
|
182
|
-
try {
|
|
183
|
-
files = readdirSync(migrationsDir).filter((f) => f.endsWith(".sql"));
|
|
184
|
-
} catch {
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
const droppedMigrations = [], skipMigration = (file, reason) => {
|
|
188
|
-
log.info(`Skipping migration on SQLite (${reason}): ${file}`);
|
|
189
|
-
droppedMigrations.push(file);
|
|
190
|
-
}, deleteMigration = (file, filePath, reason) => {
|
|
191
|
-
log.info(`Dropping no-op migration (${reason}): ${file}`);
|
|
192
|
-
try {
|
|
193
|
-
unlinkSync(filePath);
|
|
194
|
-
} catch {}
|
|
195
|
-
droppedMigrations.push(file);
|
|
196
|
-
}, 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;
|
|
197
|
-
for (const file of files) {
|
|
198
|
-
const m = file.match(/^\d+-create-(\w+)-table\.sql$/);
|
|
199
|
-
if (!m || !m[1])
|
|
200
|
-
continue;
|
|
201
|
-
const tableName = m[1], existing = createTableEarliest.get(tableName);
|
|
202
|
-
if (!existing || file < existing)
|
|
203
|
-
createTableEarliest.set(tableName, file);
|
|
204
|
-
}
|
|
205
|
-
const earlierCreateDefinesColumn = (migrationFile, table, column) => {
|
|
206
|
-
const createFile = createTableEarliest.get(table);
|
|
207
|
-
if (!createFile || createFile >= migrationFile)
|
|
208
|
-
return !1;
|
|
209
|
-
try {
|
|
210
|
-
const createContent = readFileSync(join(migrationsDir, createFile), "utf8"), createStatement = sqlStatementsOf(createContent).find((statement) => statement.match(createTablePattern)?.[1] === table);
|
|
211
|
-
if (!createStatement)
|
|
212
|
-
return !1;
|
|
213
|
-
const escapedColumn = column.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
214
|
-
return new RegExp(`(?:^|[,(])\\s*["'\`]?${escapedColumn}["'\`]?\\s+`, "i").test(createStatement);
|
|
215
|
-
} catch {
|
|
216
|
-
return !1;
|
|
217
|
-
}
|
|
218
|
-
}, sqliteDbPath = sqliteDatabasePath();
|
|
219
|
-
let sqliteDb = null;
|
|
220
|
-
if (existsSync(sqliteDbPath))
|
|
221
|
-
try {
|
|
222
|
-
const { Database } = require("bun:sqlite");
|
|
223
|
-
sqliteDb = new Database(sqliteDbPath, { readonly: !0 });
|
|
224
|
-
} catch {}
|
|
225
|
-
const migrationWasRecorded = (file) => {
|
|
226
|
-
if (!sqliteDb)
|
|
227
|
-
return !1;
|
|
228
|
-
try {
|
|
229
|
-
return Boolean(sqliteDb.prepare("SELECT 1 FROM migrations WHERE migration = ? LIMIT 1").get(file));
|
|
230
|
-
} catch {
|
|
231
|
-
return !1;
|
|
232
|
-
}
|
|
233
|
-
};
|
|
234
|
-
for (const file of files) {
|
|
235
|
-
log.debug(`[migration] Running: ${file}`);
|
|
236
|
-
const filePath = join(migrationsDir, file), content = readFileSync(filePath, "utf-8"), statements = sqlStatementsOf(content);
|
|
237
|
-
if (statements.length === 0)
|
|
238
|
-
continue;
|
|
239
|
-
const uniqueIndexNames = statements.map((s) => s.match(createUniqueIndexPattern)?.[1]).filter((name) => Boolean(name));
|
|
240
|
-
if (sqliteDb && uniqueIndexNames.length === statements.length) {
|
|
241
|
-
const indexExists = sqliteDb.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?");
|
|
242
|
-
if (uniqueIndexNames.filter((name) => !indexExists.get(name)).length > 0) {
|
|
243
|
-
log.info(`Re-queueing unique-index migration (index missing from database): ${file}`);
|
|
244
|
-
replayMigrations.push(file);
|
|
245
|
-
}
|
|
246
|
-
continue;
|
|
247
|
-
}
|
|
248
|
-
if (migrationWasRecorded(file))
|
|
249
|
-
continue;
|
|
250
|
-
const firstStatement = statements[0], createTableMatch = firstStatement ? firstStatement.match(createTablePattern) : null;
|
|
251
|
-
if (createTableMatch && createTableMatch[1]) {
|
|
252
|
-
const tableName = createTableMatch[1], earliest = createTableEarliest.get(tableName);
|
|
253
|
-
if (earliest && earliest !== file) {
|
|
254
|
-
deleteMigration(file, filePath, `duplicate create-table for "${tableName}" (kept ${earliest})`);
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
if (statements.every((s) => addConstraintPattern.test(s))) {
|
|
259
|
-
skipMigration(file, "SQLite does not support ALTER TABLE ADD CONSTRAINT");
|
|
260
|
-
continue;
|
|
261
|
-
}
|
|
262
|
-
if (statements.every((s) => createTypePattern.test(s))) {
|
|
263
|
-
skipMigration(file, "SQLite does not support CREATE TYPE (enum types)");
|
|
264
|
-
continue;
|
|
265
|
-
}
|
|
266
|
-
const addColumnTargets = statements.map((s) => s.match(addColumnPattern)).filter((m) => Boolean(m?.[1] && m[2])).map((m) => ({ table: m[1], column: m[2] }));
|
|
267
|
-
if (addColumnTargets.length > 0 && addColumnTargets.length === statements.length) {
|
|
268
|
-
const satisfied = addColumnTargets.filter(({ table, column }) => {
|
|
269
|
-
try {
|
|
270
|
-
if (sqliteDb) {
|
|
271
|
-
const safeTableName = table.replace(/[^a-zA-Z0-9_]/g, ""), columns = sqliteDb.prepare(`PRAGMA table_info("${safeTableName}")`).all();
|
|
272
|
-
if (columns.some((col) => col.name === column))
|
|
273
|
-
return !0;
|
|
274
|
-
if (columns.length > 0)
|
|
275
|
-
return !1;
|
|
276
|
-
}
|
|
277
|
-
return earlierCreateDefinesColumn(file, table, column);
|
|
278
|
-
} catch {
|
|
279
|
-
return !1;
|
|
280
|
-
}
|
|
281
|
-
});
|
|
282
|
-
if (satisfied.length === addColumnTargets.length) {
|
|
283
|
-
skipMigration(file, "every column it adds already exists or is defined by an earlier create-table migration");
|
|
284
|
-
continue;
|
|
285
|
-
}
|
|
286
|
-
if (satisfied.length > 0)
|
|
287
|
-
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.`);
|
|
288
|
-
}
|
|
289
|
-
if (statements.some((s) => dropColumnPattern.test(s))) {
|
|
290
|
-
let modified = !1;
|
|
291
|
-
const filteredStatements = [];
|
|
292
|
-
for (const stmt of statements) {
|
|
293
|
-
const dropColMatch = stmt.match(dropColumnPattern);
|
|
294
|
-
if (dropColMatch && dropColMatch[1] && dropColMatch[2]) {
|
|
295
|
-
const tableName = dropColMatch[1], columnName = dropColMatch[2];
|
|
296
|
-
if (!sqliteDb) {
|
|
297
|
-
if (earlierCreateDefinesColumn(file, tableName, columnName)) {
|
|
298
|
-
filteredStatements.push(stmt);
|
|
299
|
-
continue;
|
|
300
|
-
}
|
|
301
|
-
log.info(`Skipping DROP COLUMN "${columnName}" \u2014 no database exists yet: ${file}`);
|
|
302
|
-
modified = !0;
|
|
303
|
-
continue;
|
|
304
|
-
}
|
|
305
|
-
try {
|
|
306
|
-
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, ""), columns = sqliteDb.prepare(`PRAGMA table_info("${safeTableName}")`).all();
|
|
307
|
-
if (columns.length === 0) {
|
|
308
|
-
if (earlierCreateDefinesColumn(file, tableName, columnName)) {
|
|
309
|
-
filteredStatements.push(stmt);
|
|
310
|
-
continue;
|
|
311
|
-
}
|
|
312
|
-
log.info(`Skipping DROP COLUMN "${columnName}" \u2014 table "${tableName}" does not exist yet: ${file}`);
|
|
313
|
-
modified = !0;
|
|
314
|
-
continue;
|
|
315
|
-
}
|
|
316
|
-
if (!columns.some((col) => col.name === columnName)) {
|
|
317
|
-
log.info(`Skipping DROP COLUMN "${columnName}" from "${tableName}" \u2014 column does not exist: ${file}`);
|
|
318
|
-
modified = !0;
|
|
319
|
-
continue;
|
|
320
|
-
}
|
|
321
|
-
} catch {
|
|
322
|
-
if (earlierCreateDefinesColumn(file, tableName, columnName)) {
|
|
323
|
-
filteredStatements.push(stmt);
|
|
324
|
-
continue;
|
|
325
|
-
}
|
|
326
|
-
log.info(`Skipping DROP COLUMN "${columnName}" \u2014 table "${tableName}" not found: ${file}`);
|
|
327
|
-
modified = !0;
|
|
328
|
-
continue;
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
filteredStatements.push(stmt);
|
|
332
|
-
}
|
|
333
|
-
if (modified) {
|
|
334
|
-
if (filteredStatements.length === 0)
|
|
335
|
-
deleteMigration(file, filePath, "columns already absent from table");
|
|
336
|
-
else
|
|
337
|
-
writeFileSync(filePath, `${filteredStatements.join(`;
|
|
1
|
+
var {require}=import.meta;import{existsSync,mkdirSync,readdirSync,readFileSync,renameSync,unlinkSync,writeFileSync}from"node:fs";import{dirname,isAbsolute,join}from"node:path";import{log as _log}from"@stacksjs/logging";const log={info:(...args)=>typeof _log?.info==="function"?_log.info(...args):console.log(...args),success:(msg)=>typeof _log?.success==="function"?_log.success(msg):console.log(msg),warn:(msg)=>typeof _log?.warn==="function"?_log.warn(msg):console.warn(msg),error:(...args)=>typeof _log?.error==="function"?_log.error(...args):console.error(...args),debug:(...args)=>typeof _log?.debug==="function"?_log.debug(...args):console.debug(...args)};import{err,handleError,ok}from"@stacksjs/error-handling";import{path}from"@stacksjs/path";import{defaultModelsPath}from"./seeder";import{createQueryBuilder,executeMigration as qbExecuteMigration,generateMigration as qbGenerateMigration,resetConnection,resetDatabase as qbResetDatabase,config as qbConfig,saveMigrationSnapshot,setConfig}from"@stacksjs/query-builder";import{db,QB_SNAPSHOT_DIR}from"./utils";import{classifyConnectionError,createDatabase,describeTarget,manualCreateHint,probeTargetDatabase,resolveConnectionTarget}from"./ensure-database";import{resolveModelSources}from"./model-sources";import{frameworkManagedColumns,withoutManagedColumnDrops,withoutManagedColumnDropSql}from"./managed-columns";import{acquireMigrationLock}from"./migration-lock";import{migrateNotificationTables}from"./notification-tables";import{env as envVars}from"@stacksjs/env";import{getConnectionDefaults}from"./defaults";const databaseEnv={DB_CONNECTION:process.env.DB_CONNECTION||envVars.DB_CONNECTION,DB_DATABASE_PATH:process.env.DB_DATABASE_PATH||envVars.DB_DATABASE_PATH,DB_DATABASE:process.env.DB_DATABASE||envVars.DB_DATABASE,DB_HOST:process.env.DB_HOST||envVars.DB_HOST,DB_PORT:process.env.DB_PORT?Number(process.env.DB_PORT):envVars.DB_PORT,DB_USERNAME:process.env.DB_USERNAME||envVars.DB_USERNAME,DB_PASSWORD:process.env.DB_PASSWORD||envVars.DB_PASSWORD},dbDriver=databaseEnv.DB_CONNECTION||"sqlite",sqliteDefaults=getConnectionDefaults("sqlite",databaseEnv),mysqlDefaults=getConnectionDefaults("mysql",databaseEnv),postgresDefaults=getConnectionDefaults("postgres",databaseEnv),dbConfig={default:dbDriver,connections:{sqlite:{database:sqliteDefaults.database,prefix:""},mysql:{name:mysqlDefaults.database,host:mysqlDefaults.host,username:mysqlDefaults.username,password:mysqlDefaults.password,port:mysqlDefaults.port,prefix:""},postgres:{name:postgresDefaults.database,host:postgresDefaults.host,username:postgresDefaults.username,password:postgresDefaults.password,port:postgresDefaults.port,prefix:""}}};function sqliteDatabasePath(){const configured=dbConfig.connections.sqlite.database||"stacks.db";return isAbsolute(configured)?configured:join(process.cwd(),configured)}function getDriver(){return dbConfig.default||"sqlite"}function getDialect(){const driver=getDriver();if(driver==="sqlite"||driver==="mysql"||driver==="postgres")return driver;if(driver==="singlestore")return"mysql";if(driver==="dynamodb")throw Error("[database] DB_CONNECTION=dynamodb is not compatible with the SQL migration runner. "+"DynamoDB has no schema-migration concept \u2014 use the entity-style `dynamo.entity(...)` "+"API from @stacksjs/database directly. To run SQL migrations, set DB_CONNECTION to one of: sqlite, mysql, postgres.");throw Error(`[database] Unknown DB_CONNECTION "${driver}". Allowed values: sqlite, mysql, postgres, dynamodb.`)}function getQbDialect(){return getDriver()==="singlestore"?"singlestore":getDialect()}function configureQueryBuilder(){const dialect=getDialect(),connectionConfig=dbConfig.connections[dialect];setConfig({dialect,verbose:!1,snapshotDir:QB_SNAPSHOT_DIR,database:{database:connectionConfig?.name||connectionConfig?.database||"stacks",host:connectionConfig?.host||"localhost",port:connectionConfig?.port||(dialect==="postgres"?5432:dialect==="mysql"?3306:0),username:connectionConfig?.username||"",password:connectionConfig?.password||""}});resetConnection()}export function prepareMigrationModelsDir(){const sources=resolveModelSources();return{modelsDir:sources?.dir??path.userModelsPath(),skip:!sources}}export function sqlStatementsOf(content){const statements=[];let current="",quote=null,dollarTag=null;for(let i=0;i<content.length;i++){const char=content[i];if(dollarTag){current+=char;if(char==="$"&&content.startsWith(dollarTag,i)){current+=content.slice(i+1,i+dollarTag.length);i+=dollarTag.length-1;dollarTag=null}continue}if(quote){current+=char;if(quote==="single"&&char==="'"||quote==="double"&&char==='"')quote=null;continue}if(char==="-"&&content[i+1]==="-"){const newline=content.indexOf(`
|
|
2
|
+
`,i);if(newline===-1)break;i=newline-1;continue}const dollar=char==="$"?/^\$[A-Za-z_]*\$/.exec(content.slice(i)):null;if(dollar){dollarTag=dollar[0];current+=dollarTag;i+=dollarTag.length-1;continue}if(char==="'"){quote="single";current+=char;continue}if(char==='"'){quote="double";current+=char;continue}if(char===";"){const trimmed=current.trim();if(trimmed.length>0)statements.push(trimmed);current="";continue}current+=char}const trailing=current.trim();if(trailing.length>0)statements.push(trailing);return statements}export function orderPostgresColumnTypeChanges(sql){const lines=sql.split(`
|
|
3
|
+
`),output=[];for(const line of lines){const match=/^(\s*)ALTER\s+TABLE\s+("?[\w.]+"?)\s+ALTER\s+COLUMN\s+("?[\w]+"?)\s+TYPE\s/i.exec(line);if(match){const[,indent,table,column]=match,drop=`${indent}ALTER TABLE ${table} ALTER COLUMN ${column} DROP DEFAULT;`;if((output.length>0?output[output.length-1].trim():"")!==drop.trim())output.push(drop)}output.push(line)}return output.join(`
|
|
4
|
+
`)}export function guardPostgresEnumTypes(sql){return sql.replace(/CREATE\s+TYPE\s+("?[\w.]+"?)\s+AS\s+ENUM\s*\(([^)]*)\)/gi,(match,name,members,offset,whole)=>{if(/\bBEGIN\s*$/i.test(whole.slice(Math.max(0,offset-40),offset)))return match;return`DO $stacks$ BEGIN CREATE TYPE ${name} AS ENUM (${members}); EXCEPTION WHEN duplicate_object THEN null; END $stacks$`})}export function preprocessSqliteMigrations(){const migrationsDir=join(process.cwd(),"database","migrations");let files;try{files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql"))}catch{return}const droppedMigrations=[],skipMigration=(file,reason)=>{log.info(`Skipping migration on SQLite (${reason}): ${file}`);droppedMigrations.push(file)},deleteMigration=(file,filePath,reason)=>{log.info(`Dropping no-op migration (${reason}): ${file}`);try{unlinkSync(filePath)}catch{}droppedMigrations.push(file)},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;for(const file of files){const m=file.match(/^\d+-create-(\w+)-table\.sql$/);if(!m||!m[1])continue;const tableName=m[1],existing=createTableEarliest.get(tableName);if(!existing||file<existing)createTableEarliest.set(tableName,file)}const earlierCreateDefinesColumn=(migrationFile,table,column)=>{const createFile=createTableEarliest.get(table);if(!createFile||createFile>=migrationFile)return!1;try{const createContent=readFileSync(join(migrationsDir,createFile),"utf8"),createStatement=sqlStatementsOf(createContent).find((statement)=>statement.match(createTablePattern)?.[1]===table);if(!createStatement)return!1;const escapedColumn=column.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return new RegExp(`(?:^|[,(])\\s*["'\`]?${escapedColumn}["'\`]?\\s+`,"i").test(createStatement)}catch{return!1}},sqliteDbPath=sqliteDatabasePath();let sqliteDb=null;if(existsSync(sqliteDbPath))try{const{Database}=require("bun:sqlite");sqliteDb=new Database(sqliteDbPath,{readonly:!0})}catch{}const migrationWasRecorded=(file)=>{if(!sqliteDb)return!1;try{return Boolean(sqliteDb.prepare("SELECT 1 FROM migrations WHERE migration = ? LIMIT 1").get(file))}catch{return!1}};for(const file of files){log.debug(`[migration] Running: ${file}`);const filePath=join(migrationsDir,file),content=readFileSync(filePath,"utf-8"),statements=sqlStatementsOf(content);if(statements.length===0)continue;const uniqueIndexNames=statements.map((s)=>s.match(createUniqueIndexPattern)?.[1]).filter((name)=>Boolean(name));if(sqliteDb&&uniqueIndexNames.length===statements.length){const indexExists=sqliteDb.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?");if(uniqueIndexNames.filter((name)=>!indexExists.get(name)).length>0){log.info(`Re-queueing unique-index migration (index missing from database): ${file}`);replayMigrations.push(file)}continue}if(migrationWasRecorded(file))continue;const firstStatement=statements[0],createTableMatch=firstStatement?firstStatement.match(createTablePattern):null;if(createTableMatch&&createTableMatch[1]){const tableName=createTableMatch[1],earliest=createTableEarliest.get(tableName);if(earliest&&earliest!==file){deleteMigration(file,filePath,`duplicate create-table for "${tableName}" (kept ${earliest})`);continue}}if(statements.every((s)=>addConstraintPattern.test(s))){skipMigration(file,"SQLite does not support ALTER TABLE ADD CONSTRAINT");continue}if(statements.every((s)=>createTypePattern.test(s))){skipMigration(file,"SQLite does not support CREATE TYPE (enum types)");continue}const addColumnTargets=statements.map((s)=>s.match(addColumnPattern)).filter((m)=>Boolean(m?.[1]&&m[2])).map((m)=>({table:m[1],column:m[2]}));if(addColumnTargets.length>0&&addColumnTargets.length===statements.length){const satisfied=addColumnTargets.filter(({table,column})=>{try{if(sqliteDb){const safeTableName=table.replace(/[^a-zA-Z0-9_]/g,""),columns=sqliteDb.prepare(`PRAGMA table_info("${safeTableName}")`).all();if(columns.some((col)=>col.name===column))return!0;if(columns.length>0)return!1}return earlierCreateDefinesColumn(file,table,column)}catch{return!1}});if(satisfied.length===addColumnTargets.length){skipMigration(file,"every column it adds already exists or is defined by an earlier create-table migration");continue}if(satisfied.length>0)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.`)}if(statements.some((s)=>dropColumnPattern.test(s))){let modified=!1;const filteredStatements=[];for(const stmt of statements){const dropColMatch=stmt.match(dropColumnPattern);if(dropColMatch&&dropColMatch[1]&&dropColMatch[2]){const tableName=dropColMatch[1],columnName=dropColMatch[2];if(!sqliteDb){if(earlierCreateDefinesColumn(file,tableName,columnName)){filteredStatements.push(stmt);continue}log.info(`Skipping DROP COLUMN "${columnName}" \u2014 no database exists yet: ${file}`);modified=!0;continue}try{const safeTableName=tableName.replace(/[^a-zA-Z0-9_]/g,""),columns=sqliteDb.prepare(`PRAGMA table_info("${safeTableName}")`).all();if(columns.length===0){if(earlierCreateDefinesColumn(file,tableName,columnName)){filteredStatements.push(stmt);continue}log.info(`Skipping DROP COLUMN "${columnName}" \u2014 table "${tableName}" does not exist yet: ${file}`);modified=!0;continue}if(!columns.some((col)=>col.name===columnName)){log.info(`Skipping DROP COLUMN "${columnName}" from "${tableName}" \u2014 column does not exist: ${file}`);modified=!0;continue}}catch{if(earlierCreateDefinesColumn(file,tableName,columnName)){filteredStatements.push(stmt);continue}log.info(`Skipping DROP COLUMN "${columnName}" \u2014 table "${tableName}" not found: ${file}`);modified=!0;continue}}filteredStatements.push(stmt)}if(modified){if(filteredStatements.length===0)deleteMigration(file,filePath,"columns already absent from table");else writeFileSync(filePath,`${filteredStatements.join(`;
|
|
338
5
|
`)};
|
|
339
|
-
`);
|
|
340
|
-
continue;
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
if (sqliteDb)
|
|
345
|
-
try {
|
|
346
|
-
sqliteDb.close();
|
|
347
|
-
} catch {}
|
|
348
|
-
if (droppedMigrations.length > 0 || replayMigrations.length > 0)
|
|
349
|
-
try {
|
|
350
|
-
const dbPath = sqliteDatabasePath();
|
|
351
|
-
mkdirSync(dirname(dbPath), { recursive: !0 });
|
|
352
|
-
const { Database } = require("bun:sqlite"), writeDb = new Database(dbPath);
|
|
353
|
-
try {
|
|
354
|
-
writeDb.exec(`CREATE TABLE IF NOT EXISTS migrations (
|
|
6
|
+
`);continue}}}if(sqliteDb)try{sqliteDb.close()}catch{}if(droppedMigrations.length>0||replayMigrations.length>0)try{const dbPath=sqliteDatabasePath();mkdirSync(dirname(dbPath),{recursive:!0});const{Database}=require("bun:sqlite"),writeDb=new Database(dbPath);try{writeDb.exec(`CREATE TABLE IF NOT EXISTS migrations (
|
|
355
7
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
356
8
|
migration TEXT NOT NULL UNIQUE,
|
|
357
9
|
executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
358
|
-
)`);
|
|
359
|
-
|
|
360
|
-
for (const migration of droppedMigrations)
|
|
361
|
-
insert.run(migration);
|
|
362
|
-
const unrecord = writeDb.prepare("DELETE FROM migrations WHERE migration = ?");
|
|
363
|
-
for (const migration of replayMigrations)
|
|
364
|
-
unrecord.run(migration);
|
|
365
|
-
} finally {
|
|
366
|
-
writeDb.close();
|
|
367
|
-
}
|
|
368
|
-
} catch (e) {
|
|
369
|
-
log.debug(`[migration] Could not record dropped migrations as executed: ${e}`);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
function mayCreateMissingDatabase() {
|
|
373
|
-
const signal = process.env.STACKS_CREATE_DATABASE;
|
|
374
|
-
if (signal === "1")
|
|
375
|
-
return !0;
|
|
376
|
-
if (signal === "0")
|
|
377
|
-
return !1;
|
|
378
|
-
const policy = String(process.env.DB_CREATE_DATABASE || "").toLowerCase();
|
|
379
|
-
return !(policy === "never" || policy === "false" || policy === "0");
|
|
380
|
-
}
|
|
381
|
-
function describeProbeFailure(target, kind, error) {
|
|
382
|
-
const where = describeTarget(target), detail = error instanceof Error ? error.message : String(error ?? "");
|
|
383
|
-
switch (kind) {
|
|
384
|
-
case "missing-role":
|
|
385
|
-
return `The user "${target.username}" does not exist on ${where}. Set DB_USERNAME to a role that exists, or create it with: createuser -s ${target.username}`;
|
|
386
|
-
case "auth-failed":
|
|
387
|
-
return `Authentication failed for user "${target.username}" on ${where}. Check DB_USERNAME and DB_PASSWORD.`;
|
|
388
|
-
case "server-unreachable":
|
|
389
|
-
return `Could not reach the database server at ${target.host}:${target.port}. Check that it is running and that DB_HOST and DB_PORT are correct.`;
|
|
390
|
-
case "timeout":
|
|
391
|
-
return `Timed out connecting to the database server at ${target.host}:${target.port}. ${detail}`;
|
|
392
|
-
case "permission-denied":
|
|
393
|
-
return `The user "${target.username}" is not allowed to connect to "${target.database}" on ${where}.`;
|
|
394
|
-
default:
|
|
395
|
-
return `Could not connect to the database "${target.database}" on ${where}. ${detail}`;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
async function ensureDatabaseExists() {
|
|
399
|
-
const target = resolveConnectionTarget();
|
|
400
|
-
if (!target)
|
|
401
|
-
return;
|
|
402
|
-
const probe = await probeTargetDatabase(target);
|
|
403
|
-
if (probe.ok)
|
|
404
|
-
return;
|
|
405
|
-
if (probe.kind !== "missing-database")
|
|
406
|
-
throw Error(describeProbeFailure(target, probe.kind, probe.error));
|
|
407
|
-
if (!mayCreateMissingDatabase())
|
|
408
|
-
throw Error(`The database "${target.database}" does not exist on ${describeTarget(target)}. Create it with: ${manualCreateHint(target)}`);
|
|
409
|
-
const result = await createDatabase(target);
|
|
410
|
-
if (!result.created && result.error)
|
|
411
|
-
throw Error(`The database "${target.database}" does not exist on ${describeTarget(target)} and it could not be created automatically. ${describeProbeFailure(target, result.kind, result.error)}
|
|
412
|
-
Create it yourself with: ${manualCreateHint(target)}`);
|
|
413
|
-
if (result.created)
|
|
414
|
-
log.success(`Created database "${target.database}" on ${target.host}:${target.port}`);
|
|
415
|
-
}
|
|
416
|
-
let databaseBootstrapped = !1;
|
|
417
|
-
export async function ensureDatabaseReady() {
|
|
418
|
-
if (databaseBootstrapped)
|
|
419
|
-
return;
|
|
420
|
-
await ensureDatabaseExists();
|
|
421
|
-
databaseBootstrapped = !0;
|
|
422
|
-
}
|
|
423
|
-
export function resetDatabaseBootstrapCache() {
|
|
424
|
-
databaseBootstrapped = !1;
|
|
425
|
-
}
|
|
426
|
-
async function hideDisabledFeatureMigrations() {
|
|
427
|
-
const hidden = [];
|
|
428
|
-
try {
|
|
429
|
-
const { appModelClaimsTable, FEATURE_NAMES, migrationFeature, migrationTable } = await import("@stacksjs/buddy"), { feature: isFeatureEnabled } = await import("@stacksjs/config"), fs = await import("node:fs/promises"), migrationsDir = path.projectPath("database/migrations");
|
|
430
|
-
if (!existsSync(migrationsDir))
|
|
431
|
-
return hidden;
|
|
432
|
-
const disabledFeatures = new Set(FEATURE_NAMES.filter((f) => !isFeatureEnabled(f)));
|
|
433
|
-
if (disabledFeatures.size === 0)
|
|
434
|
-
return hidden;
|
|
435
|
-
const files = readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")), gatedTables = new Set;
|
|
436
|
-
for (const file of files) {
|
|
437
|
-
const owner = migrationFeature(file);
|
|
438
|
-
if (!owner || !disabledFeatures.has(owner))
|
|
439
|
-
continue;
|
|
440
|
-
const table = migrationTable(file);
|
|
441
|
-
if (table && appModelClaimsTable(table))
|
|
442
|
-
continue;
|
|
443
|
-
if (table)
|
|
444
|
-
gatedTables.add(table.toLowerCase());
|
|
445
|
-
const original = join(migrationsDir, file), hiddenPath = `${original}.disabled`;
|
|
446
|
-
await fs.rename(original, hiddenPath);
|
|
447
|
-
hidden.push({ original, hidden: hiddenPath, feature: owner });
|
|
448
|
-
}
|
|
449
|
-
for (const file of files) {
|
|
450
|
-
const filePath = join(migrationsDir, file);
|
|
451
|
-
if (!existsSync(filePath))
|
|
452
|
-
continue;
|
|
453
|
-
const sql = readFileSync(filePath, "utf8"), filtered = withoutGatedStatements(sql, gatedTables);
|
|
454
|
-
if (filtered === sql)
|
|
455
|
-
continue;
|
|
456
|
-
const backup = `${filePath}.ungated`;
|
|
457
|
-
await fs.rename(filePath, backup);
|
|
458
|
-
writeFileSync(filePath, filtered);
|
|
459
|
-
hidden.push({ original: filePath, hidden: backup, feature: "mixed" });
|
|
460
|
-
}
|
|
461
|
-
if (hidden.length > 0) {
|
|
462
|
-
const summary = Object.entries(hidden.reduce((acc, h) => {
|
|
463
|
-
acc[h.feature] = (acc[h.feature] ?? 0) + 1;
|
|
464
|
-
return acc;
|
|
465
|
-
}, {})).map(([f, n]) => `${f}: ${n}`).join(", ");
|
|
466
|
-
log.info(`[migration] Skipping ${hidden.length} migration(s) for disabled features (${summary}). Run \`./buddy <feature>:install\` to enable.`);
|
|
467
|
-
}
|
|
468
|
-
} catch {}
|
|
469
|
-
return hidden;
|
|
470
|
-
}
|
|
471
|
-
export function statementTable(statement) {
|
|
472
|
-
const patterns = [
|
|
473
|
-
/^\s*ALTER\s+TABLE\s+["`]?([a-z0-9_]+)["`]?/i,
|
|
474
|
-
/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?([a-z0-9_]+)["`]?/i,
|
|
475
|
-
/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["`]?([a-z0-9_]+)["`]?/i,
|
|
476
|
-
/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?[a-z0-9_]+["`]?\s+ON\s+["`]?([a-z0-9_]+)["`]?/i
|
|
477
|
-
];
|
|
478
|
-
for (const pattern of patterns) {
|
|
479
|
-
const match = pattern.exec(statement);
|
|
480
|
-
if (match)
|
|
481
|
-
return match[1].toLowerCase();
|
|
482
|
-
}
|
|
483
|
-
return null;
|
|
484
|
-
}
|
|
485
|
-
export function withoutGatedStatements(sql, gated) {
|
|
486
|
-
if (gated.size === 0)
|
|
487
|
-
return sql;
|
|
488
|
-
const statements = sql.split(";").map((s) => s.trim()).filter(Boolean), kept = statements.filter((statement) => {
|
|
489
|
-
const table = statementTable(statement);
|
|
490
|
-
return !table || !gated.has(table);
|
|
491
|
-
});
|
|
492
|
-
if (kept.length === statements.length)
|
|
493
|
-
return sql;
|
|
494
|
-
return kept.length === 0 ? "" : `${kept.join(`;
|
|
10
|
+
)`);const insert=writeDb.prepare("INSERT OR IGNORE INTO migrations (migration) VALUES (?)");for(const migration of droppedMigrations)insert.run(migration);const unrecord=writeDb.prepare("DELETE FROM migrations WHERE migration = ?");for(const migration of replayMigrations)unrecord.run(migration)}finally{writeDb.close()}}catch(e){log.debug(`[migration] Could not record dropped migrations as executed: ${e}`)}}function mayCreateMissingDatabase(){const signal=process.env.STACKS_CREATE_DATABASE;if(signal==="1")return!0;if(signal==="0")return!1;const policy=String(process.env.DB_CREATE_DATABASE||"").toLowerCase();return!(policy==="never"||policy==="false"||policy==="0")}function describeProbeFailure(target,kind,error){const where=describeTarget(target),detail=error instanceof Error?error.message:String(error??"");switch(kind){case"missing-role":return`The user "${target.username}" does not exist on ${where}. Set DB_USERNAME to a role that exists, or create it with: createuser -s ${target.username}`;case"auth-failed":return`Authentication failed for user "${target.username}" on ${where}. Check DB_USERNAME and DB_PASSWORD.`;case"server-unreachable":return`Could not reach the database server at ${target.host}:${target.port}. Check that it is running and that DB_HOST and DB_PORT are correct.`;case"timeout":return`Timed out connecting to the database server at ${target.host}:${target.port}. ${detail}`;case"permission-denied":return`The user "${target.username}" is not allowed to connect to "${target.database}" on ${where}.`;default:return`Could not connect to the database "${target.database}" on ${where}. ${detail}`}}async function ensureDatabaseExists(){const target=resolveConnectionTarget();if(!target)return;const probe=await probeTargetDatabase(target);if(probe.ok)return;if(probe.kind!=="missing-database")throw Error(describeProbeFailure(target,probe.kind,probe.error));if(!mayCreateMissingDatabase())throw Error(`The database "${target.database}" does not exist on ${describeTarget(target)}. Create it with: ${manualCreateHint(target)}`);const result=await createDatabase(target);if(!result.created&&result.error)throw Error(`The database "${target.database}" does not exist on ${describeTarget(target)} and it could not be created automatically. ${describeProbeFailure(target,result.kind,result.error)}
|
|
11
|
+
Create it yourself with: ${manualCreateHint(target)}`);if(result.created)log.success(`Created database "${target.database}" on ${target.host}:${target.port}`)}let databaseBootstrapped=!1;export async function ensureDatabaseReady(){if(databaseBootstrapped)return;await ensureDatabaseExists();databaseBootstrapped=!0}export function resetDatabaseBootstrapCache(){databaseBootstrapped=!1}async function hideDisabledFeatureMigrations(){const hidden=[];try{const{appModelClaimsTable,FEATURE_NAMES,migrationFeature,migrationTable}=await import("@stacksjs/buddy"),{feature:isFeatureEnabled}=await import("@stacksjs/config"),fs=await import("node:fs/promises"),migrationsDir=path.projectPath("database/migrations");if(!existsSync(migrationsDir))return hidden;const disabledFeatures=new Set(FEATURE_NAMES.filter((f)=>!isFeatureEnabled(f)));if(disabledFeatures.size===0)return hidden;const files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql")),gatedTables=new Set;for(const file of files){const owner=migrationFeature(file);if(!owner||!disabledFeatures.has(owner))continue;const table=migrationTable(file);if(table&&appModelClaimsTable(table))continue;if(table)gatedTables.add(table.toLowerCase());const original=join(migrationsDir,file),hiddenPath=`${original}.disabled`;await fs.rename(original,hiddenPath);hidden.push({original,hidden:hiddenPath,feature:owner})}for(const file of files){const filePath=join(migrationsDir,file);if(!existsSync(filePath))continue;const sql=readFileSync(filePath,"utf8"),filtered=withoutGatedStatements(sql,gatedTables);if(filtered===sql)continue;const backup=`${filePath}.ungated`;await fs.rename(filePath,backup);writeFileSync(filePath,filtered);hidden.push({original:filePath,hidden:backup,feature:"mixed"})}if(hidden.length>0){const summary=Object.entries(hidden.reduce((acc,h)=>{acc[h.feature]=(acc[h.feature]??0)+1;return acc},{})).map(([f,n])=>`${f}: ${n}`).join(", ");log.info(`[migration] Skipping ${hidden.length} migration(s) for disabled features (${summary}). Run \`./buddy <feature>:install\` to enable.`)}}catch{}return hidden}export function statementTable(statement){const patterns=[/^\s*ALTER\s+TABLE\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?([a-z0-9_]+)["`]?/i,/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["`]?([a-z0-9_]+)["`]?/i,/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?[a-z0-9_]+["`]?\s+ON\s+["`]?([a-z0-9_]+)["`]?/i];for(const pattern of patterns){const match=pattern.exec(statement);if(match)return match[1].toLowerCase()}return null}export function withoutGatedStatements(sql,gated){if(gated.size===0)return sql;const statements=sql.split(";").map((s)=>s.trim()).filter(Boolean),kept=statements.filter((statement)=>{const table=statementTable(statement);return!table||!gated.has(table)});if(kept.length===statements.length)return sql;return kept.length===0?"":`${kept.join(`;
|
|
495
12
|
`)};
|
|
496
|
-
`;
|
|
497
|
-
}
|
|
498
|
-
async function restoreHiddenMigrations(hidden) {
|
|
499
|
-
const fs = await import("node:fs/promises");
|
|
500
|
-
for (const { original, hidden: h } of hidden)
|
|
501
|
-
try {
|
|
502
|
-
if (h.endsWith(".ungated"))
|
|
503
|
-
await fs.rm(original, { force: !0 });
|
|
504
|
-
await fs.rename(h, original);
|
|
505
|
-
} catch {}
|
|
506
|
-
}
|
|
507
|
-
export async function countAppliedMigrations() {
|
|
508
|
-
try {
|
|
509
|
-
const row = await db.selectFrom("migrations").select((eb) => eb.fn.count("id").as("n")).executeTakeFirst();
|
|
510
|
-
if (!row)
|
|
511
|
-
return 0;
|
|
512
|
-
const n = Number(row.n ?? row.N ?? 0);
|
|
513
|
-
return Number.isFinite(n) ? n : 0;
|
|
514
|
-
} catch {
|
|
515
|
-
return 0;
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
async function writeMigrateMarker(appliedCount) {
|
|
519
|
-
try {
|
|
520
|
-
const fs = await import("node:fs/promises"), dir = path.frameworkRuntimePath();
|
|
521
|
-
await fs.mkdir(dir, { recursive: !0 });
|
|
522
|
-
const file = `${dir}/last-migrate-result.json`, body = JSON.stringify({
|
|
523
|
-
appliedCount,
|
|
524
|
-
completedAt: new Date().toISOString()
|
|
525
|
-
});
|
|
526
|
-
await fs.writeFile(file, body, "utf8");
|
|
527
|
-
} catch {}
|
|
528
|
-
}
|
|
529
|
-
function idempotentSql(sql) {
|
|
530
|
-
const stmts = sql.split(";").map((s) => s.trim()).filter(Boolean);
|
|
531
|
-
if (stmts.length === 0)
|
|
532
|
-
return sql;
|
|
533
|
-
const out = [];
|
|
534
|
-
for (const raw of stmts) {
|
|
535
|
-
const stmt = raw.replace(/\bADD\s+COLUMN\s+(?!IF\s+NOT\s+EXISTS\b)/gi, "ADD COLUMN IF NOT EXISTS "), m = /^ALTER\s+TABLE\s+("?\w+"?)\s+ADD\s+CONSTRAINT\s+("?\w+"?)/i.exec(stmt);
|
|
536
|
-
if (m) {
|
|
537
|
-
const drop = `ALTER TABLE ${m[1]} DROP CONSTRAINT IF EXISTS ${m[2]}`;
|
|
538
|
-
if ((out[out.length - 1] ?? "").toUpperCase() !== drop.toUpperCase())
|
|
539
|
-
out.push(drop);
|
|
540
|
-
}
|
|
541
|
-
out.push(stmt);
|
|
542
|
-
}
|
|
543
|
-
return `${out.join(`;
|
|
13
|
+
`}async function restoreHiddenMigrations(hidden){const fs=await import("node:fs/promises");for(const{original,hidden:h}of hidden)try{if(h.endsWith(".ungated"))await fs.rm(original,{force:!0});await fs.rename(h,original)}catch{}}export async function countAppliedMigrations(){try{const row=await db.selectFrom("migrations").select((eb)=>eb.fn.count("id").as("n")).executeTakeFirst();if(!row)return 0;const n=Number(row.n??row.N??0);return Number.isFinite(n)?n:0}catch{return 0}}async function writeMigrateMarker(appliedCount){try{const fs=await import("node:fs/promises"),dir=path.frameworkRuntimePath();await fs.mkdir(dir,{recursive:!0});const file=`${dir}/last-migrate-result.json`,body=JSON.stringify({appliedCount,completedAt:new Date().toISOString()});await fs.writeFile(file,body,"utf8")}catch{}}function idempotentSql(sql){const stmts=sql.split(";").map((s)=>s.trim()).filter(Boolean);if(stmts.length===0)return sql;const out=[];for(const raw of stmts){const stmt=raw.replace(/\bADD\s+COLUMN\s+(?!IF\s+NOT\s+EXISTS\b)/gi,"ADD COLUMN IF NOT EXISTS "),m=/^ALTER\s+TABLE\s+("?\w+"?)\s+ADD\s+CONSTRAINT\s+("?\w+"?)/i.exec(stmt);if(m){const drop=`ALTER TABLE ${m[1]} DROP CONSTRAINT IF EXISTS ${m[2]}`;if((out[out.length-1]??"").toUpperCase()!==drop.toUpperCase())out.push(drop)}out.push(stmt)}return`${out.join(`;
|
|
544
14
|
`)};
|
|
545
|
-
|
|
546
|
-
}
|
|
547
|
-
function makeMigrationsIdempotent() {
|
|
548
|
-
const rewritten = [], migrationsDir = join(process.cwd(), "database", "migrations");
|
|
549
|
-
let files;
|
|
550
|
-
try {
|
|
551
|
-
files = readdirSync(migrationsDir).filter((f) => f.endsWith(".sql"));
|
|
552
|
-
} catch {
|
|
553
|
-
return;
|
|
554
|
-
}
|
|
555
|
-
for (const f of files) {
|
|
556
|
-
const p = join(migrationsDir, f);
|
|
557
|
-
let sql;
|
|
558
|
-
try {
|
|
559
|
-
sql = readFileSync(p, "utf8");
|
|
560
|
-
} catch {
|
|
561
|
-
continue;
|
|
562
|
-
}
|
|
563
|
-
if (!(/\bADD\s+(?:COLUMN|CONSTRAINT)\b/i.test(sql) || /\bCREATE\s+TYPE\b/i.test(sql) || /\bALTER\s+COLUMN\b[^\n]*\bTYPE\b/i.test(sql)))
|
|
564
|
-
continue;
|
|
565
|
-
const next = orderPostgresColumnTypeChanges(guardPostgresEnumTypes(idempotentSql(sql)));
|
|
566
|
-
if (next !== sql)
|
|
567
|
-
try {
|
|
568
|
-
writeFileSync(p, next);
|
|
569
|
-
rewritten.push(f);
|
|
570
|
-
} catch {}
|
|
571
|
-
}
|
|
572
|
-
if (rewritten.length > 0)
|
|
573
|
-
log.warn(`[migration] Rewrote ${rewritten.length} migration file(s) on disk to be idempotent: ${rewritten.slice(0, 3).join(", ")}${rewritten.length > 3 ? `, +${rewritten.length - 3} more` : ""}. These files are tracked in git, so this shows up as a working-tree change.`);
|
|
574
|
-
}
|
|
575
|
-
export async function runDatabaseMigration() {
|
|
576
|
-
const startedAt = Date.now(), hidden = await hideDisabledFeatureMigrations();
|
|
577
|
-
let lockHandle = null;
|
|
578
|
-
try {
|
|
579
|
-
log.debug("Migrating database...");
|
|
580
|
-
await ensureDatabaseReady();
|
|
581
|
-
configureQueryBuilder();
|
|
582
|
-
const notificationTables = await migrateNotificationTables();
|
|
583
|
-
if (!notificationTables.success)
|
|
584
|
-
throw Error(notificationTables.error || "Failed to prepare notification tables");
|
|
585
|
-
const dialect = getDialect(), lockDb = dialect === "sqlite" ? null : createQueryBuilder();
|
|
586
|
-
lockHandle = await acquireMigrationLock(dialect, lockDb);
|
|
587
|
-
if (dialect === "sqlite")
|
|
588
|
-
preprocessSqliteMigrations();
|
|
589
|
-
else if (dialect === "postgres")
|
|
590
|
-
makeMigrationsIdempotent();
|
|
591
|
-
const modelsDir = path.userModelsPath(), appliedBefore = await countAppliedMigrations();
|
|
592
|
-
log.debug(`[migration] Running migrations from: ${modelsDir}`);
|
|
593
|
-
await qbExecuteMigration(modelsDir);
|
|
594
|
-
const appliedAfter = await countAppliedMigrations(), appliedCount = Math.max(0, appliedAfter - appliedBefore);
|
|
595
|
-
await writeMigrateMarker(appliedCount);
|
|
596
|
-
log.debug(`Database migration completed in ${Date.now() - startedAt}ms (applied ${appliedCount}).`);
|
|
597
|
-
return ok(appliedCount === 0 ? "Nothing to migrate." : `Applied ${appliedCount} migration${appliedCount === 1 ? "" : "s"}.`);
|
|
598
|
-
} catch (error) {
|
|
599
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
600
|
-
log.error(`[migration] Failed after ${Date.now() - startedAt}ms: ${detail}`);
|
|
601
|
-
log.info("[migration] Run `./buddy migrate:fresh` to drop and recreate the schema if state is partial.");
|
|
602
|
-
return err(handleError("Migration failed", error));
|
|
603
|
-
} finally {
|
|
604
|
-
if (lockHandle)
|
|
605
|
-
try {
|
|
606
|
-
await lockHandle.release();
|
|
607
|
-
} catch {}
|
|
608
|
-
await restoreHiddenMigrations(hidden);
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
const FRAMEWORK_TABLES = [
|
|
612
|
-
"oauth_refresh_tokens",
|
|
613
|
-
"oauth_access_tokens",
|
|
614
|
-
"oauth_clients",
|
|
615
|
-
"passkeys",
|
|
616
|
-
"failed_jobs",
|
|
617
|
-
"jobs",
|
|
618
|
-
"notifications",
|
|
619
|
-
"password_reset_tokens"
|
|
620
|
-
];
|
|
621
|
-
export async function resetDatabase() {
|
|
622
|
-
try {
|
|
623
|
-
await ensureDatabaseReady();
|
|
624
|
-
configureQueryBuilder();
|
|
625
|
-
const modelsDir = path.userModelsPath(), dialect = getDialect();
|
|
626
|
-
await dropFrameworkTables(dialect);
|
|
627
|
-
if (existsSync(modelsDir))
|
|
628
|
-
await qbResetDatabase(modelsDir, { dialect });
|
|
629
|
-
else
|
|
630
|
-
log.debug(`No models directory at ${modelsDir}; skipping model table drops.`);
|
|
631
|
-
const defaultsDir = defaultModelsPath();
|
|
632
|
-
if (existsSync(defaultsDir))
|
|
633
|
-
await qbResetDatabase(defaultsDir, { dialect });
|
|
634
|
-
else
|
|
635
|
-
log.debug(`No framework default models directory at ${defaultsDir}; skipping.`);
|
|
636
|
-
if (dialect === "postgres")
|
|
637
|
-
await dropOrphanedEnumTypes();
|
|
638
|
-
return ok("All tables dropped successfully!");
|
|
639
|
-
} catch (error) {
|
|
640
|
-
return err(handleError("Database reset failed", error));
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
async function dropOrphanedEnumTypes() {
|
|
644
|
-
try {
|
|
645
|
-
const rows = await db.unsafe(`
|
|
15
|
+
`}function makeMigrationsIdempotent(){const rewritten=[],migrationsDir=join(process.cwd(),"database","migrations");let files;try{files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql"))}catch{return}for(const f of files){const p=join(migrationsDir,f);let sql;try{sql=readFileSync(p,"utf8")}catch{continue}if(!(/\bADD\s+(?:COLUMN|CONSTRAINT)\b/i.test(sql)||/\bCREATE\s+TYPE\b/i.test(sql)||/\bALTER\s+COLUMN\b[^\n]*\bTYPE\b/i.test(sql)))continue;const next=orderPostgresColumnTypeChanges(guardPostgresEnumTypes(idempotentSql(sql)));if(next!==sql)try{writeFileSync(p,next);rewritten.push(f)}catch{}}if(rewritten.length>0)log.warn(`[migration] Rewrote ${rewritten.length} migration file(s) on disk to be idempotent: ${rewritten.slice(0,3).join(", ")}${rewritten.length>3?`, +${rewritten.length-3} more`:""}. These files are tracked in git, so this shows up as a working-tree change.`)}export async function runDatabaseMigration(){const startedAt=Date.now(),hidden=await hideDisabledFeatureMigrations();let lockHandle=null;try{log.debug("Migrating database...");await ensureDatabaseReady();configureQueryBuilder();const notificationTables=await migrateNotificationTables();if(!notificationTables.success)throw Error(notificationTables.error||"Failed to prepare notification tables");const dialect=getDialect(),lockDb=dialect==="sqlite"?null:createQueryBuilder();lockHandle=await acquireMigrationLock(dialect,lockDb);if(dialect==="sqlite")preprocessSqliteMigrations();else if(dialect==="postgres")makeMigrationsIdempotent();const modelsDir=path.userModelsPath(),appliedBefore=await countAppliedMigrations();log.debug(`[migration] Running migrations from: ${modelsDir}`);await qbExecuteMigration(modelsDir);const appliedAfter=await countAppliedMigrations(),appliedCount=Math.max(0,appliedAfter-appliedBefore);await writeMigrateMarker(appliedCount);log.debug(`Database migration completed in ${Date.now()-startedAt}ms (applied ${appliedCount}).`);return ok(appliedCount===0?"Nothing to migrate.":`Applied ${appliedCount} migration${appliedCount===1?"":"s"}.`)}catch(error){const detail=error instanceof Error?error.message:String(error);log.error(`[migration] Failed after ${Date.now()-startedAt}ms: ${detail}`);log.info("[migration] Run `./buddy migrate:fresh` to drop and recreate the schema if state is partial.");return err(handleError("Migration failed",error))}finally{if(lockHandle)try{await lockHandle.release()}catch{}await restoreHiddenMigrations(hidden)}}const FRAMEWORK_TABLES=["oauth_refresh_tokens","oauth_access_tokens","oauth_clients","passkeys","failed_jobs","jobs","notifications","password_reset_tokens"];export async function resetDatabase(){try{await ensureDatabaseReady();configureQueryBuilder();const modelsDir=path.userModelsPath(),dialect=getDialect();await dropFrameworkTables(dialect);if(existsSync(modelsDir))await qbResetDatabase(modelsDir,{dialect});else log.debug(`No models directory at ${modelsDir}; skipping model table drops.`);const defaultsDir=defaultModelsPath();if(existsSync(defaultsDir))await qbResetDatabase(defaultsDir,{dialect});else log.debug(`No framework default models directory at ${defaultsDir}; skipping.`);if(dialect==="postgres")await dropOrphanedEnumTypes();return ok("All tables dropped successfully!")}catch(error){return err(handleError("Database reset failed",error))}}async function dropOrphanedEnumTypes(){try{const rows=await db.unsafe(`
|
|
646
16
|
SELECT t.typname AS name
|
|
647
17
|
FROM pg_type t
|
|
648
18
|
JOIN pg_namespace n ON n.oid = t.typnamespace
|
|
@@ -653,485 +23,12 @@ async function dropOrphanedEnumTypes() {
|
|
|
653
23
|
JOIN pg_class c ON c.oid = a.attrelid
|
|
654
24
|
WHERE a.atttypid = t.oid AND c.relkind = 'r' AND NOT a.attisdropped
|
|
655
25
|
)
|
|
656
|
-
`).execute(), names = (Array.isArray(rows) ? rows : rows?.rows ?? []).map((row) => String(row.name ?? row.typname ?? "")).filter(Boolean);
|
|
657
|
-
for (const name of names)
|
|
658
|
-
try {
|
|
659
|
-
await db.unsafe(`DROP TYPE IF EXISTS "${name}" CASCADE`).execute();
|
|
660
|
-
log.debug(`Dropped orphaned enum type: ${name}`);
|
|
661
|
-
} catch (error) {
|
|
662
|
-
log.warn(`Could not drop enum type ${name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
663
|
-
}
|
|
664
|
-
} catch (error) {
|
|
665
|
-
log.warn(`Could not list enum types to drop: ${error instanceof Error ? error.message : String(error)}`);
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
async function dropFrameworkTables(dialect) {
|
|
669
|
-
if (dialect === "mysql")
|
|
670
|
-
try {
|
|
671
|
-
await db.unsafe("SET FOREIGN_KEY_CHECKS = 0").execute();
|
|
672
|
-
} catch (error) {
|
|
673
|
-
log.warn(`Could not disable foreign key checks: ${error instanceof Error ? error.message : String(error)}`);
|
|
674
|
-
}
|
|
675
|
-
if (dialect === "sqlite")
|
|
676
|
-
try {
|
|
677
|
-
await db.unsafe("PRAGMA foreign_keys = OFF").execute();
|
|
678
|
-
} catch (error) {
|
|
679
|
-
log.warn(`Could not disable foreign key checks: ${error instanceof Error ? error.message : String(error)}`);
|
|
680
|
-
}
|
|
681
|
-
for (const tableName of FRAMEWORK_TABLES)
|
|
682
|
-
try {
|
|
683
|
-
let dropSql;
|
|
684
|
-
if (dialect === "postgres")
|
|
685
|
-
dropSql = `DROP TABLE IF EXISTS "${tableName}" CASCADE`;
|
|
686
|
-
else if (dialect === "mysql")
|
|
687
|
-
dropSql = `DROP TABLE IF EXISTS \`${tableName}\``;
|
|
688
|
-
else
|
|
689
|
-
dropSql = `DROP TABLE IF EXISTS "${tableName}"`;
|
|
690
|
-
log.info(`Dropping framework table: ${tableName}`);
|
|
691
|
-
await db.unsafe(dropSql).execute();
|
|
692
|
-
log.info(`Dropped framework table: ${tableName}`);
|
|
693
|
-
} catch (error) {
|
|
694
|
-
const kind = classifyConnectionError(error);
|
|
695
|
-
if (kind === "missing-database" || kind === "missing-role" || kind === "auth-failed" || kind === "server-unreachable" || kind === "timeout")
|
|
696
|
-
throw error;
|
|
697
|
-
log.warn(`Could not drop table ${tableName}: ${error instanceof Error ? error.message : String(error)}`);
|
|
698
|
-
}
|
|
699
|
-
if (dialect === "mysql")
|
|
700
|
-
try {
|
|
701
|
-
await db.unsafe("SET FOREIGN_KEY_CHECKS = 1").execute();
|
|
702
|
-
} catch (error) {
|
|
703
|
-
log.warn(`Could not re-enable foreign key checks: ${error instanceof Error ? error.message : String(error)}`);
|
|
704
|
-
}
|
|
705
|
-
if (dialect === "sqlite")
|
|
706
|
-
try {
|
|
707
|
-
await db.unsafe("PRAGMA foreign_keys = ON").execute();
|
|
708
|
-
} catch (error) {
|
|
709
|
-
log.warn(`Could not re-enable foreign key checks: ${error instanceof Error ? error.message : String(error)}`);
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
function resolveGenerateOptions(options) {
|
|
713
|
-
const applyRenames = options.applyRenames ?? (process.env.STACKS_MIGRATE_NO_RENAME === "1" ? !1 : void 0), fromDb = options.fromDb ?? (process.env.STACKS_MIGRATE_FROM_DB === "1" ? !0 : void 0);
|
|
714
|
-
return { applyRenames, fromDb };
|
|
715
|
-
}
|
|
716
|
-
export async function previewPendingMigrations(options = {}) {
|
|
717
|
-
try {
|
|
718
|
-
configureQueryBuilder();
|
|
719
|
-
const dialect = getDialect(), { modelsDir, skip } = prepareMigrationModelsDir();
|
|
720
|
-
if (skip)
|
|
721
|
-
return [];
|
|
722
|
-
const { applyRenames, fromDb } = resolveGenerateOptions(options), operations = (await qbGenerateMigration(modelsDir, { dialect: getQbDialect(), dryRun: !0, applyRenames, fromDb })).operations ?? [];
|
|
723
|
-
if (!operations.some((op) => op.kind === "drop_column"))
|
|
724
|
-
return operations;
|
|
725
|
-
return withoutManagedColumnDrops(operations, await frameworkManagedColumns());
|
|
726
|
-
} catch (error) {
|
|
727
|
-
log.debug(`[migration] preview failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
728
|
-
return [];
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
function snapshotDirLabel() {
|
|
732
|
-
return qbConfig?.snapshotDir || QB_SNAPSHOT_DIR;
|
|
733
|
-
}
|
|
734
|
-
function resolveSnapshotDir() {
|
|
735
|
-
return join(process.cwd(), snapshotDirLabel());
|
|
736
|
-
}
|
|
737
|
-
function snapshotPathFor(dialect) {
|
|
738
|
-
return join(resolveSnapshotDir(), `model-snapshot.${dialect}.json`);
|
|
739
|
-
}
|
|
740
|
-
function readStoredMigrationPlan(dialect) {
|
|
741
|
-
const snapshotPath = snapshotPathFor(dialect);
|
|
742
|
-
if (!existsSync(snapshotPath))
|
|
743
|
-
return;
|
|
744
|
-
let parsed;
|
|
745
|
-
try {
|
|
746
|
-
parsed = JSON.parse(readFileSync(snapshotPath, "utf8"));
|
|
747
|
-
} catch (error) {
|
|
748
|
-
throw Error(`The migration snapshot is not valid JSON: ${snapshotPath}. Repair or regenerate it before creating migrations.`, { cause: error });
|
|
749
|
-
}
|
|
750
|
-
const candidate = parsed && typeof parsed === "object" && "plan" in parsed ? parsed.plan : parsed;
|
|
751
|
-
if (!candidate || typeof candidate !== "object" || !Array.isArray(candidate.tables))
|
|
752
|
-
throw TypeError(`The migration snapshot has an invalid structure: ${snapshotPath}. Repair or regenerate it before creating migrations.`);
|
|
753
|
-
return candidate;
|
|
754
|
-
}
|
|
755
|
-
export function preserveMigrationPlanTableOrder(next, previous) {
|
|
756
|
-
const orderedByPrevious = (values, previousValues, keyFor) => {
|
|
757
|
-
if (!previousValues)
|
|
758
|
-
return [...values];
|
|
759
|
-
const previousOrder = new Map(previousValues.map((value, index) => [keyFor(value), index])), nextOrder = new Map(values.map((value, index) => [keyFor(value), index])), previousCount = previousValues.length;
|
|
760
|
-
return [...values].sort((left, right) => {
|
|
761
|
-
const leftKey = keyFor(left), rightKey = keyFor(right), leftOrder = previousOrder.get(leftKey) ?? previousCount + nextOrder.get(leftKey), rightOrder = previousOrder.get(rightKey) ?? previousCount + nextOrder.get(rightKey);
|
|
762
|
-
return leftOrder - rightOrder;
|
|
763
|
-
});
|
|
764
|
-
}, previousTables = new Map(previous?.tables.map((table) => [table.table, table]) ?? []), tables = next.tables.map((table) => {
|
|
765
|
-
const previousTable = previousTables.get(table.table), previousColumns = new Map(previousTable?.columns.map((column) => [column.name, column]) ?? []);
|
|
766
|
-
return {
|
|
767
|
-
...table,
|
|
768
|
-
columns: orderedByPrevious(table.columns.map((column) => {
|
|
769
|
-
if (next.dialect !== "sqlite" || !column.enumTypeName || previousColumns.get(column.name)?.enumTypeName)
|
|
770
|
-
return column;
|
|
771
|
-
const portableColumn = { ...column };
|
|
772
|
-
delete portableColumn.enumTypeName;
|
|
773
|
-
return portableColumn;
|
|
774
|
-
}), previousTable?.columns, (column) => column.name),
|
|
775
|
-
indexes: orderedByPrevious(table.indexes, previousTable?.indexes, (index) => index.name)
|
|
776
|
-
};
|
|
777
|
-
});
|
|
778
|
-
return {
|
|
779
|
-
...next,
|
|
780
|
-
tables: orderedByPrevious(tables, previous?.tables, (table) => table.table)
|
|
781
|
-
};
|
|
782
|
-
}
|
|
783
|
-
function detectSnapshotDialectMismatch(dialect) {
|
|
784
|
-
const qbDir = resolveSnapshotDir();
|
|
785
|
-
let files;
|
|
786
|
-
try {
|
|
787
|
-
files = readdirSync(qbDir);
|
|
788
|
-
} catch {
|
|
789
|
-
return null;
|
|
790
|
-
}
|
|
791
|
-
const snapshotFor = (d) => `model-snapshot.${d}.json`;
|
|
792
|
-
if (files.includes(snapshotFor(dialect)))
|
|
793
|
-
return null;
|
|
794
|
-
for (const f of files) {
|
|
795
|
-
const m = /^model-snapshot\.(\w+)\.json$/.exec(f);
|
|
796
|
-
if (m?.[1] && m[1] !== dialect)
|
|
797
|
-
return m[1];
|
|
798
|
-
}
|
|
799
|
-
return null;
|
|
800
|
-
}
|
|
801
|
-
export async function generateMigrations(options = {}) {
|
|
802
|
-
try {
|
|
803
|
-
log.debug("Generating migrations...");
|
|
804
|
-
configureQueryBuilder();
|
|
805
|
-
const dialect = getDialect(), mismatch = detectSnapshotDialectMismatch(dialect);
|
|
806
|
-
if (mismatch) {
|
|
807
|
-
const snapshotDir = snapshotDirLabel();
|
|
808
|
-
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.`));
|
|
809
|
-
}
|
|
810
|
-
const storedPlan = readStoredMigrationPlan(getQbDialect()), { modelsDir, skip } = prepareMigrationModelsDir();
|
|
811
|
-
if (skip) {
|
|
812
|
-
log.debug("No app/Models directory found; using committed framework migrations");
|
|
813
|
-
return ok("Migrations generated");
|
|
814
|
-
}
|
|
815
|
-
const { applyRenames, fromDb } = resolveGenerateOptions(options);
|
|
816
|
-
log.debug(`[migration] Generating migrations for dialect: ${dialect}, models: ${modelsDir}`);
|
|
817
|
-
const result = await qbGenerateMigration(modelsDir, { dialect: getQbDialect(), dryRun: !0, applyRenames, fromDb });
|
|
818
|
-
let sqlStatements = result.sqlStatements ?? [];
|
|
819
|
-
if (dialect === "sqlite")
|
|
820
|
-
sqlStatements = inlineSqliteAddedColumnReferences(sqlStatements, result.plan);
|
|
821
|
-
if (result.hasChanges && sqlStatements.length > 0) {
|
|
822
|
-
const filtered = withoutManagedColumnDropSql(sqlStatements, await frameworkManagedColumns(), result.operations ?? []);
|
|
823
|
-
if (filtered.removed.length > 0)
|
|
824
|
-
log.debug(`[migration] Skipped ${filtered.removed.length} generated drop(s) of framework-managed column(s) (stacksjs/stacks#2075)`);
|
|
825
|
-
sqlStatements = filtered.statements;
|
|
826
|
-
}
|
|
827
|
-
if (result.hasChanges && sqlStatements.length > 0) {
|
|
828
|
-
const dangling = findDanglingTypeReferences(sqlStatements);
|
|
829
|
-
if (dangling.length > 0) {
|
|
830
|
-
const created = createMissingEnumTypes(dangling, result.plan);
|
|
831
|
-
if (created.statements.length > 0) {
|
|
832
|
-
sqlStatements = [...created.statements, ...sqlStatements];
|
|
833
|
-
log.debug(`[migration] Created ${created.statements.length} enum type(s) an ALTER needed: ${created.defined.join(", ")}`);
|
|
834
|
-
}
|
|
835
|
-
const unresolved = dangling.filter((name) => !created.defined.includes(name));
|
|
836
|
-
if (unresolved.length > 0) {
|
|
837
|
-
const before = sqlStatements.length;
|
|
838
|
-
sqlStatements = sqlStatements.filter((statement) => !referencesUndefinedType(statement, unresolved));
|
|
839
|
-
log.warn(`[migration] Skipped ${before - sqlStatements.length} generated statement(s) referencing enum type(s) nothing creates and no model defines values for (${unresolved.slice(0, 3).join(", ")}${unresolved.length > 3 ? ", \u2026" : ""}).`);
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
if (result.hasChanges) {
|
|
844
|
-
const written = persistGeneratedMigrations(sqlStatements);
|
|
845
|
-
if (written > 0)
|
|
846
|
-
log.success(`Generated ${written} migration file${written === 1 ? "" : "s"}`);
|
|
847
|
-
else
|
|
848
|
-
log.debug("Migration generation produced no new files (already up to date)");
|
|
849
|
-
} else
|
|
850
|
-
log.debug("No changes detected");
|
|
851
|
-
const stablePlan = preserveMigrationPlanTableOrder(result.plan, storedPlan);
|
|
852
|
-
if (!storedPlan || JSON.stringify(stablePlan) !== JSON.stringify(storedPlan))
|
|
853
|
-
saveMigrationSnapshot(stablePlan, { dialect: getQbDialect() });
|
|
854
|
-
else
|
|
855
|
-
log.debug("Model snapshot unchanged");
|
|
856
|
-
return ok("Migrations generated");
|
|
857
|
-
} catch (error) {
|
|
858
|
-
return err(handleError("Migration generation failed", error));
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
export function referencesUndefinedType(statement, dangling) {
|
|
862
|
-
return dangling.some((name) => statement.includes(`"${name}"`));
|
|
863
|
-
}
|
|
864
|
-
export function findDanglingTypeReferences(statements) {
|
|
865
|
-
const defined = new Set, referenced = new Set;
|
|
866
|
-
for (const statement of statements) {
|
|
867
|
-
for (const match of statement.matchAll(/CREATE\s+TYPE\s+"([^"]+)"/gi))
|
|
868
|
-
defined.add(match[1]);
|
|
869
|
-
for (const match of statement.matchAll(/\bTYPE\s+"([^"]+)"/gi))
|
|
870
|
-
referenced.add(match[1]);
|
|
871
|
-
}
|
|
872
|
-
return [...referenced].filter((name) => !defined.has(name)).sort();
|
|
873
|
-
}
|
|
874
|
-
export function createMissingEnumTypes(dangling, plan) {
|
|
875
|
-
if (dangling.length === 0)
|
|
876
|
-
return { statements: [], defined: [] };
|
|
877
|
-
const values = new Map;
|
|
878
|
-
for (const table of plan?.tables ?? []) {
|
|
879
|
-
if (!table.table)
|
|
880
|
-
continue;
|
|
881
|
-
for (const column of table.columns ?? []) {
|
|
882
|
-
if (!column.name || !column.enumValues?.length)
|
|
883
|
-
continue;
|
|
884
|
-
values.set(`${table.table}_${column.name}_type`, column.enumValues);
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
const statements = [], defined = [];
|
|
888
|
-
for (const name of dangling) {
|
|
889
|
-
const members = values.get(name);
|
|
890
|
-
if (!members?.length)
|
|
891
|
-
continue;
|
|
892
|
-
const literals = members.map((member) => `'${String(member).replaceAll("'", "''")}'`).join(", ");
|
|
893
|
-
statements.push(`${guardPostgresEnumTypes(`CREATE TYPE "${name}" AS ENUM (${literals})`)};`);
|
|
894
|
-
defined.push(name);
|
|
895
|
-
}
|
|
896
|
-
return { statements, defined };
|
|
897
|
-
}
|
|
898
|
-
export function inlineSqliteAddedColumnReferences(statements, plan) {
|
|
899
|
-
const references = new Map;
|
|
900
|
-
for (const table of plan?.tables ?? []) {
|
|
901
|
-
if (!table.table)
|
|
902
|
-
continue;
|
|
903
|
-
for (const column of table.columns ?? []) {
|
|
904
|
-
const reference = column.references;
|
|
905
|
-
if (!column.name || !reference?.table || !reference.column)
|
|
906
|
-
continue;
|
|
907
|
-
references.set(`${table.table}.${column.name}`, {
|
|
908
|
-
table: reference.table,
|
|
909
|
-
column: reference.column
|
|
910
|
-
});
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
if (references.size === 0)
|
|
914
|
-
return statements;
|
|
915
|
-
return statements.map((statement) => {
|
|
916
|
-
if (/\bREFERENCES\b/i.test(statement))
|
|
917
|
-
return statement;
|
|
918
|
-
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;
|
|
919
|
-
if (!match || !reference)
|
|
920
|
-
return statement;
|
|
921
|
-
return `${match[1]}${match[4].trimEnd()} REFERENCES "${reference.table}"("${reference.column}")${match[5]}`;
|
|
922
|
-
});
|
|
923
|
-
}
|
|
924
|
-
export async function regenerateMigrationCorpus(options = {}) {
|
|
925
|
-
try {
|
|
926
|
-
configureQueryBuilder();
|
|
927
|
-
const dialect = options.dialect ?? getQbDialect(), dir = options.dir ?? join(process.cwd(), "database", "migrations"), sources = resolveModelSources();
|
|
928
|
-
if (!sources)
|
|
929
|
-
return err(Error("No models found. Define models in app/Models, or ensure the framework defaults at storage/framework/defaults/app/Models are present."));
|
|
930
|
-
try {
|
|
931
|
-
writeFileSync(join(sources.dir, `.qb-migrations.${dialect}.json`), JSON.stringify({ plan: { dialect, tables: [] } }));
|
|
932
|
-
} catch {}
|
|
933
|
-
const snapshotPath = join(resolveSnapshotDir(), `model-snapshot.${dialect}.json`), parkedSnapshot = `${snapshotPath}.regenerating`;
|
|
934
|
-
let snapshotParked = !1;
|
|
935
|
-
if (existsSync(snapshotPath))
|
|
936
|
-
try {
|
|
937
|
-
renameSync(snapshotPath, parkedSnapshot);
|
|
938
|
-
snapshotParked = !0;
|
|
939
|
-
} catch {}
|
|
940
|
-
let result;
|
|
941
|
-
try {
|
|
942
|
-
result = await qbGenerateMigration(sources.dir, { dialect, dryRun: !0 });
|
|
943
|
-
} finally {
|
|
944
|
-
if (snapshotParked)
|
|
945
|
-
try {
|
|
946
|
-
renameSync(parkedSnapshot, snapshotPath);
|
|
947
|
-
} catch {}
|
|
948
|
-
}
|
|
949
|
-
const statements = result.sqlStatements ?? [];
|
|
950
|
-
if (statements.length === 0)
|
|
951
|
-
return err(Error(`The generator produced no SQL for dialect "${dialect}".`));
|
|
952
|
-
const dangling = findDanglingTypeReferences(statements);
|
|
953
|
-
if (dangling.length > 0)
|
|
954
|
-
return err(Error(`The generator emitted ${dangling.length} reference(s) to enum type(s) it never creates: ${dangling.slice(0, 5).join(", ")}${dangling.length > 5 ? `, +${dangling.length - 5} more` : ""}. Writing this corpus would fail partway through a migration. This is a bug in the migration generator, not in your models.`));
|
|
955
|
-
const groups = groupGeneratedStatements(statements);
|
|
956
|
-
let existing = [];
|
|
957
|
-
try {
|
|
958
|
-
existing = readdirSync(dir).filter((f) => f.endsWith(".sql")).sort();
|
|
959
|
-
} catch {}
|
|
960
|
-
const files = groups.map((group, index) => ({
|
|
961
|
-
name: `${String(index + 1).padStart(10, "0")}-${group.label}.sql`,
|
|
962
|
-
statements: group.statements.length
|
|
963
|
-
}));
|
|
964
|
-
if (options.dryRun)
|
|
965
|
-
return ok({ dialect, models: sources.models.length, files, removed: existing, dir });
|
|
966
|
-
mkdirSync(dir, { recursive: !0 });
|
|
967
|
-
for (const file of existing)
|
|
968
|
-
unlinkSync(join(dir, file));
|
|
969
|
-
groups.forEach((group, index) => {
|
|
970
|
-
const body = `${group.statements.map((s) => s.trim().replace(/;\s*$/, "")).join(`;
|
|
26
|
+
`).execute(),names=(Array.isArray(rows)?rows:rows?.rows??[]).map((row)=>String(row.name??row.typname??"")).filter(Boolean);for(const name of names)try{await db.unsafe(`DROP TYPE IF EXISTS "${name}" CASCADE`).execute();log.debug(`Dropped orphaned enum type: ${name}`)}catch(error){log.warn(`Could not drop enum type ${name}: ${error instanceof Error?error.message:String(error)}`)}}catch(error){log.warn(`Could not list enum types to drop: ${error instanceof Error?error.message:String(error)}`)}}async function dropFrameworkTables(dialect){if(dialect==="mysql")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 0").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = OFF").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}for(const tableName of FRAMEWORK_TABLES)try{let dropSql;if(dialect==="postgres")dropSql=`DROP TABLE IF EXISTS "${tableName}" CASCADE`;else if(dialect==="mysql")dropSql=`DROP TABLE IF EXISTS \`${tableName}\``;else dropSql=`DROP TABLE IF EXISTS "${tableName}"`;log.info(`Dropping framework table: ${tableName}`);await db.unsafe(dropSql).execute();log.info(`Dropped framework table: ${tableName}`)}catch(error){const kind=classifyConnectionError(error);if(kind==="missing-database"||kind==="missing-role"||kind==="auth-failed"||kind==="server-unreachable"||kind==="timeout")throw error;log.warn(`Could not drop table ${tableName}: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="mysql")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 1").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = ON").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}}function resolveGenerateOptions(options){const applyRenames=options.applyRenames??(process.env.STACKS_MIGRATE_NO_RENAME==="1"?!1:void 0),fromDb=options.fromDb??(process.env.STACKS_MIGRATE_FROM_DB==="1"?!0:void 0);return{applyRenames,fromDb}}export async function previewPendingMigrations(options={}){try{configureQueryBuilder();const dialect=getDialect(),{modelsDir,skip}=prepareMigrationModelsDir();if(skip)return[];const{applyRenames,fromDb}=resolveGenerateOptions(options),operations=(await qbGenerateMigration(modelsDir,{dialect:getQbDialect(),dryRun:!0,applyRenames,fromDb})).operations??[];if(!operations.some((op)=>op.kind==="drop_column"))return operations;return withoutManagedColumnDrops(operations,await frameworkManagedColumns())}catch(error){log.debug(`[migration] preview failed: ${error instanceof Error?error.message:String(error)}`);return[]}}function snapshotDirLabel(){return qbConfig?.snapshotDir||QB_SNAPSHOT_DIR}function resolveSnapshotDir(){return join(process.cwd(),snapshotDirLabel())}function snapshotPathFor(dialect){return join(resolveSnapshotDir(),`model-snapshot.${dialect}.json`)}function readStoredMigrationPlan(dialect){const snapshotPath=snapshotPathFor(dialect);if(!existsSync(snapshotPath))return;let parsed;try{parsed=JSON.parse(readFileSync(snapshotPath,"utf8"))}catch(error){throw Error(`The migration snapshot is not valid JSON: ${snapshotPath}. Repair or regenerate it before creating migrations.`,{cause:error})}const candidate=parsed&&typeof parsed==="object"&&"plan"in parsed?parsed.plan:parsed;if(!candidate||typeof candidate!=="object"||!Array.isArray(candidate.tables))throw TypeError(`The migration snapshot has an invalid structure: ${snapshotPath}. Repair or regenerate it before creating migrations.`);return candidate}export function preserveMigrationPlanTableOrder(next,previous){const orderedByPrevious=(values,previousValues,keyFor)=>{if(!previousValues)return[...values];const previousOrder=new Map(previousValues.map((value,index)=>[keyFor(value),index])),nextOrder=new Map(values.map((value,index)=>[keyFor(value),index])),previousCount=previousValues.length;return[...values].sort((left,right)=>{const leftKey=keyFor(left),rightKey=keyFor(right),leftOrder=previousOrder.get(leftKey)??previousCount+nextOrder.get(leftKey),rightOrder=previousOrder.get(rightKey)??previousCount+nextOrder.get(rightKey);return leftOrder-rightOrder})},previousTables=new Map(previous?.tables.map((table)=>[table.table,table])??[]),tables=next.tables.map((table)=>{const previousTable=previousTables.get(table.table),previousColumns=new Map(previousTable?.columns.map((column)=>[column.name,column])??[]);return{...table,columns:orderedByPrevious(table.columns.map((column)=>{if(next.dialect!=="sqlite"||!column.enumTypeName||previousColumns.get(column.name)?.enumTypeName)return column;const portableColumn={...column};delete portableColumn.enumTypeName;return portableColumn}),previousTable?.columns,(column)=>column.name),indexes:orderedByPrevious(table.indexes,previousTable?.indexes,(index)=>index.name)}});return{...next,tables:orderedByPrevious(tables,previous?.tables,(table)=>table.table)}}function detectSnapshotDialectMismatch(dialect){const qbDir=resolveSnapshotDir();let files;try{files=readdirSync(qbDir)}catch{return null}const snapshotFor=(d)=>`model-snapshot.${d}.json`;if(files.includes(snapshotFor(dialect)))return null;for(const f of files){const m=/^model-snapshot\.(\w+)\.json$/.exec(f);if(m?.[1]&&m[1]!==dialect)return m[1]}return null}export async function generateMigrations(options={}){try{log.debug("Generating migrations...");configureQueryBuilder();const dialect=getDialect(),mismatch=detectSnapshotDialectMismatch(dialect);if(mismatch){const snapshotDir=snapshotDirLabel();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.`))}const storedPlan=readStoredMigrationPlan(getQbDialect()),{modelsDir,skip}=prepareMigrationModelsDir();if(skip){log.debug("No app/Models directory found; using committed framework migrations");return ok("Migrations generated")}const{applyRenames,fromDb}=resolveGenerateOptions(options);log.debug(`[migration] Generating migrations for dialect: ${dialect}, models: ${modelsDir}`);const result=await qbGenerateMigration(modelsDir,{dialect:getQbDialect(),dryRun:!0,applyRenames,fromDb});let sqlStatements=result.sqlStatements??[];if(dialect==="sqlite")sqlStatements=inlineSqliteAddedColumnReferences(sqlStatements,result.plan);if(result.hasChanges&&sqlStatements.length>0){const filtered=withoutManagedColumnDropSql(sqlStatements,await frameworkManagedColumns(),result.operations??[]);if(filtered.removed.length>0)log.debug(`[migration] Skipped ${filtered.removed.length} generated drop(s) of framework-managed column(s) (stacksjs/stacks#2075)`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0){const dangling=findDanglingTypeReferences(sqlStatements);if(dangling.length>0){const created=createMissingEnumTypes(dangling,result.plan);if(created.statements.length>0){sqlStatements=[...created.statements,...sqlStatements];log.debug(`[migration] Created ${created.statements.length} enum type(s) an ALTER needed: ${created.defined.join(", ")}`)}const unresolved=dangling.filter((name)=>!created.defined.includes(name));if(unresolved.length>0){const before=sqlStatements.length;sqlStatements=sqlStatements.filter((statement)=>!referencesUndefinedType(statement,unresolved));log.warn(`[migration] Skipped ${before-sqlStatements.length} generated statement(s) referencing enum type(s) nothing creates and no model defines values for (${unresolved.slice(0,3).join(", ")}${unresolved.length>3?", \u2026":""}).`)}}}if(result.hasChanges){const written=persistGeneratedMigrations(sqlStatements);if(written>0)log.success(`Generated ${written} migration file${written===1?"":"s"}`);else log.debug("Migration generation produced no new files (already up to date)")}else log.debug("No changes detected");const stablePlan=preserveMigrationPlanTableOrder(result.plan,storedPlan);if(!storedPlan||JSON.stringify(stablePlan)!==JSON.stringify(storedPlan))saveMigrationSnapshot(stablePlan,{dialect:getQbDialect()});else log.debug("Model snapshot unchanged");return ok("Migrations generated")}catch(error){return err(handleError("Migration generation failed",error))}}export function referencesUndefinedType(statement,dangling){return dangling.some((name)=>statement.includes(`"${name}"`))}export function findDanglingTypeReferences(statements){const defined=new Set,referenced=new Set;for(const statement of statements){for(const match of statement.matchAll(/CREATE\s+TYPE\s+"([^"]+)"/gi))defined.add(match[1]);for(const match of statement.matchAll(/\bTYPE\s+"([^"]+)"/gi))referenced.add(match[1])}return[...referenced].filter((name)=>!defined.has(name)).sort()}export function createMissingEnumTypes(dangling,plan){if(dangling.length===0)return{statements:[],defined:[]};const values=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){if(!column.name||!column.enumValues?.length)continue;values.set(`${table.table}_${column.name}_type`,column.enumValues)}}const statements=[],defined=[];for(const name of dangling){const members=values.get(name);if(!members?.length)continue;const literals=members.map((member)=>`'${String(member).replaceAll("'","''")}'`).join(", ");statements.push(`${guardPostgresEnumTypes(`CREATE TYPE "${name}" AS ENUM (${literals})`)};`);defined.push(name)}return{statements,defined}}export function inlineSqliteAddedColumnReferences(statements,plan){const references=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){const reference=column.references;if(!column.name||!reference?.table||!reference.column)continue;references.set(`${table.table}.${column.name}`,{table:reference.table,column:reference.column})}}if(references.size===0)return statements;return statements.map((statement)=>{if(/\bREFERENCES\b/i.test(statement))return statement;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;if(!match||!reference)return statement;return`${match[1]}${match[4].trimEnd()} REFERENCES "${reference.table}"("${reference.column}")${match[5]}`})}export async function regenerateMigrationCorpus(options={}){try{configureQueryBuilder();const dialect=options.dialect??getQbDialect(),dir=options.dir??join(process.cwd(),"database","migrations"),sources=resolveModelSources();if(!sources)return err(Error("No models found. Define models in app/Models, or ensure the framework defaults at storage/framework/defaults/app/Models are present."));try{writeFileSync(join(sources.dir,`.qb-migrations.${dialect}.json`),JSON.stringify({plan:{dialect,tables:[]}}))}catch{}const snapshotPath=join(resolveSnapshotDir(),`model-snapshot.${dialect}.json`),parkedSnapshot=`${snapshotPath}.regenerating`;let snapshotParked=!1;if(existsSync(snapshotPath))try{renameSync(snapshotPath,parkedSnapshot);snapshotParked=!0}catch{}let result;try{result=await qbGenerateMigration(sources.dir,{dialect,dryRun:!0})}finally{if(snapshotParked)try{renameSync(parkedSnapshot,snapshotPath)}catch{}}const statements=result.sqlStatements??[];if(statements.length===0)return err(Error(`The generator produced no SQL for dialect "${dialect}".`));const dangling=findDanglingTypeReferences(statements);if(dangling.length>0)return err(Error(`The generator emitted ${dangling.length} reference(s) to enum type(s) it never creates: ${dangling.slice(0,5).join(", ")}${dangling.length>5?`, +${dangling.length-5} more`:""}. Writing this corpus would fail partway through a migration. This is a bug in the migration generator, not in your models.`));const groups=groupGeneratedStatements(statements);let existing=[];try{existing=readdirSync(dir).filter((f)=>f.endsWith(".sql")).sort()}catch{}const files=groups.map((group,index)=>({name:`${String(index+1).padStart(10,"0")}-${group.label}.sql`,statements:group.statements.length}));if(options.dryRun)return ok({dialect,models:sources.models.length,files,removed:existing,dir});mkdirSync(dir,{recursive:!0});for(const file of existing)unlinkSync(join(dir,file));groups.forEach((group,index)=>{const body=`${group.statements.map((s)=>s.trim().replace(/;\s*$/,"")).join(`;
|
|
971
27
|
`)};
|
|
972
|
-
`;
|
|
973
|
-
|
|
974
|
-
});
|
|
975
|
-
return ok({ dialect, models: sources.models.length, files, removed: existing, dir });
|
|
976
|
-
} catch (error) {
|
|
977
|
-
return err(handleError("Migration regeneration failed", error));
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
function persistGeneratedMigrations(sqlStatements) {
|
|
981
|
-
if (!sqlStatements?.length)
|
|
982
|
-
return 0;
|
|
983
|
-
const migrationsDir = join(process.cwd(), "database", "migrations");
|
|
984
|
-
try {
|
|
985
|
-
require("node:fs").mkdirSync(migrationsDir, { recursive: !0 });
|
|
986
|
-
} catch {}
|
|
987
|
-
let existingSql = "";
|
|
988
|
-
try {
|
|
989
|
-
for (const f of readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")))
|
|
990
|
-
existingSql += `
|
|
991
|
-
${readFileSync(join(migrationsDir, f), "utf8")}`;
|
|
992
|
-
} catch {}
|
|
993
|
-
const normalize = (s) => s.replace(/\s+/g, " ").trim(), haystack = normalize(existingSql), groups = groupGeneratedStatements(sqlStatements);
|
|
994
|
-
let written = 0, cursor = nextMigrationNumber(migrationsDir);
|
|
995
|
-
for (const group of groups) {
|
|
996
|
-
const fresh = group.statements.filter((stmt) => !haystack.includes(normalize(stmt)));
|
|
997
|
-
if (fresh.length === 0)
|
|
998
|
-
continue;
|
|
999
|
-
const filename = `${String(cursor).padStart(10, "0")}-${group.label}.sql`, filePath = join(migrationsDir, filename), body = `${fresh.map((s) => s.trim().replace(/;\s*$/, "")).join(`;
|
|
28
|
+
`;writeFileSync(join(dir,files[index].name),body)});return ok({dialect,models:sources.models.length,files,removed:existing,dir})}catch(error){return err(handleError("Migration regeneration failed",error))}}function persistGeneratedMigrations(sqlStatements){if(!sqlStatements?.length)return 0;const migrationsDir=join(process.cwd(),"database","migrations");try{require("node:fs").mkdirSync(migrationsDir,{recursive:!0})}catch{}let existingSql="";try{for(const f of readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql")))existingSql+=`
|
|
29
|
+
${readFileSync(join(migrationsDir,f),"utf8")}`}catch{}const normalize=(s)=>s.replace(/\s+/g," ").trim(),haystack=normalize(existingSql),groups=groupGeneratedStatements(sqlStatements);let written=0,cursor=nextMigrationNumber(migrationsDir);for(const group of groups){const fresh=group.statements.filter((stmt)=>!haystack.includes(normalize(stmt)));if(fresh.length===0)continue;const filename=`${String(cursor).padStart(10,"0")}-${group.label}.sql`,filePath=join(migrationsDir,filename),body=`${fresh.map((s)=>s.trim().replace(/;\s*$/,"")).join(`;
|
|
1000
30
|
`)};
|
|
1001
|
-
`;
|
|
1002
|
-
|
|
1003
|
-
log.debug(`[migration] Wrote ${filename} (${fresh.length} stmt${fresh.length === 1 ? "" : "s"})`);
|
|
1004
|
-
written += 1;
|
|
1005
|
-
cursor += 1;
|
|
1006
|
-
}
|
|
1007
|
-
return written;
|
|
1008
|
-
}
|
|
1009
|
-
function normalizeCreateStatements(sqlStatements) {
|
|
1010
|
-
const creates = [], constraints = [], passthrough = [];
|
|
1011
|
-
for (const raw of sqlStatements) {
|
|
1012
|
-
const statement = raw.trim();
|
|
1013
|
-
if (!statement)
|
|
1014
|
-
continue;
|
|
1015
|
-
const create = statement.match(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/i);
|
|
1016
|
-
if (create?.[1]) {
|
|
1017
|
-
creates.push({ statement, table: create[1] });
|
|
1018
|
-
continue;
|
|
1019
|
-
}
|
|
1020
|
-
const constraint = statement.match(/^ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+CONSTRAINT\s+([\s\S]+?);?$/i);
|
|
1021
|
-
if (constraint?.[1] && constraint[2]) {
|
|
1022
|
-
const references = [...constraint[2].matchAll(/REFERENCES\s+["`]?(\w+)["`]?/gi)].flatMap((match) => match[1] ? [match[1]] : []);
|
|
1023
|
-
constraints.push({ body: `CONSTRAINT ${constraint[2].replace(/;\s*$/, "")}`, references, statement, table: constraint[1] });
|
|
1024
|
-
continue;
|
|
1025
|
-
}
|
|
1026
|
-
passthrough.push(statement);
|
|
1027
|
-
}
|
|
1028
|
-
if (creates.length === 0)
|
|
1029
|
-
return sqlStatements.map((statement) => statement.trim()).filter(Boolean);
|
|
1030
|
-
const createdTables = new Set(creates.map((create) => create.table)), createOrder = new Map(creates.map((create, index) => [create.table, index])), relevantConstraints = constraints.filter((constraint) => createdTables.has(constraint.table)), unrelatedConstraints = constraints.filter((constraint) => !createdTables.has(constraint.table)), dependencies = new Map(creates.map((create) => [
|
|
1031
|
-
create.table,
|
|
1032
|
-
new Set(relevantConstraints.filter((constraint) => constraint.table === create.table).flatMap((constraint) => constraint.references).filter((reference) => reference !== create.table && createdTables.has(reference)))
|
|
1033
|
-
])), sortTables = (ignoredEdges = new Set) => {
|
|
1034
|
-
const remaining = new Set(createdTables), sorted = [];
|
|
1035
|
-
while (remaining.size > 0) {
|
|
1036
|
-
const ready = [...remaining].filter((table) => [...dependencies.get(table) ?? []].every((dependency) => {
|
|
1037
|
-
return !remaining.has(dependency) || ignoredEdges.has(`${table}->${dependency}`);
|
|
1038
|
-
})).sort((a, b) => (createOrder.get(a) ?? 0) - (createOrder.get(b) ?? 0));
|
|
1039
|
-
if (ready.length === 0)
|
|
1040
|
-
break;
|
|
1041
|
-
for (const table of ready) {
|
|
1042
|
-
remaining.delete(table);
|
|
1043
|
-
sorted.push(table);
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
return sorted;
|
|
1047
|
-
}, initiallySorted = sortTables(), cyclicTables = new Set([...createdTables].filter((table) => !initiallySorted.includes(table))), deferred = relevantConstraints.filter((constraint) => constraint.references.some((reference) => {
|
|
1048
|
-
return reference !== constraint.table && cyclicTables.has(constraint.table) && cyclicTables.has(reference);
|
|
1049
|
-
})), deferredStatements = new Set(deferred.map((constraint) => constraint.statement)), ignoredEdges = new Set(deferred.flatMap((constraint) => constraint.references.map((reference) => `${constraint.table}->${reference}`))), orderedTables = sortTables(ignoredEdges), byTable = new Map(creates.map((create) => [create.table, create]));
|
|
1050
|
-
return [
|
|
1051
|
-
...orderedTables.map((table) => {
|
|
1052
|
-
const create = byTable.get(table), inline = relevantConstraints.filter((constraint) => constraint.table === table && !deferredStatements.has(constraint.statement));
|
|
1053
|
-
if (inline.length === 0)
|
|
1054
|
-
return create.statement;
|
|
1055
|
-
const closing = create.statement.lastIndexOf(")");
|
|
1056
|
-
if (closing < 0)
|
|
1057
|
-
return create.statement;
|
|
1058
|
-
const before = create.statement.slice(0, closing).trimEnd(), after = create.statement.slice(closing);
|
|
1059
|
-
return `${before},
|
|
1060
|
-
${inline.map((constraint) => constraint.body).join(`,
|
|
31
|
+
`;writeFileSync(filePath,body);log.debug(`[migration] Wrote ${filename} (${fresh.length} stmt${fresh.length===1?"":"s"})`);written+=1;cursor+=1}return written}function normalizeCreateStatements(sqlStatements){const creates=[],constraints=[],passthrough=[];for(const raw of sqlStatements){const statement=raw.trim();if(!statement)continue;const create=statement.match(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/i);if(create?.[1]){creates.push({statement,table:create[1]});continue}const constraint=statement.match(/^ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+CONSTRAINT\s+([\s\S]+?);?$/i);if(constraint?.[1]&&constraint[2]){const references=[...constraint[2].matchAll(/REFERENCES\s+["`]?(\w+)["`]?/gi)].flatMap((match)=>match[1]?[match[1]]:[]);constraints.push({body:`CONSTRAINT ${constraint[2].replace(/;\s*$/,"")}`,references,statement,table:constraint[1]});continue}passthrough.push(statement)}if(creates.length===0)return sqlStatements.map((statement)=>statement.trim()).filter(Boolean);const createdTables=new Set(creates.map((create)=>create.table)),createOrder=new Map(creates.map((create,index)=>[create.table,index])),relevantConstraints=constraints.filter((constraint)=>createdTables.has(constraint.table)),unrelatedConstraints=constraints.filter((constraint)=>!createdTables.has(constraint.table)),dependencies=new Map(creates.map((create)=>[create.table,new Set(relevantConstraints.filter((constraint)=>constraint.table===create.table).flatMap((constraint)=>constraint.references).filter((reference)=>reference!==create.table&&createdTables.has(reference)))])),sortTables=(ignoredEdges=new Set)=>{const remaining=new Set(createdTables),sorted=[];while(remaining.size>0){const ready=[...remaining].filter((table)=>[...dependencies.get(table)??[]].every((dependency)=>{return!remaining.has(dependency)||ignoredEdges.has(`${table}->${dependency}`)})).sort((a,b)=>(createOrder.get(a)??0)-(createOrder.get(b)??0));if(ready.length===0)break;for(const table of ready){remaining.delete(table);sorted.push(table)}}return sorted},initiallySorted=sortTables(),cyclicTables=new Set([...createdTables].filter((table)=>!initiallySorted.includes(table))),deferred=relevantConstraints.filter((constraint)=>constraint.references.some((reference)=>{return reference!==constraint.table&&cyclicTables.has(constraint.table)&&cyclicTables.has(reference)})),deferredStatements=new Set(deferred.map((constraint)=>constraint.statement)),ignoredEdges=new Set(deferred.flatMap((constraint)=>constraint.references.map((reference)=>`${constraint.table}->${reference}`))),orderedTables=sortTables(ignoredEdges),byTable=new Map(creates.map((create)=>[create.table,create]));return[...orderedTables.map((table)=>{const create=byTable.get(table),inline=relevantConstraints.filter((constraint)=>constraint.table===table&&!deferredStatements.has(constraint.statement));if(inline.length===0)return create.statement;const closing=create.statement.lastIndexOf(")");if(closing<0)return create.statement;const before=create.statement.slice(0,closing).trimEnd(),after=create.statement.slice(closing);return`${before},
|
|
32
|
+
${inline.map((constraint)=>constraint.body).join(`,
|
|
1061
33
|
`)}
|
|
1062
|
-
${after}
|
|
1063
|
-
}),
|
|
1064
|
-
...passthrough,
|
|
1065
|
-
...unrelatedConstraints.map((constraint) => constraint.statement),
|
|
1066
|
-
...deferred.map((constraint) => constraint.statement)
|
|
1067
|
-
];
|
|
1068
|
-
}
|
|
1069
|
-
export function groupGeneratedStatements(sqlStatements) {
|
|
1070
|
-
const normalizedStatements = normalizeCreateStatements(sqlStatements), groups = new Map, push = (label, stmt) => {
|
|
1071
|
-
const list = groups.get(label) ?? [];
|
|
1072
|
-
list.push(stmt);
|
|
1073
|
-
groups.set(label, list);
|
|
1074
|
-
}, createdTables = new Set(normalizedStatements.flatMap((raw) => {
|
|
1075
|
-
const match = raw.trim().match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/i);
|
|
1076
|
-
return match?.[1] ? [match[1]] : [];
|
|
1077
|
-
}));
|
|
1078
|
-
for (const raw of normalizedStatements) {
|
|
1079
|
-
const stmt = raw.trim();
|
|
1080
|
-
if (!stmt)
|
|
1081
|
-
continue;
|
|
1082
|
-
const create = stmt.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/i);
|
|
1083
|
-
if (create) {
|
|
1084
|
-
push(`create-${create[1]}-table`, stmt);
|
|
1085
|
-
continue;
|
|
1086
|
-
}
|
|
1087
|
-
if (stmt.match(/^\s*CREATE\s+TYPE\s+/i)) {
|
|
1088
|
-
push("create-database-types", stmt);
|
|
1089
|
-
continue;
|
|
1090
|
-
}
|
|
1091
|
-
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];
|
|
1092
|
-
if (alter && alterTable) {
|
|
1093
|
-
const isCreateTimeConstraint = createdTables.has(alterTable) && !alter[2] && !alter[3];
|
|
1094
|
-
push(isCreateTimeConstraint ? "create-foreign-key-constraints" : `alter-${alterTable}-columns`, stmt);
|
|
1095
|
-
continue;
|
|
1096
|
-
}
|
|
1097
|
-
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];
|
|
1098
|
-
if (idxName && idxTable) {
|
|
1099
|
-
push(createdTables.has(idxTable) ? `create-${idxTable}-table` : `create-${idxName}-index-in-${idxTable}`, stmt);
|
|
1100
|
-
continue;
|
|
1101
|
-
}
|
|
1102
|
-
const drop = stmt.match(/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["`]?(\w+)["`]?/i);
|
|
1103
|
-
if (drop) {
|
|
1104
|
-
push(`drop-${drop[1]}-table`, stmt);
|
|
1105
|
-
continue;
|
|
1106
|
-
}
|
|
1107
|
-
push("auto-misc", stmt);
|
|
1108
|
-
}
|
|
1109
|
-
return [...groups.entries()].map(([label, statements]) => ({ label, statements })).sort((a, b) => Number(b.label === "create-database-types") - Number(a.label === "create-database-types"));
|
|
1110
|
-
}
|
|
1111
|
-
function nextMigrationNumber(migrationsDir) {
|
|
1112
|
-
let max = 0;
|
|
1113
|
-
try {
|
|
1114
|
-
for (const f of readdirSync(migrationsDir)) {
|
|
1115
|
-
const m = f.match(/^(\d+)-/);
|
|
1116
|
-
if (m?.[1])
|
|
1117
|
-
max = Math.max(max, Number.parseInt(m[1], 10));
|
|
1118
|
-
}
|
|
1119
|
-
} catch {}
|
|
1120
|
-
return max + 1;
|
|
1121
|
-
}
|
|
1122
|
-
export async function generateMigrations2() {
|
|
1123
|
-
try {
|
|
1124
|
-
log.info("Generating fresh migrations...");
|
|
1125
|
-
configureQueryBuilder();
|
|
1126
|
-
const dialect = getDialect(), { modelsDir, skip } = prepareMigrationModelsDir();
|
|
1127
|
-
if (skip) {
|
|
1128
|
-
log.info("No app/Models directory found; using committed framework migrations");
|
|
1129
|
-
return ok("Migrations generated");
|
|
1130
|
-
}
|
|
1131
|
-
await qbGenerateMigration(modelsDir, { dialect: getQbDialect(), full: !0, dryRun: !0 });
|
|
1132
|
-
log.success("Migrations generated");
|
|
1133
|
-
return ok("Migrations generated");
|
|
1134
|
-
} catch (error) {
|
|
1135
|
-
return err(handleError("Fresh migration generation failed", error));
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
34
|
+
${after}`}),...passthrough,...unrelatedConstraints.map((constraint)=>constraint.statement),...deferred.map((constraint)=>constraint.statement)]}export function groupGeneratedStatements(sqlStatements){const normalizedStatements=normalizeCreateStatements(sqlStatements),groups=new Map,push=(label,stmt)=>{const list=groups.get(label)??[];list.push(stmt);groups.set(label,list)},createdTables=new Set(normalizedStatements.flatMap((raw)=>{const match=raw.trim().match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/i);return match?.[1]?[match[1]]:[]}));for(const raw of normalizedStatements){const stmt=raw.trim();if(!stmt)continue;const create=stmt.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/i);if(create){push(`create-${create[1]}-table`,stmt);continue}if(stmt.match(/^\s*CREATE\s+TYPE\s+/i)){push("create-database-types",stmt);continue}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];if(alter&&alterTable){const isCreateTimeConstraint=createdTables.has(alterTable)&&!alter[2]&&!alter[3];push(isCreateTimeConstraint?"create-foreign-key-constraints":`alter-${alterTable}-columns`,stmt);continue}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];if(idxName&&idxTable){push(createdTables.has(idxTable)?`create-${idxTable}-table`:`create-${idxName}-index-in-${idxTable}`,stmt);continue}const drop=stmt.match(/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["`]?(\w+)["`]?/i);if(drop){push(`drop-${drop[1]}-table`,stmt);continue}push("auto-misc",stmt)}return[...groups.entries()].map(([label,statements])=>({label,statements})).sort((a,b)=>Number(b.label==="create-database-types")-Number(a.label==="create-database-types"))}function nextMigrationNumber(migrationsDir){let max=0;try{for(const f of readdirSync(migrationsDir)){const m=f.match(/^(\d+)-/);if(m?.[1])max=Math.max(max,Number.parseInt(m[1],10))}}catch{}return max+1}export async function generateMigrations2(){try{log.info("Generating fresh migrations...");configureQueryBuilder();const dialect=getDialect(),{modelsDir,skip}=prepareMigrationModelsDir();if(skip){log.info("No app/Models directory found; using committed framework migrations");return ok("Migrations generated")}await qbGenerateMigration(modelsDir,{dialect:getQbDialect(),full:!0,dryRun:!0});log.success("Migrations generated");return ok("Migrations generated")}catch(error){return err(handleError("Fresh migration generation failed",error))}}
|