@stacksjs/database 0.70.88 → 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.
- package/dist/auth-tables.d.ts +60 -0
- package/dist/auth-tables.js +220 -0
- package/dist/class-seeder.d.ts +65 -0
- package/dist/class-seeder.js +116 -0
- package/dist/column.d.ts +17 -0
- package/dist/column.js +26 -0
- package/dist/custom/audits.d.ts +16 -0
- package/dist/custom/audits.js +57 -0
- package/dist/custom/errors.d.ts +1 -0
- package/dist/custom/errors.js +48 -0
- package/dist/custom/index.d.ts +3 -0
- package/dist/custom/index.js +3 -0
- package/dist/custom/jobs.d.ts +3 -0
- package/dist/custom/jobs.js +449 -0
- package/dist/database.d.ts +89 -0
- package/dist/database.js +178 -0
- package/dist/defaults.d.ts +48 -0
- package/dist/defaults.js +48 -0
- package/dist/driver-config.d.ts +149 -0
- package/dist/driver-config.js +144 -0
- package/dist/drivers/defaults/index.d.ts +2 -0
- package/dist/drivers/defaults/index.js +2 -0
- package/dist/drivers/defaults/passwords.d.ts +4 -0
- package/dist/drivers/defaults/passwords.js +106 -0
- package/dist/drivers/defaults/traits.d.ts +33 -0
- package/dist/drivers/defaults/traits.js +1125 -0
- package/dist/drivers/dynamodb.d.ts +200 -0
- package/dist/drivers/dynamodb.js +607 -0
- package/dist/drivers/helpers.d.ts +35 -0
- package/dist/drivers/helpers.js +206 -0
- package/dist/drivers/index.d.ts +16 -0
- package/dist/drivers/index.js +9 -0
- package/dist/drivers/mysql.d.ts +7 -0
- package/dist/drivers/mysql.js +322 -0
- package/dist/drivers/postgres.d.ts +7 -0
- package/dist/drivers/postgres.js +411 -0
- package/dist/drivers/sqlite.d.ts +20 -0
- package/dist/drivers/sqlite.js +397 -0
- package/dist/factory.d.ts +41 -0
- package/dist/factory.js +51 -0
- package/dist/fk-audit.d.ts +101 -0
- package/dist/fk-audit.js +181 -0
- package/dist/index.d.ts +149 -0
- package/dist/index.js +55 -0
- package/dist/migration-lock.d.ts +23 -0
- package/dist/migration-lock.js +143 -0
- package/dist/migrations.d.ts +76 -0
- package/dist/migrations.js +528 -0
- package/dist/notification-tables.d.ts +20 -0
- package/dist/notification-tables.js +54 -0
- package/dist/query-logger.d.ts +26 -0
- package/dist/query-logger.js +213 -0
- package/dist/query-parser.d.ts +4 -0
- package/dist/query-parser.js +93 -0
- package/dist/rbac-tables.d.ts +17 -0
- package/dist/rbac-tables.js +84 -0
- package/dist/safe-migrations.d.ts +72 -0
- package/dist/safe-migrations.js +59 -0
- package/dist/schema.d.ts +4 -0
- package/dist/schema.js +10 -0
- package/dist/seed-scaffold.d.ts +34 -0
- package/dist/seed-scaffold.js +144 -0
- package/dist/seeder.d.ts +116 -0
- package/dist/seeder.js +363 -0
- package/dist/sql-helpers.d.ts +33 -0
- package/dist/sql-helpers.js +24 -0
- package/dist/table.d.ts +7 -0
- package/dist/table.js +26 -0
- package/dist/tools/setup.d.ts +1 -0
- package/dist/tools/setup.js +6 -0
- package/dist/transaction-context.d.ts +52 -0
- package/dist/transaction-context.js +62 -0
- package/dist/types.d.ts +151 -0
- package/dist/types.js +23 -0
- package/dist/unique-audit.d.ts +60 -0
- package/dist/unique-audit.js +174 -0
- package/dist/utils.d.ts +189 -0
- package/dist/utils.js +163 -0
- package/dist/uuid-columns.d.ts +22 -0
- package/dist/uuid-columns.js +68 -0
- package/dist/validators.d.ts +26 -0
- package/dist/validators.js +122 -0
- package/package.json +11 -11
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { sqlHelpers } from './sql-helpers';
|
|
2
|
+
/**
|
|
3
|
+
* `CREATE TABLE IF NOT EXISTS notifications` for the given dialect.
|
|
4
|
+
* Pure (no execution) so the cross-dialect DDL is unit-testable.
|
|
5
|
+
* Columns match the `DatabaseNotification` interface in
|
|
6
|
+
* `notifications/src/drivers/database.ts`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function notificationsTableSql(sql: SqlHelpers): string;
|
|
9
|
+
/**
|
|
10
|
+
* `CREATE TABLE IF NOT EXISTS notification_preferences`. The
|
|
11
|
+
* `UNIQUE (user_id, channel, category)` constraint is what makes the
|
|
12
|
+
* preference upsert safe — matches `NotificationPreferenceRow`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function notificationPreferencesTableSql(sql: SqlHelpers): string;
|
|
15
|
+
/**
|
|
16
|
+
* Create the notification + notification_preferences tables. Idempotent
|
|
17
|
+
* (`IF NOT EXISTS`), so it's safe to run on every `buddy migrate`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function migrateNotificationTables(options?: { verbose?: boolean }): Promise<{ success: boolean, error?: string }>;
|
|
20
|
+
declare type SqlHelpers = ReturnType<typeof sqlHelpers>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { log } from "@stacksjs/logging";
|
|
2
|
+
import { env as envVars } from "@stacksjs/env";
|
|
3
|
+
import { db } from "./utils";
|
|
4
|
+
import { sqlHelpers } from "./sql-helpers";
|
|
5
|
+
function getDbDriver() {
|
|
6
|
+
return envVars.DB_CONNECTION || "sqlite";
|
|
7
|
+
}
|
|
8
|
+
export function notificationsTableSql(sql) {
|
|
9
|
+
const { pkColumn, nullableTimestamp } = sql;
|
|
10
|
+
return `CREATE TABLE IF NOT EXISTS notifications (
|
|
11
|
+
${pkColumn},
|
|
12
|
+
user_id INTEGER NOT NULL,
|
|
13
|
+
type VARCHAR(255) NOT NULL,
|
|
14
|
+
data TEXT NOT NULL,
|
|
15
|
+
read_at ${nullableTimestamp},
|
|
16
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
17
|
+
updated_at ${nullableTimestamp}
|
|
18
|
+
)`;
|
|
19
|
+
}
|
|
20
|
+
export function notificationPreferencesTableSql(sql) {
|
|
21
|
+
const { pkColumn, boolTrue, nullableTimestamp } = sql;
|
|
22
|
+
return `CREATE TABLE IF NOT EXISTS notification_preferences (
|
|
23
|
+
${pkColumn},
|
|
24
|
+
user_id INTEGER NOT NULL,
|
|
25
|
+
channel VARCHAR(50) NOT NULL,
|
|
26
|
+
enabled BOOLEAN NOT NULL DEFAULT ${boolTrue},
|
|
27
|
+
category VARCHAR(255),
|
|
28
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
29
|
+
updated_at ${nullableTimestamp},
|
|
30
|
+
UNIQUE (user_id, channel, category)
|
|
31
|
+
)`;
|
|
32
|
+
}
|
|
33
|
+
export async function migrateNotificationTables(options = {}) {
|
|
34
|
+
const dbDriver = getDbDriver(), sql = sqlHelpers(dbDriver);
|
|
35
|
+
if (options.verbose)
|
|
36
|
+
log.info(`Creating notification tables for ${dbDriver}...`);
|
|
37
|
+
try {
|
|
38
|
+
if (options.verbose)
|
|
39
|
+
log.info("Creating notifications table...");
|
|
40
|
+
await db.unsafe(notificationsTableSql(sql)).execute();
|
|
41
|
+
await db.unsafe("CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_id)").execute();
|
|
42
|
+
if (options.verbose)
|
|
43
|
+
log.info("Creating notification_preferences table...");
|
|
44
|
+
await db.unsafe(notificationPreferencesTableSql(sql)).execute();
|
|
45
|
+
await db.unsafe("CREATE INDEX IF NOT EXISTS idx_notification_preferences_user ON notification_preferences (user_id)").execute();
|
|
46
|
+
if (options.verbose)
|
|
47
|
+
log.success("Notification tables created");
|
|
48
|
+
return { success: !0 };
|
|
49
|
+
} catch (error) {
|
|
50
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
51
|
+
log.error(`Failed to create notification tables: ${message}`);
|
|
52
|
+
return { success: !1, error: message };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare function setQueryTracker(fn: QueryTracker): void;
|
|
2
|
+
/**
|
|
3
|
+
* Process an executed query and store it in the database
|
|
4
|
+
*/
|
|
5
|
+
export declare function logQuery(event: LogEvent): Promise<void>;
|
|
6
|
+
/**
|
|
7
|
+
* Query log event type - compatible with bun-query-builder hooks
|
|
8
|
+
*/
|
|
9
|
+
declare interface LogEvent {
|
|
10
|
+
query?: {
|
|
11
|
+
sql?: string
|
|
12
|
+
parameters?: unknown[]
|
|
13
|
+
}
|
|
14
|
+
queryDurationMillis?: number
|
|
15
|
+
error?: Error | unknown
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Soft dependency on the router's query tracker. Importing it directly
|
|
19
|
+
* creates the cycle `database → router → database` (router uses db
|
|
20
|
+
* helpers transitively via middleware). The DI shape below lets the
|
|
21
|
+
* router register its tracker at module-init time and lets the database
|
|
22
|
+
* package stay leaf-node — runs that don't load the router (CLI tools,
|
|
23
|
+
* cron tasks) silently no-op the tracker call.
|
|
24
|
+
*/
|
|
25
|
+
// eslint-disable-next-line pickier/no-unused-vars
|
|
26
|
+
declare type QueryTracker = (query: string, durationMs?: number, connection?: string) => void;
|