@stacksjs/database 0.70.87 → 0.70.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/auth-tables.js +220 -0
  2. package/dist/class-seeder.js +116 -0
  3. package/dist/column.d.ts +17 -0
  4. package/dist/column.js +26 -0
  5. package/dist/custom/audits.js +57 -0
  6. package/dist/custom/errors.js +48 -0
  7. package/dist/custom/index.js +3 -0
  8. package/dist/custom/jobs.js +449 -0
  9. package/dist/database.js +178 -0
  10. package/dist/defaults.js +48 -0
  11. package/dist/driver-config.js +144 -0
  12. package/dist/drivers/defaults/index.js +2 -0
  13. package/dist/drivers/defaults/passwords.js +106 -0
  14. package/dist/drivers/defaults/traits.js +1125 -0
  15. package/dist/drivers/dynamodb.js +607 -0
  16. package/dist/drivers/helpers.js +206 -0
  17. package/dist/drivers/index.js +9 -0
  18. package/dist/drivers/mysql.js +322 -0
  19. package/dist/drivers/postgres.js +411 -0
  20. package/dist/drivers/sqlite.js +397 -0
  21. package/dist/factory.js +51 -0
  22. package/dist/fk-audit.js +181 -0
  23. package/dist/index.js +55 -1263
  24. package/dist/migration-lock.js +143 -0
  25. package/dist/migrations.js +528 -0
  26. package/dist/notification-tables.js +54 -0
  27. package/dist/query-logger.js +213 -0
  28. package/dist/query-parser.js +93 -0
  29. package/dist/rbac-tables.js +84 -0
  30. package/dist/safe-migrations.js +59 -0
  31. package/dist/schema.d.ts +4 -0
  32. package/dist/schema.js +10 -0
  33. package/dist/seed-scaffold.js +144 -0
  34. package/dist/seeder.js +363 -0
  35. package/dist/sql-helpers.js +24 -0
  36. package/dist/table.d.ts +7 -0
  37. package/dist/table.js +26 -0
  38. package/dist/tools/setup.d.ts +1 -0
  39. package/dist/tools/setup.js +6 -0
  40. package/dist/transaction-context.js +62 -0
  41. package/dist/types.js +23 -0
  42. package/dist/unique-audit.js +174 -0
  43. package/dist/utils.js +163 -0
  44. package/dist/uuid-columns.js +68 -0
  45. package/dist/validators.js +122 -0
  46. package/package.json +11 -11
@@ -0,0 +1,143 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { createHash } from "node:crypto";
3
+ import { closeSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import process from "node:process";
5
+ import { userDatabasePath } from "@stacksjs/path";
6
+ const DEFAULT_TIMEOUT_MS = 30000, INITIAL_BACKOFF_MS = 100, MAX_BACKOFF_MS = 2000, STALE_LOCK_MS = 60000, LOCK_NAME = "stacks_migrations";
7
+ export async function acquireMigrationLock(dialect, adminDb, opts = {}) {
8
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
9
+ if (dialect !== "sqlite" && dialect !== "postgres" && dialect !== "mysql")
10
+ throw Error(`[migration-lock] unknown dialect: ${String(dialect)}`);
11
+ if (dialect === "sqlite")
12
+ return acquireSqliteLock(opts.sqliteLockPath, timeoutMs);
13
+ if (!adminDb)
14
+ throw Error(`[migration-lock] ${dialect} requires a database connection to acquire the lock`);
15
+ if (dialect === "postgres")
16
+ return acquirePostgresLock(adminDb, timeoutMs);
17
+ return acquireMySqlLock(adminDb, timeoutMs);
18
+ }
19
+ function lockKeysForPostgres() {
20
+ const hash = createHash("sha256").update(LOCK_NAME).digest(), key1 = hash.readInt32BE(0), key2 = hash.readInt32BE(4);
21
+ return { key1, key2 };
22
+ }
23
+ async function acquirePostgresLock(adminDb, timeoutMs) {
24
+ const { key1, key2 } = lockKeysForPostgres(), start = Date.now();
25
+ let backoff = INITIAL_BACKOFF_MS;
26
+ while (!0) {
27
+ const result = await adminDb.unsafe(`SELECT pg_try_advisory_lock(${key1}, ${key2}) AS acquired`);
28
+ if (extractFirstBool(result, "acquired"))
29
+ return {
30
+ release: async () => {
31
+ try {
32
+ await adminDb.unsafe(`SELECT pg_advisory_unlock(${key1}, ${key2})`);
33
+ } catch {}
34
+ }
35
+ };
36
+ if (Date.now() - start >= timeoutMs)
37
+ throw Error("[migration-lock] another migration is in progress \u2014 could not acquire postgres advisory lock within timeout");
38
+ await sleepWithJitter(backoff);
39
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
40
+ }
41
+ }
42
+ async function acquireMySqlLock(adminDb, timeoutMs) {
43
+ const start = Date.now();
44
+ let backoff = INITIAL_BACKOFF_MS;
45
+ while (!0) {
46
+ const result = await adminDb.unsafe(`SELECT GET_LOCK('${LOCK_NAME}', 0) AS acquired`);
47
+ if (extractFirstInt(result, "acquired") === 1)
48
+ return {
49
+ release: async () => {
50
+ try {
51
+ await adminDb.unsafe(`SELECT RELEASE_LOCK('${LOCK_NAME}')`);
52
+ } catch {}
53
+ }
54
+ };
55
+ if (Date.now() - start >= timeoutMs)
56
+ throw Error("[migration-lock] another migration is in progress \u2014 could not acquire MySQL named lock within timeout");
57
+ await sleepWithJitter(backoff);
58
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
59
+ }
60
+ }
61
+ function defaultSqliteLockPath() {
62
+ return userDatabasePath(".migration.lock");
63
+ }
64
+ async function acquireSqliteLock(lockPath, timeoutMs) {
65
+ const path = lockPath ?? defaultSqliteLockPath(), start = Date.now();
66
+ let backoff = INITIAL_BACKOFF_MS;
67
+ while (!0) {
68
+ if (tryCreateLockFile(path)) {
69
+ let released = !1;
70
+ return {
71
+ release: async () => {
72
+ if (released)
73
+ return;
74
+ released = !0;
75
+ try {
76
+ unlinkSync(path);
77
+ } catch {}
78
+ }
79
+ };
80
+ }
81
+ reclaimIfStale(path);
82
+ if (Date.now() - start >= timeoutMs)
83
+ throw Error(`[migration-lock] another migration is in progress \u2014 lock file ${path} held within timeout`);
84
+ await sleepWithJitter(backoff);
85
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
86
+ }
87
+ }
88
+ function tryCreateLockFile(path) {
89
+ try {
90
+ const fd = openSync(path, "wx");
91
+ try {
92
+ const payload = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
93
+ writeFileSync(fd, Buffer.from(payload, "utf8"));
94
+ } finally {
95
+ closeSync(fd);
96
+ }
97
+ return !0;
98
+ } catch (e) {
99
+ if (e.code === "EEXIST")
100
+ return !1;
101
+ throw e;
102
+ }
103
+ }
104
+ function reclaimIfStale(path) {
105
+ try {
106
+ const st = statSync(path);
107
+ if (Date.now() - st.mtimeMs > STALE_LOCK_MS)
108
+ try {
109
+ unlinkSync(path);
110
+ } catch {}
111
+ } catch {}
112
+ }
113
+ function sleepWithJitter(ms) {
114
+ const jittered = ms * (1 + Math.random() * 0.25);
115
+ return new Promise((resolve) => setTimeout(resolve, jittered));
116
+ }
117
+ function extractFirstBool(result, column) {
118
+ const row = pluckFirstRow(result);
119
+ if (!row)
120
+ return !1;
121
+ const value = row[column];
122
+ return value === !0 || value === 1 || value === "1" || value === "t";
123
+ }
124
+ function extractFirstInt(result, column) {
125
+ const row = pluckFirstRow(result);
126
+ if (!row)
127
+ return null;
128
+ const value = row[column];
129
+ if (typeof value === "number")
130
+ return value;
131
+ if (typeof value === "string" && /^-?\d+$/.test(value))
132
+ return Number.parseInt(value, 10);
133
+ return null;
134
+ }
135
+ function pluckFirstRow(result) {
136
+ if (!result)
137
+ return null;
138
+ if (Array.isArray(result))
139
+ return result[0];
140
+ if (typeof result === "object" && "rows" in result && Array.isArray(result.rows))
141
+ return result.rows[0];
142
+ return null;
143
+ }
@@ -0,0 +1,528 @@
1
+ var {require}=import.meta;import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { log as _log } from "@stacksjs/logging";
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 {
14
+ createQueryBuilder,
15
+ executeMigration as qbExecuteMigration,
16
+ generateMigration as qbGenerateMigration,
17
+ resetConnection,
18
+ resetDatabase as qbResetDatabase,
19
+ setConfig
20
+ } from "@stacksjs/query-builder";
21
+ import { db } from "./utils";
22
+ import { acquireMigrationLock } from "./migration-lock";
23
+ import { env as envVars } from "@stacksjs/env";
24
+ import { getConnectionDefaults } from "./defaults";
25
+ const dbDriver = envVars.DB_CONNECTION || "sqlite", sqliteDefaults = getConnectionDefaults("sqlite", envVars), mysqlDefaults = getConnectionDefaults("mysql", envVars), postgresDefaults = getConnectionDefaults("postgres", envVars), dbConfig = {
26
+ default: dbDriver,
27
+ connections: {
28
+ sqlite: { database: sqliteDefaults.database, prefix: "" },
29
+ mysql: { name: mysqlDefaults.database, host: mysqlDefaults.host, username: mysqlDefaults.username, password: mysqlDefaults.password, port: mysqlDefaults.port, prefix: "" },
30
+ postgres: { name: postgresDefaults.database, host: postgresDefaults.host, username: postgresDefaults.username, password: postgresDefaults.password, port: postgresDefaults.port, prefix: "" }
31
+ }
32
+ };
33
+ function getDriver() {
34
+ return dbConfig.default || "sqlite";
35
+ }
36
+ function getDialect() {
37
+ const driver = getDriver();
38
+ if (driver === "sqlite" || driver === "mysql" || driver === "postgres")
39
+ return driver;
40
+ if (driver === "singlestore")
41
+ return "mysql";
42
+ if (driver === "dynamodb")
43
+ 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.");
44
+ throw Error(`[database] Unknown DB_CONNECTION "${driver}". Allowed values: sqlite, mysql, postgres, dynamodb.`);
45
+ }
46
+ function getQbDialect() {
47
+ return getDriver() === "singlestore" ? "singlestore" : getDialect();
48
+ }
49
+ function configureQueryBuilder() {
50
+ const dialect = getDialect(), connectionConfig = dbConfig.connections[dialect];
51
+ setConfig({
52
+ dialect,
53
+ verbose: !1,
54
+ database: {
55
+ database: connectionConfig?.name || connectionConfig?.database || "stacks",
56
+ host: connectionConfig?.host || "localhost",
57
+ port: connectionConfig?.port || (dialect === "postgres" ? 5432 : dialect === "mysql" ? 3306 : 0),
58
+ username: connectionConfig?.username || "",
59
+ password: connectionConfig?.password || ""
60
+ }
61
+ });
62
+ resetConnection();
63
+ }
64
+ function prepareMigrationModelsDir() {
65
+ const userModelsDir = path.userModelsPath();
66
+ return { modelsDir: userModelsDir, skip: !existsSync(userModelsDir) };
67
+ }
68
+ export function preprocessSqliteMigrations() {
69
+ const migrationsDir = join(process.cwd(), "database", "migrations");
70
+ let files;
71
+ try {
72
+ files = readdirSync(migrationsDir).filter((f) => f.endsWith(".sql"));
73
+ } catch {
74
+ return;
75
+ }
76
+ const droppedMigrations = [], skipMigration = (file, reason) => {
77
+ log.info(`Skipping migration on SQLite (${reason}): ${file}`);
78
+ droppedMigrations.push(file);
79
+ }, deleteMigration = (file, filePath, reason) => {
80
+ log.info(`Dropping no-op migration (${reason}): ${file}`);
81
+ try {
82
+ unlinkSync(filePath);
83
+ } catch {}
84
+ droppedMigrations.push(file);
85
+ }, replayMigrations = [], addConstraintPattern = /^\s*ALTER\s+TABLE\s+.+\s+ADD\s+CONSTRAINT\s+/i, createTypePattern = /^\s*CREATE\s+TYPE\s+/i, createUniqueIndexPattern = /^\s*CREATE\s+UNIQUE\s+INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i, dropColumnPattern = /^\s*ALTER\s+TABLE\s+["']?(\w+)["']?\s+DROP\s+COLUMN\s+["']?(\w+)["']?\s*$/i, createTablePattern = /^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(\w+)["']?/i, createTableEarliest = new Map;
86
+ for (const file of files) {
87
+ const m = file.match(/^\d+-create-(\w+)-table\.sql$/);
88
+ if (!m || !m[1])
89
+ continue;
90
+ const tableName = m[1], existing = createTableEarliest.get(tableName);
91
+ if (!existing || file < existing)
92
+ createTableEarliest.set(tableName, file);
93
+ }
94
+ const sqliteDbPath = join(process.cwd(), dbConfig.connections.sqlite.database || "stacks.db");
95
+ let sqliteDb = null;
96
+ if (existsSync(sqliteDbPath))
97
+ try {
98
+ const { Database } = require("bun:sqlite");
99
+ sqliteDb = new Database(sqliteDbPath, { readonly: !0 });
100
+ } catch {}
101
+ for (const file of files) {
102
+ log.debug(`[migration] Running: ${file}`);
103
+ const filePath = join(migrationsDir, file), statements = readFileSync(filePath, "utf-8").split(";").map((s) => s.trim()).filter((s) => s.length > 0 && !s.startsWith("--"));
104
+ if (statements.length === 0)
105
+ continue;
106
+ const firstStatement = statements[0], createTableMatch = firstStatement ? firstStatement.match(createTablePattern) : null;
107
+ if (createTableMatch && createTableMatch[1]) {
108
+ const tableName = createTableMatch[1], earliest = createTableEarliest.get(tableName);
109
+ if (earliest && earliest !== file) {
110
+ deleteMigration(file, filePath, `duplicate create-table for "${tableName}" (kept ${earliest})`);
111
+ continue;
112
+ }
113
+ }
114
+ if (statements.every((s) => addConstraintPattern.test(s))) {
115
+ skipMigration(file, "SQLite does not support ALTER TABLE ADD CONSTRAINT");
116
+ continue;
117
+ }
118
+ if (statements.every((s) => createTypePattern.test(s))) {
119
+ skipMigration(file, "SQLite does not support CREATE TYPE (enum types)");
120
+ continue;
121
+ }
122
+ const uniqueIndexNames = statements.map((s) => s.match(createUniqueIndexPattern)?.[1]).filter((name) => Boolean(name));
123
+ if (sqliteDb && uniqueIndexNames.length === statements.length) {
124
+ const indexExists = sqliteDb.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?");
125
+ if (uniqueIndexNames.filter((name) => !indexExists.get(name)).length > 0) {
126
+ log.info(`Re-queueing unique-index migration (index missing from database): ${file}`);
127
+ replayMigrations.push(file);
128
+ }
129
+ continue;
130
+ }
131
+ if (statements.some((s) => dropColumnPattern.test(s))) {
132
+ let modified = !1;
133
+ const filteredStatements = [];
134
+ for (const stmt of statements) {
135
+ const dropColMatch = stmt.match(dropColumnPattern);
136
+ if (dropColMatch && dropColMatch[1] && dropColMatch[2]) {
137
+ const tableName = dropColMatch[1], columnName = dropColMatch[2];
138
+ if (!sqliteDb) {
139
+ log.info(`Skipping DROP COLUMN "${columnName}" \u2014 no database exists yet: ${file}`);
140
+ modified = !0;
141
+ continue;
142
+ }
143
+ try {
144
+ const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, ""), columns = sqliteDb.prepare(`PRAGMA table_info("${safeTableName}")`).all();
145
+ if (columns.length === 0) {
146
+ log.info(`Skipping DROP COLUMN "${columnName}" \u2014 table "${tableName}" does not exist yet: ${file}`);
147
+ modified = !0;
148
+ continue;
149
+ }
150
+ if (!columns.some((col) => col.name === columnName)) {
151
+ log.info(`Skipping DROP COLUMN "${columnName}" from "${tableName}" \u2014 column does not exist: ${file}`);
152
+ modified = !0;
153
+ continue;
154
+ }
155
+ } catch {
156
+ log.info(`Skipping DROP COLUMN "${columnName}" \u2014 table "${tableName}" not found: ${file}`);
157
+ modified = !0;
158
+ continue;
159
+ }
160
+ }
161
+ filteredStatements.push(stmt);
162
+ }
163
+ if (modified) {
164
+ if (filteredStatements.length === 0)
165
+ deleteMigration(file, filePath, "columns already absent from table");
166
+ else
167
+ writeFileSync(filePath, `${filteredStatements.join(`;
168
+ `)};
169
+ `);
170
+ continue;
171
+ }
172
+ }
173
+ }
174
+ if (sqliteDb)
175
+ try {
176
+ sqliteDb.close();
177
+ } catch {}
178
+ if (droppedMigrations.length > 0 || replayMigrations.length > 0)
179
+ try {
180
+ const dbPath = join(process.cwd(), dbConfig.connections.sqlite.database || "stacks.db");
181
+ mkdirSync(dirname(dbPath), { recursive: !0 });
182
+ const { Database } = require("bun:sqlite"), writeDb = new Database(dbPath);
183
+ try {
184
+ writeDb.exec(`CREATE TABLE IF NOT EXISTS migrations (
185
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
186
+ migration TEXT NOT NULL UNIQUE,
187
+ executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
188
+ )`);
189
+ const insert = writeDb.prepare("INSERT OR IGNORE INTO migrations (migration) VALUES (?)");
190
+ for (const migration of droppedMigrations)
191
+ insert.run(migration);
192
+ const unrecord = writeDb.prepare("DELETE FROM migrations WHERE migration = ?");
193
+ for (const migration of replayMigrations)
194
+ unrecord.run(migration);
195
+ } finally {
196
+ writeDb.close();
197
+ }
198
+ } catch (e) {
199
+ log.debug(`[migration] Could not record dropped migrations as executed: ${e}`);
200
+ }
201
+ }
202
+ async function ensureDatabaseExists() {
203
+ const dialect = getDialect();
204
+ if (dialect === "sqlite")
205
+ return;
206
+ const connectionConfig = dbConfig.connections[dialect], dbName = (connectionConfig?.name || "stacks").replace(/['"]/g, ""), host = connectionConfig?.host || "localhost", port = connectionConfig?.port || (dialect === "postgres" ? 5432 : 3306), username = connectionConfig?.username || (dialect === "postgres" ? process.env.USER || "postgres" : "root"), password = connectionConfig?.password || "", adminDatabase = dialect === "postgres" ? "postgres" : "mysql";
207
+ try {
208
+ setConfig({
209
+ dialect,
210
+ database: {
211
+ database: adminDatabase,
212
+ host,
213
+ port,
214
+ username,
215
+ password
216
+ }
217
+ });
218
+ resetConnection();
219
+ const adminDb = createQueryBuilder();
220
+ if (dialect === "postgres")
221
+ try {
222
+ await adminDb.unsafe(`CREATE DATABASE "${dbName}"`);
223
+ log.info(`Created database "${dbName}"`);
224
+ } catch (e) {
225
+ if (e?.message?.includes("already exists") || e?.errno === "42P04")
226
+ log.info(`Database "${dbName}" already exists`);
227
+ else
228
+ throw e;
229
+ }
230
+ else if (dialect === "mysql")
231
+ try {
232
+ await adminDb.unsafe(`CREATE DATABASE IF NOT EXISTS \`${dbName}\``);
233
+ log.info(`Ensured database "${dbName}" exists`);
234
+ } catch (e) {
235
+ if (e?.message?.includes("database exists"))
236
+ log.info(`Database "${dbName}" already exists`);
237
+ else
238
+ throw e;
239
+ }
240
+ resetConnection();
241
+ } catch (error) {
242
+ log.warn(`Could not auto-create database "${dbName}": ${error?.message || error}`);
243
+ log.info("If the database already exists, this warning can be ignored.");
244
+ resetConnection();
245
+ }
246
+ }
247
+ async function hideDisabledFeatureMigrations() {
248
+ const hidden = [];
249
+ try {
250
+ const { FEATURE_NAMES, migrationFeature } = await import("@stacksjs/buddy"), { feature: isFeatureEnabled } = await import("@stacksjs/config"), fs = await import("node:fs/promises"), migrationsDir = path.projectPath("database/migrations");
251
+ if (!existsSync(migrationsDir))
252
+ return hidden;
253
+ const disabledFeatures = new Set(FEATURE_NAMES.filter((f) => !isFeatureEnabled(f)));
254
+ if (disabledFeatures.size === 0)
255
+ return hidden;
256
+ const files = readdirSync(migrationsDir).filter((f) => f.endsWith(".sql"));
257
+ for (const file of files) {
258
+ const owner = migrationFeature(file);
259
+ if (!owner || !disabledFeatures.has(owner))
260
+ continue;
261
+ const original = join(migrationsDir, file), hiddenPath = `${original}.disabled`;
262
+ await fs.rename(original, hiddenPath);
263
+ hidden.push({ original, hidden: hiddenPath, feature: owner });
264
+ }
265
+ if (hidden.length > 0) {
266
+ const summary = Object.entries(hidden.reduce((acc, h) => {
267
+ acc[h.feature] = (acc[h.feature] ?? 0) + 1;
268
+ return acc;
269
+ }, {})).map(([f, n]) => `${f}: ${n}`).join(", ");
270
+ log.info(`[migration] Skipping ${hidden.length} migration(s) for disabled features (${summary}). Run \`./buddy <feature>:install\` to enable.`);
271
+ }
272
+ } catch {}
273
+ return hidden;
274
+ }
275
+ async function restoreHiddenMigrations(hidden) {
276
+ const fs = await import("node:fs/promises");
277
+ for (const { original, hidden: h } of hidden)
278
+ try {
279
+ await fs.rename(h, original);
280
+ } catch {}
281
+ }
282
+ async function countAppliedMigrations() {
283
+ try {
284
+ const row = await db.selectFrom("migrations").select((eb) => eb.fn.count("id").as("n")).executeTakeFirst();
285
+ if (!row)
286
+ return 0;
287
+ const n = Number(row.n ?? row.N ?? 0);
288
+ return Number.isFinite(n) ? n : 0;
289
+ } catch {
290
+ return 0;
291
+ }
292
+ }
293
+ async function writeMigrateMarker(appliedCount) {
294
+ try {
295
+ const fs = await import("node:fs/promises"), dir = path.projectPath(".stacks");
296
+ await fs.mkdir(dir, { recursive: !0 });
297
+ const file = `${dir}/last-migrate-result.json`, body = JSON.stringify({
298
+ appliedCount,
299
+ completedAt: new Date().toISOString()
300
+ });
301
+ await fs.writeFile(file, body, "utf8");
302
+ } catch {}
303
+ }
304
+ export async function runDatabaseMigration() {
305
+ const startedAt = Date.now(), hidden = await hideDisabledFeatureMigrations();
306
+ let lockHandle = null;
307
+ try {
308
+ log.debug("Migrating database...");
309
+ await ensureDatabaseExists();
310
+ configureQueryBuilder();
311
+ const dialect = getDialect(), lockDb = dialect === "sqlite" ? null : createQueryBuilder();
312
+ lockHandle = await acquireMigrationLock(dialect, lockDb);
313
+ if (dialect === "sqlite")
314
+ preprocessSqliteMigrations();
315
+ const modelsDir = path.userModelsPath(), appliedBefore = await countAppliedMigrations();
316
+ log.debug(`[migration] Running migrations from: ${modelsDir}`);
317
+ await qbExecuteMigration(modelsDir);
318
+ const appliedAfter = await countAppliedMigrations(), appliedCount = Math.max(0, appliedAfter - appliedBefore);
319
+ await writeMigrateMarker(appliedCount);
320
+ log.debug(`Database migration completed in ${Date.now() - startedAt}ms (applied ${appliedCount}).`);
321
+ return ok(appliedCount === 0 ? "Nothing to migrate." : `Applied ${appliedCount} migration${appliedCount === 1 ? "" : "s"}.`);
322
+ } catch (error) {
323
+ const detail = error instanceof Error ? error.message : String(error);
324
+ log.error(`[migration] Failed after ${Date.now() - startedAt}ms: ${detail}`);
325
+ log.info("[migration] Run `./buddy migrate:fresh` to drop and recreate the schema if state is partial.");
326
+ return err(handleError("Migration failed", error));
327
+ } finally {
328
+ if (lockHandle)
329
+ try {
330
+ await lockHandle.release();
331
+ } catch {}
332
+ await restoreHiddenMigrations(hidden);
333
+ }
334
+ }
335
+ const FRAMEWORK_TABLES = [
336
+ "oauth_refresh_tokens",
337
+ "oauth_access_tokens",
338
+ "oauth_clients",
339
+ "passkeys",
340
+ "failed_jobs",
341
+ "jobs",
342
+ "notifications",
343
+ "password_reset_tokens"
344
+ ];
345
+ export async function resetDatabase() {
346
+ try {
347
+ configureQueryBuilder();
348
+ const modelsDir = path.userModelsPath(), dialect = getDialect();
349
+ await dropFrameworkTables(dialect);
350
+ await qbResetDatabase(modelsDir, { dialect });
351
+ return ok("All tables dropped successfully!");
352
+ } catch (error) {
353
+ return err(handleError("Database reset failed", error));
354
+ }
355
+ }
356
+ async function dropFrameworkTables(dialect) {
357
+ if (dialect === "mysql")
358
+ try {
359
+ await db.unsafe("SET FOREIGN_KEY_CHECKS = 0").execute();
360
+ } catch (error) {
361
+ log.warn(`Could not disable foreign key checks: ${error instanceof Error ? error.message : String(error)}`);
362
+ }
363
+ if (dialect === "sqlite")
364
+ try {
365
+ await db.unsafe("PRAGMA foreign_keys = OFF").execute();
366
+ } catch (error) {
367
+ log.warn(`Could not disable foreign key checks: ${error instanceof Error ? error.message : String(error)}`);
368
+ }
369
+ for (const tableName of FRAMEWORK_TABLES)
370
+ try {
371
+ let dropSql;
372
+ if (dialect === "postgres")
373
+ dropSql = `DROP TABLE IF EXISTS "${tableName}" CASCADE`;
374
+ else if (dialect === "mysql")
375
+ dropSql = `DROP TABLE IF EXISTS \`${tableName}\``;
376
+ else
377
+ dropSql = `DROP TABLE IF EXISTS "${tableName}"`;
378
+ log.info(`Dropping framework table: ${tableName}`);
379
+ await db.unsafe(dropSql).execute();
380
+ log.info(`Dropped framework table: ${tableName}`);
381
+ } catch (error) {
382
+ log.warn(`Could not drop table ${tableName}: ${error instanceof Error ? error.message : String(error)}`);
383
+ }
384
+ if (dialect === "mysql")
385
+ try {
386
+ await db.unsafe("SET FOREIGN_KEY_CHECKS = 1").execute();
387
+ } catch (error) {
388
+ log.warn(`Could not re-enable foreign key checks: ${error instanceof Error ? error.message : String(error)}`);
389
+ }
390
+ if (dialect === "sqlite")
391
+ try {
392
+ await db.unsafe("PRAGMA foreign_keys = ON").execute();
393
+ } catch (error) {
394
+ log.warn(`Could not re-enable foreign key checks: ${error instanceof Error ? error.message : String(error)}`);
395
+ }
396
+ }
397
+ function resolveGenerateOptions(options) {
398
+ 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);
399
+ return { applyRenames, fromDb };
400
+ }
401
+ export async function previewPendingMigrations(options = {}) {
402
+ try {
403
+ configureQueryBuilder();
404
+ const dialect = getDialect(), { modelsDir, skip } = prepareMigrationModelsDir();
405
+ if (skip)
406
+ return [];
407
+ const { applyRenames, fromDb } = resolveGenerateOptions(options);
408
+ return (await qbGenerateMigration(modelsDir, { dialect: getQbDialect(), dryRun: !0, applyRenames, fromDb })).operations ?? [];
409
+ } catch (error) {
410
+ log.debug(`[migration] preview failed: ${error instanceof Error ? error.message : String(error)}`);
411
+ return [];
412
+ }
413
+ }
414
+ export async function generateMigrations(options = {}) {
415
+ try {
416
+ log.debug("Generating migrations...");
417
+ configureQueryBuilder();
418
+ const dialect = getDialect(), { modelsDir, skip } = prepareMigrationModelsDir();
419
+ if (skip) {
420
+ log.debug("No app/Models directory found; using committed framework migrations");
421
+ return ok("Migrations generated");
422
+ }
423
+ const { applyRenames, fromDb } = resolveGenerateOptions(options);
424
+ log.debug(`[migration] Generating migrations for dialect: ${dialect}, models: ${modelsDir}`);
425
+ const result = await qbGenerateMigration(modelsDir, { dialect: getQbDialect(), dryRun: !0, applyRenames, fromDb });
426
+ if (result.hasChanges) {
427
+ const written = persistGeneratedMigrations(result.sqlStatements ?? []);
428
+ if (written > 0)
429
+ log.success(`Generated ${written} migration file${written === 1 ? "" : "s"}`);
430
+ else
431
+ log.debug("Migration generation produced no new files (already up to date)");
432
+ } else
433
+ log.debug("No changes detected");
434
+ return ok("Migrations generated");
435
+ } catch (error) {
436
+ return err(handleError("Migration generation failed", error));
437
+ }
438
+ }
439
+ function persistGeneratedMigrations(sqlStatements) {
440
+ if (!sqlStatements?.length)
441
+ return 0;
442
+ const migrationsDir = join(process.cwd(), "database", "migrations");
443
+ try {
444
+ require("node:fs").mkdirSync(migrationsDir, { recursive: !0 });
445
+ } catch {}
446
+ let existingSql = "";
447
+ try {
448
+ for (const f of readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")))
449
+ existingSql += `
450
+ ${readFileSync(join(migrationsDir, f), "utf8")}`;
451
+ } catch {}
452
+ const normalize = (s) => s.replace(/\s+/g, " ").trim(), haystack = normalize(existingSql), groups = groupGeneratedStatements(sqlStatements);
453
+ let written = 0, cursor = nextMigrationNumber(migrationsDir);
454
+ for (const group of groups) {
455
+ const fresh = group.statements.filter((stmt) => !haystack.includes(normalize(stmt)));
456
+ if (fresh.length === 0)
457
+ continue;
458
+ const filename = `${String(cursor).padStart(10, "0")}-${group.label}.sql`, filePath = join(migrationsDir, filename), body = `${fresh.map((s) => s.trim().replace(/;\s*$/, "")).join(`;
459
+ `)};
460
+ `;
461
+ writeFileSync(filePath, body);
462
+ log.debug(`[migration] Wrote ${filename} (${fresh.length} stmt${fresh.length === 1 ? "" : "s"})`);
463
+ written += 1;
464
+ cursor += 1;
465
+ }
466
+ return written;
467
+ }
468
+ function groupGeneratedStatements(sqlStatements) {
469
+ const groups = new Map, push = (label, stmt) => {
470
+ const list = groups.get(label) ?? [];
471
+ list.push(stmt);
472
+ groups.set(label, list);
473
+ };
474
+ for (const raw of sqlStatements) {
475
+ const stmt = raw.trim();
476
+ if (!stmt)
477
+ continue;
478
+ const create = stmt.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/i);
479
+ if (create) {
480
+ push(`create-${create[1]}-table`, stmt);
481
+ continue;
482
+ }
483
+ 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);
484
+ if (alter) {
485
+ push(`alter-${alter[1]}-${alter[2] || alter[3] || "constraint"}`, stmt);
486
+ continue;
487
+ }
488
+ const idx = stmt.match(/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?\s+ON\s+["`]?(\w+)["`]?/i);
489
+ if (idx) {
490
+ push(`create-${idx[1]}-index-in-${idx[2]}`, stmt);
491
+ continue;
492
+ }
493
+ const drop = stmt.match(/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["`]?(\w+)["`]?/i);
494
+ if (drop) {
495
+ push(`drop-${drop[1]}-table`, stmt);
496
+ continue;
497
+ }
498
+ push("auto-misc", stmt);
499
+ }
500
+ return [...groups.entries()].map(([label, statements]) => ({ label, statements }));
501
+ }
502
+ function nextMigrationNumber(migrationsDir) {
503
+ let max = 0;
504
+ try {
505
+ for (const f of readdirSync(migrationsDir)) {
506
+ const m = f.match(/^(\d+)-/);
507
+ if (m?.[1])
508
+ max = Math.max(max, Number.parseInt(m[1], 10));
509
+ }
510
+ } catch {}
511
+ return max + 1;
512
+ }
513
+ export async function generateMigrations2() {
514
+ try {
515
+ log.info("Generating fresh migrations...");
516
+ configureQueryBuilder();
517
+ const dialect = getDialect(), { modelsDir, skip } = prepareMigrationModelsDir();
518
+ if (skip) {
519
+ log.info("No app/Models directory found; using committed framework migrations");
520
+ return ok("Migrations generated");
521
+ }
522
+ await qbGenerateMigration(modelsDir, { dialect: getQbDialect(), full: !0, dryRun: !0 });
523
+ log.success("Migrations generated");
524
+ return ok("Migrations generated");
525
+ } catch (error) {
526
+ return err(handleError("Fresh migration generation failed", error));
527
+ }
528
+ }