@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
package/dist/fk-audit.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { plural, singular, snakeCase } from "@stacksjs/strings";
|
|
4
|
+
import { path } from "@stacksjs/path";
|
|
5
|
+
import { globSync } from "@stacksjs/storage";
|
|
6
|
+
export function safeGlob(pattern) {
|
|
7
|
+
const metaIdx = pattern.search(/[*?[]/), root = metaIdx === -1 ? dirname(pattern) : dirname(pattern.slice(0, metaIdx));
|
|
8
|
+
if (!existsSync(root))
|
|
9
|
+
return [];
|
|
10
|
+
try {
|
|
11
|
+
return globSync(pattern, { absolute: !0 });
|
|
12
|
+
} catch {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export async function getDeclaredFKs() {
|
|
17
|
+
const modelFiles = [
|
|
18
|
+
...safeGlob(path.userModelsPath("*.ts")),
|
|
19
|
+
...safeGlob(path.storagePath("framework/defaults/app/Models/**/*.ts"))
|
|
20
|
+
], declared = [];
|
|
21
|
+
for (const modelFile of modelFiles) {
|
|
22
|
+
let model;
|
|
23
|
+
try {
|
|
24
|
+
model = (await import(modelFile)).default;
|
|
25
|
+
} catch {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (!model || typeof model !== "object")
|
|
29
|
+
continue;
|
|
30
|
+
const fromTable = model.table || plural(snakeCase(model.name || "")), belongsTo = model.belongsTo;
|
|
31
|
+
if (Array.isArray(belongsTo))
|
|
32
|
+
for (const entry of belongsTo) {
|
|
33
|
+
const related = typeof entry === "string" ? entry : entry?.model ?? "";
|
|
34
|
+
if (!related)
|
|
35
|
+
continue;
|
|
36
|
+
const fromColumn = `${snakeCase(singular(related))}_id`, toTable = plural(snakeCase(related));
|
|
37
|
+
declared.push({
|
|
38
|
+
fromTable,
|
|
39
|
+
fromColumn,
|
|
40
|
+
toTable,
|
|
41
|
+
toColumn: "id",
|
|
42
|
+
model: model.name || ""
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
else if (belongsTo && typeof belongsTo === "object")
|
|
46
|
+
for (const related of Object.keys(belongsTo)) {
|
|
47
|
+
const fromColumn = `${snakeCase(singular(related))}_id`, toTable = plural(snakeCase(related));
|
|
48
|
+
declared.push({
|
|
49
|
+
fromTable,
|
|
50
|
+
fromColumn,
|
|
51
|
+
toTable,
|
|
52
|
+
toColumn: "id",
|
|
53
|
+
model: model.name || ""
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return declared;
|
|
58
|
+
}
|
|
59
|
+
export async function getLiveFKs() {
|
|
60
|
+
const { db } = await import("./utils"), dialect = await currentDialect();
|
|
61
|
+
if (dialect === "sqlite")
|
|
62
|
+
return getSqliteLiveFKs(db);
|
|
63
|
+
if (dialect === "mysql")
|
|
64
|
+
return getMysqlLiveFKs(db);
|
|
65
|
+
if (dialect === "postgres")
|
|
66
|
+
return getPostgresLiveFKs(db);
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
export async function auditForeignKeys() {
|
|
70
|
+
const declared = await getDeclaredFKs(), live = await getLiveFKs(), liveKeys = new Set(live.map((fk) => `${fk.fromTable.toLowerCase()}.${fk.fromColumn.toLowerCase()}\u2192${fk.toTable.toLowerCase()}.${fk.toColumn.toLowerCase()}`)), missing = declared.filter((d) => {
|
|
71
|
+
const key = `${d.fromTable.toLowerCase()}.${d.fromColumn.toLowerCase()}\u2192${d.toTable.toLowerCase()}.${d.toColumn.toLowerCase()}`;
|
|
72
|
+
return !liveKeys.has(key);
|
|
73
|
+
});
|
|
74
|
+
return { declared, live, missing };
|
|
75
|
+
}
|
|
76
|
+
export async function findFkOrphans(dialect) {
|
|
77
|
+
if ((dialect ?? await currentDialect()) !== "sqlite")
|
|
78
|
+
return { supported: !1, total: 0, orphans: [] };
|
|
79
|
+
const { db } = await import("./utils"), rows = await db.unsafe("PRAGMA foreign_key_check").execute(), checkRows = Array.isArray(rows) ? rows : [], fkListCache = new Map;
|
|
80
|
+
async function fkListFor(table) {
|
|
81
|
+
if (fkListCache.has(table))
|
|
82
|
+
return fkListCache.get(table);
|
|
83
|
+
if (!/^[a-z_]\w*$/i.test(table)) {
|
|
84
|
+
fkListCache.set(table, []);
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
const list = await db.unsafe(`PRAGMA foreign_key_list("${table}")`).execute(), arr = Array.isArray(list) ? list : [];
|
|
88
|
+
fkListCache.set(table, arr);
|
|
89
|
+
return arr;
|
|
90
|
+
}
|
|
91
|
+
const grouped = new Map;
|
|
92
|
+
for (const raw of checkRows) {
|
|
93
|
+
const r = raw, table = String(r.table ?? ""), parent = String(r.parent ?? "");
|
|
94
|
+
if (!table || !parent)
|
|
95
|
+
continue;
|
|
96
|
+
const fkid = Number(r.fkid ?? 0);
|
|
97
|
+
let column = "";
|
|
98
|
+
const fkList = await fkListFor(table);
|
|
99
|
+
for (const fk of fkList) {
|
|
100
|
+
const f = fk;
|
|
101
|
+
if (Number(f.id) === fkid && f.from) {
|
|
102
|
+
column = String(f.from);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const key = `${table}\x00${parent}\x00${fkid}`;
|
|
107
|
+
let entry = grouped.get(key);
|
|
108
|
+
if (!entry) {
|
|
109
|
+
entry = { table, column, parent, count: 0, sampleRowids: [] };
|
|
110
|
+
grouped.set(key, entry);
|
|
111
|
+
}
|
|
112
|
+
entry.count++;
|
|
113
|
+
if (entry.sampleRowids.length < 5 && typeof r.rowid === "number")
|
|
114
|
+
entry.sampleRowids.push(r.rowid);
|
|
115
|
+
}
|
|
116
|
+
const orphans = [...grouped.values()];
|
|
117
|
+
return { supported: !0, total: orphans.reduce((sum, o) => sum + o.count, 0), orphans };
|
|
118
|
+
}
|
|
119
|
+
async function currentDialect() {
|
|
120
|
+
const driver = ((await import("@stacksjs/env")).env?.DB_CONNECTION ?? "sqlite").toLowerCase();
|
|
121
|
+
if (driver === "sqlite" || driver === "mysql" || driver === "postgres")
|
|
122
|
+
return driver;
|
|
123
|
+
return "other";
|
|
124
|
+
}
|
|
125
|
+
async function getSqliteLiveFKs(db) {
|
|
126
|
+
const tables = await db.unsafe("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"), rows = Array.isArray(tables) ? tables : [], fks = [];
|
|
127
|
+
for (const row of rows) {
|
|
128
|
+
const fromTable = row.name;
|
|
129
|
+
if (!fromTable)
|
|
130
|
+
continue;
|
|
131
|
+
if (!/^[a-z_][\w]*$/i.test(fromTable))
|
|
132
|
+
continue;
|
|
133
|
+
const fkRows = await db.unsafe(`PRAGMA foreign_key_list("${fromTable}")`);
|
|
134
|
+
for (const fk of Array.isArray(fkRows) ? fkRows : []) {
|
|
135
|
+
const r = fk;
|
|
136
|
+
if ((r.seq ?? 0) !== 0)
|
|
137
|
+
continue;
|
|
138
|
+
if (!r.from || !r.to || !r.table)
|
|
139
|
+
continue;
|
|
140
|
+
fks.push({ fromTable, fromColumn: r.from, toTable: r.table, toColumn: r.to });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return fks;
|
|
144
|
+
}
|
|
145
|
+
async function getMysqlLiveFKs(db) {
|
|
146
|
+
const rows = await db.unsafe(`
|
|
147
|
+
SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME
|
|
148
|
+
FROM information_schema.KEY_COLUMN_USAGE
|
|
149
|
+
WHERE TABLE_SCHEMA = DATABASE()
|
|
150
|
+
AND REFERENCED_TABLE_NAME IS NOT NULL
|
|
151
|
+
`);
|
|
152
|
+
return (Array.isArray(rows) ? rows : []).map((row) => ({
|
|
153
|
+
fromTable: String(row.TABLE_NAME ?? row.table_name ?? ""),
|
|
154
|
+
fromColumn: String(row.COLUMN_NAME ?? row.column_name ?? ""),
|
|
155
|
+
toTable: String(row.REFERENCED_TABLE_NAME ?? row.referenced_table_name ?? ""),
|
|
156
|
+
toColumn: String(row.REFERENCED_COLUMN_NAME ?? row.referenced_column_name ?? "")
|
|
157
|
+
})).filter((fk) => fk.fromTable && fk.fromColumn && fk.toTable && fk.toColumn);
|
|
158
|
+
}
|
|
159
|
+
async function getPostgresLiveFKs(db) {
|
|
160
|
+
const rows = await db.unsafe(`
|
|
161
|
+
SELECT
|
|
162
|
+
kcu.table_name AS from_table,
|
|
163
|
+
kcu.column_name AS from_column,
|
|
164
|
+
ccu.table_name AS to_table,
|
|
165
|
+
ccu.column_name AS to_column
|
|
166
|
+
FROM information_schema.referential_constraints rc
|
|
167
|
+
JOIN information_schema.key_column_usage kcu
|
|
168
|
+
ON kcu.constraint_name = rc.constraint_name
|
|
169
|
+
AND kcu.constraint_schema = rc.constraint_schema
|
|
170
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
171
|
+
ON ccu.constraint_name = rc.constraint_name
|
|
172
|
+
AND ccu.constraint_schema = rc.constraint_schema
|
|
173
|
+
WHERE rc.constraint_schema = 'public'
|
|
174
|
+
`);
|
|
175
|
+
return (Array.isArray(rows) ? rows : []).map((row) => ({
|
|
176
|
+
fromTable: String(row.from_table ?? ""),
|
|
177
|
+
fromColumn: String(row.from_column ?? ""),
|
|
178
|
+
toTable: String(row.to_table ?? ""),
|
|
179
|
+
toColumn: String(row.to_column ?? "")
|
|
180
|
+
})).filter((fk) => fk.fromTable && fk.fromColumn && fk.toTable && fk.toColumn);
|
|
181
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
DatabaseConnectionConfig,
|
|
3
|
+
DatabaseOptions,
|
|
4
|
+
} from './database';
|
|
5
|
+
export type {
|
|
6
|
+
DatabaseConnections,
|
|
7
|
+
DynamoDbConfig,
|
|
8
|
+
FullDatabaseConfig,
|
|
9
|
+
MysqlConfig,
|
|
10
|
+
PostgresConfig,
|
|
11
|
+
SqliteConfig,
|
|
12
|
+
} from './driver-config';
|
|
13
|
+
export type { GenerateOptions } from './factory';
|
|
14
|
+
export type { ScaffoldOptions, ScaffoldResult } from './seed-scaffold';
|
|
15
|
+
export type { DeclaredFK, FkAuditResult, FkOrphan, FkOrphanReport, LiveFK } from './fk-audit';
|
|
16
|
+
export type { DeclaredUnique, LiveUniqueIndex, UniqueAuditResult } from './unique-audit';
|
|
17
|
+
export type {
|
|
18
|
+
QueryBuilder,
|
|
19
|
+
QueryBuilderConfig,
|
|
20
|
+
Seeder as QueryBuilderSeeder,
|
|
21
|
+
SupportedDialect,
|
|
22
|
+
} from '@stacksjs/query-builder';
|
|
23
|
+
export type {
|
|
24
|
+
DynamoConnectionConfig,
|
|
25
|
+
SingleTableEntityMapping,
|
|
26
|
+
SortKeyBuilder,
|
|
27
|
+
BatchWriteOperation,
|
|
28
|
+
TransactWriteOperation,
|
|
29
|
+
QueryResult,
|
|
30
|
+
} from './drivers/dynamodb';
|
|
31
|
+
/**
|
|
32
|
+
* @stacksjs/database
|
|
33
|
+
*
|
|
34
|
+
* Database module powered by bun-query-builder.
|
|
35
|
+
* Provides database initialization, driver configuration, migrations,
|
|
36
|
+
* seeding, and a fluent query builder interface.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* import { Database, db, createSqliteDatabase } from '@stacksjs/database'
|
|
41
|
+
*
|
|
42
|
+
* // Use the default db instance (configured from environment)
|
|
43
|
+
* const users = await db.selectFrom('users').where('active', '=', true).get()
|
|
44
|
+
*
|
|
45
|
+
* // Or create a custom database instance
|
|
46
|
+
* const customDb = new Database({
|
|
47
|
+
* driver: 'postgres',
|
|
48
|
+
* connection: {
|
|
49
|
+
* database: 'myapp',
|
|
50
|
+
* host: 'localhost',
|
|
51
|
+
* port: 5432,
|
|
52
|
+
* username: 'postgres',
|
|
53
|
+
* password: 'secret'
|
|
54
|
+
* }
|
|
55
|
+
* })
|
|
56
|
+
*
|
|
57
|
+
* // Helper functions for quick setup
|
|
58
|
+
* const sqliteDb = createSqliteDatabase('database/app.sqlite')
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
// Database initialization and management
|
|
62
|
+
export {
|
|
63
|
+
Database,
|
|
64
|
+
createDatabase,
|
|
65
|
+
createMysqlDatabase,
|
|
66
|
+
createPostgresDatabase,
|
|
67
|
+
createSqliteDatabase,
|
|
68
|
+
} from './database';
|
|
69
|
+
// Driver configuration
|
|
70
|
+
export {
|
|
71
|
+
detectDriver,
|
|
72
|
+
driverDefaults,
|
|
73
|
+
getConfigFromEnv,
|
|
74
|
+
getConnectionString,
|
|
75
|
+
mergeWithDefaults,
|
|
76
|
+
validateDriverConfig,
|
|
77
|
+
} from './driver-config';
|
|
78
|
+
// Core database utilities and default instance
|
|
79
|
+
export * from './utils';
|
|
80
|
+
// Types (compatibility layer for Kysely types)
|
|
81
|
+
export * from './types';
|
|
82
|
+
// Migrations
|
|
83
|
+
export * from './migrations';
|
|
84
|
+
// Query logger DI hook (router calls setQueryTracker on init)
|
|
85
|
+
export { setQueryTracker, logQuery } from './query-logger';
|
|
86
|
+
// Class-based seeders (supplements the model-attribute auto-seeder)
|
|
87
|
+
export { Seeder, runClassSeeders } from './class-seeder';
|
|
88
|
+
// Zero-downtime migration helpers
|
|
89
|
+
export { addColumnSafely, backfillInBatches, renameColumnSafely } from './safe-migrations';
|
|
90
|
+
// Seeding
|
|
91
|
+
export * from './seeder';
|
|
92
|
+
// stacksjs/stacks#1919 — public factory API. The canonical replacement
|
|
93
|
+
// for the legacy `useSeeder` trait + auto-walker. Class seeders call
|
|
94
|
+
// `factory.generate(Model, opts)` explicitly so there's one
|
|
95
|
+
// orchestration layer per table, no double-fire on tables that have
|
|
96
|
+
// both a `useSeeder` trait and a class seeder file.
|
|
97
|
+
export { factory, generate as factoryGenerate } from './factory';
|
|
98
|
+
// `buddy seed:scaffold` codemod — generates class-seeder files for
|
|
99
|
+
// every model with a `useSeeder` trait, easing the migration off the
|
|
100
|
+
// auto-walker.
|
|
101
|
+
export { scaffoldClassSeedersFromModels, renderSeederFile } from './seed-scaffold';
|
|
102
|
+
// Driver utilities
|
|
103
|
+
export * from './drivers/index';
|
|
104
|
+
// Custom migrations (jobs, errors, etc.)
|
|
105
|
+
export * from './custom/index';
|
|
106
|
+
// Auth tables migration
|
|
107
|
+
export * from './auth-tables';
|
|
108
|
+
// uuid column guarantee for `useUuid` models (stacksjs/status#1 Phase 9)
|
|
109
|
+
export * from './uuid-columns';
|
|
110
|
+
// Notification tables migration (stacksjs/stacks#1937)
|
|
111
|
+
export { migrateNotificationTables } from './notification-tables';
|
|
112
|
+
// RBAC tables migration (stacksjs/stacks#1941 Phase A)
|
|
113
|
+
export { migrateRbacTables } from './rbac-tables';
|
|
114
|
+
// SQL dialect helpers & connection defaults
|
|
115
|
+
export * from './sql-helpers';
|
|
116
|
+
export * from './defaults';
|
|
117
|
+
// Foreign-key audit (stacksjs/stacks#1916) — compare declared
|
|
118
|
+
// `belongsTo` relationships against live FKs.
|
|
119
|
+
export { auditForeignKeys, findFkOrphans, getDeclaredFKs, getLiveFKs } from './fk-audit';
|
|
120
|
+
// Unique-index drift audit (stacksjs/stacks#1952) — compare declared
|
|
121
|
+
// `unique: true` attributes / indexes against live UNIQUE indexes.
|
|
122
|
+
export { auditUniqueIndexes, getDeclaredUniques, getLiveUniqueIndexes } from './unique-audit';
|
|
123
|
+
// Transaction context: AsyncLocalStorage-based scope so side-effect
|
|
124
|
+
// emitters (queue dispatch, mailer send) can buffer themselves
|
|
125
|
+
// until the surrounding `db.transaction(...)` commits
|
|
126
|
+
// (stacksjs/stacks#1882).
|
|
127
|
+
export {
|
|
128
|
+
__flushAfterCommitNow,
|
|
129
|
+
__pendingAfterCommitCount,
|
|
130
|
+
enqueueAfterCommit,
|
|
131
|
+
isInTransaction,
|
|
132
|
+
runInTransactionScope,
|
|
133
|
+
} from './transaction-context';
|
|
134
|
+
// Re-export bun-query-builder functions and types
|
|
135
|
+
export {
|
|
136
|
+
createQueryBuilder,
|
|
137
|
+
setConfig,
|
|
138
|
+
} from '@stacksjs/query-builder';
|
|
139
|
+
// DynamoDB entity-centric API
|
|
140
|
+
export {
|
|
141
|
+
createDynamo,
|
|
142
|
+
dynamo,
|
|
143
|
+
EntityQueryBuilder,
|
|
144
|
+
generateKeyPattern,
|
|
145
|
+
parseKeyPattern,
|
|
146
|
+
buildKey,
|
|
147
|
+
marshall,
|
|
148
|
+
unmarshall,
|
|
149
|
+
} from './drivers/dynamodb';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export {
|
|
2
|
+
Database,
|
|
3
|
+
createDatabase,
|
|
4
|
+
createMysqlDatabase,
|
|
5
|
+
createPostgresDatabase,
|
|
6
|
+
createSqliteDatabase
|
|
7
|
+
} from "./database";
|
|
8
|
+
export {
|
|
9
|
+
detectDriver,
|
|
10
|
+
driverDefaults,
|
|
11
|
+
getConfigFromEnv,
|
|
12
|
+
getConnectionString,
|
|
13
|
+
mergeWithDefaults,
|
|
14
|
+
validateDriverConfig
|
|
15
|
+
} from "./driver-config";
|
|
16
|
+
export * from "./utils";
|
|
17
|
+
export * from "./types";
|
|
18
|
+
export * from "./migrations";
|
|
19
|
+
export { setQueryTracker, logQuery } from "./query-logger";
|
|
20
|
+
export { Seeder, runClassSeeders } from "./class-seeder";
|
|
21
|
+
export { addColumnSafely, backfillInBatches, renameColumnSafely } from "./safe-migrations";
|
|
22
|
+
export * from "./seeder";
|
|
23
|
+
export { factory, generate as factoryGenerate } from "./factory";
|
|
24
|
+
export { scaffoldClassSeedersFromModels, renderSeederFile } from "./seed-scaffold";
|
|
25
|
+
export * from "./drivers";
|
|
26
|
+
export * from "./custom";
|
|
27
|
+
export * from "./auth-tables";
|
|
28
|
+
export * from "./uuid-columns";
|
|
29
|
+
export { migrateNotificationTables } from "./notification-tables";
|
|
30
|
+
export { migrateRbacTables } from "./rbac-tables";
|
|
31
|
+
export * from "./sql-helpers";
|
|
32
|
+
export * from "./defaults";
|
|
33
|
+
export { auditForeignKeys, findFkOrphans, getDeclaredFKs, getLiveFKs } from "./fk-audit";
|
|
34
|
+
export { auditUniqueIndexes, getDeclaredUniques, getLiveUniqueIndexes } from "./unique-audit";
|
|
35
|
+
export {
|
|
36
|
+
__flushAfterCommitNow,
|
|
37
|
+
__pendingAfterCommitCount,
|
|
38
|
+
enqueueAfterCommit,
|
|
39
|
+
isInTransaction,
|
|
40
|
+
runInTransactionScope
|
|
41
|
+
} from "./transaction-context";
|
|
42
|
+
export {
|
|
43
|
+
createQueryBuilder,
|
|
44
|
+
setConfig
|
|
45
|
+
} from "@stacksjs/query-builder";
|
|
46
|
+
export {
|
|
47
|
+
createDynamo,
|
|
48
|
+
dynamo,
|
|
49
|
+
EntityQueryBuilder,
|
|
50
|
+
generateKeyPattern,
|
|
51
|
+
parseKeyPattern,
|
|
52
|
+
buildKey,
|
|
53
|
+
marshall,
|
|
54
|
+
unmarshall
|
|
55
|
+
} from "./drivers/dynamodb";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Acquire the distributed migration lock for the given dialect.
|
|
3
|
+
* Returns a handle whose `release()` method MUST be called in a
|
|
4
|
+
* `finally` to free the lock — even on error paths.
|
|
5
|
+
*
|
|
6
|
+
* @param dialect - which database driver is being migrated against
|
|
7
|
+
* @param adminDb - the bun-query-builder connection to issue lock SQL
|
|
8
|
+
* through (PG / MySQL). Ignored for SQLite.
|
|
9
|
+
* @param opts.timeoutMs - max time to wait for an existing holder
|
|
10
|
+
* @param opts.sqliteLockPath - override the file path SQLite uses
|
|
11
|
+
*
|
|
12
|
+
* Throws an error if the lock can't be acquired within `timeoutMs`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function acquireMigrationLock(dialect: Dialect, adminDb: { unsafe: (sql: string) => Promise<unknown> } | null, opts?: { timeoutMs?: number, sqliteLockPath?: string }): Promise<MigrationLockHandle>;
|
|
15
|
+
/**
|
|
16
|
+
* Returned by `acquireMigrationLock()`. Callers MUST invoke `release`
|
|
17
|
+
* in a finally block; the lock is process-external (file, advisory,
|
|
18
|
+
* or named) so leaking it strands future migration runs.
|
|
19
|
+
*/
|
|
20
|
+
export declare interface MigrationLockHandle {
|
|
21
|
+
release: () => Promise<void>
|
|
22
|
+
}
|
|
23
|
+
export type Dialect = 'sqlite' | 'mysql' | 'postgres';
|
|
@@ -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,76 @@
|
|
|
1
|
+
import type { MigrationOperation } from '@stacksjs/query-builder';
|
|
2
|
+
import type { Result } from '@stacksjs/error-handling';
|
|
3
|
+
export type { MigrationResult as MigrationResultType };
|
|
4
|
+
/**
|
|
5
|
+
* SQLite compatibility preprocessing for migrations.
|
|
6
|
+
*
|
|
7
|
+
* SQLite does not support:
|
|
8
|
+
* - ALTER TABLE ADD CONSTRAINT (foreign keys must be defined at table creation)
|
|
9
|
+
* - CREATE TYPE ... AS ENUM (SQLite has no user-defined types; enum columns
|
|
10
|
+
* are plain TEXT, with the allowed values enforced at the validation layer)
|
|
11
|
+
*
|
|
12
|
+
* Note: CREATE UNIQUE INDEX files are deliberately NOT skipped — the SQLite
|
|
13
|
+
* dialect driver never renders inline UNIQUE in CREATE TABLE, so the
|
|
14
|
+
* standalone index file is the only uniqueness enforcement on SQLite
|
|
15
|
+
* (stacksjs/stacks#1952).
|
|
16
|
+
*
|
|
17
|
+
* Two flavours of "no-op on SQLite" need different handling:
|
|
18
|
+
*
|
|
19
|
+
* - **Skip-and-keep** (`skipMigration`): the file is portable — it would
|
|
20
|
+
* run cleanly on MySQL/Postgres — but doesn't apply to SQLite. Record
|
|
21
|
+
* it as executed in the migrations tracking table so it doesn't replay,
|
|
22
|
+
* but **leave the file on disk** so a future `DB_CONNECTION` flip can
|
|
23
|
+
* pick it up. This is the right path for FK constraint files.
|
|
24
|
+
* (stacksjs/stacks#1916)
|
|
25
|
+
*
|
|
26
|
+
* - **Drop-and-delete** (`deleteMigration`): the file is genuinely dead
|
|
27
|
+
* — a duplicate CREATE TABLE created by `buddy generate:migrations`
|
|
28
|
+
* regenerating against an already-modeled table, or a DROP COLUMN
|
|
29
|
+
* migration whose target column never existed. Removing it keeps the
|
|
30
|
+
* directory clean and prevents future runs from re-discovering it.
|
|
31
|
+
*/
|
|
32
|
+
export declare function preprocessSqliteMigrations(): void;
|
|
33
|
+
/**
|
|
34
|
+
* Run database migrations
|
|
35
|
+
*/
|
|
36
|
+
export declare function runDatabaseMigration(): Promise<Result<string, Error>>;
|
|
37
|
+
/**
|
|
38
|
+
* Reset the database (drop all tables)
|
|
39
|
+
*/
|
|
40
|
+
export declare function resetDatabase(): Promise<Result<string, Error>>;
|
|
41
|
+
/**
|
|
42
|
+
* Preview the pending migration as a list of structured operations WITHOUT
|
|
43
|
+
* writing any files or advancing the snapshot. The `buddy migrate` command
|
|
44
|
+
* uses this (in the interactive parent process) to gate destructive changes
|
|
45
|
+
* behind confirmation before spawning the non-interactive migrate action.
|
|
46
|
+
*/
|
|
47
|
+
export declare function previewPendingMigrations(options?: GenerateMigrationsOptions): Promise<MigrationOperation[]>;
|
|
48
|
+
export declare function generateMigrations(options?: GenerateMigrationsOptions): Promise<Result<string, Error>>;
|
|
49
|
+
/**
|
|
50
|
+
* Generate fresh migrations (full regeneration, ignoring previous state)
|
|
51
|
+
*/
|
|
52
|
+
export declare function generateMigrations2(): Promise<Result<string, Error>>;
|
|
53
|
+
/*` definitions to the stored snapshot
|
|
54
|
+
* (`.qb/model-snapshot.<dialect>.json`) via bun-query-builder, then — if
|
|
55
|
+
* there are changes — writes the resulting ALTER/CREATE/DROP statements
|
|
56
|
+
* out to a fresh file in `database/migrations/`. Each statement is
|
|
57
|
+
* grouped by table + DDL verb and lands in its own file using the
|
|
58
|
+
* runner's existing naming convention so it picks them up the same way
|
|
59
|
+
* as a hand-written migration.
|
|
60
|
+
*
|
|
61
|
+
* Without this write step the qb generator stages the diff in memory but
|
|
62
|
+
* the runner never sees it, so model edits silently no-op'd — defeating
|
|
63
|
+
* the "models are the source of truth" promise.
|
|
64
|
+
*/
|
|
65
|
+
export declare interface GenerateMigrationsOptions {
|
|
66
|
+
applyRenames?: boolean
|
|
67
|
+
fromDb?: boolean
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Migration result type for compatibility
|
|
71
|
+
*/
|
|
72
|
+
export declare interface MigrationResult {
|
|
73
|
+
migrationName: string
|
|
74
|
+
direction: 'Up' | 'Down'
|
|
75
|
+
status: 'Success' | 'Error' | 'NotExecuted'
|
|
76
|
+
}
|