@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.
- package/dist/auth-tables.js +220 -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.js +57 -0
- package/dist/custom/errors.js +48 -0
- package/dist/custom/index.js +3 -0
- package/dist/custom/jobs.js +449 -0
- package/dist/database.js +178 -0
- package/dist/defaults.js +48 -0
- package/dist/driver-config.js +144 -0
- package/dist/drivers/defaults/index.js +2 -0
- package/dist/drivers/defaults/passwords.js +106 -0
- package/dist/drivers/defaults/traits.js +1125 -0
- package/dist/drivers/dynamodb.js +607 -0
- package/dist/drivers/helpers.js +206 -0
- package/dist/drivers/index.js +9 -0
- package/dist/drivers/mysql.js +322 -0
- package/dist/drivers/postgres.js +411 -0
- package/dist/drivers/sqlite.js +397 -0
- package/dist/factory.js +51 -0
- package/dist/fk-audit.js +181 -0
- package/dist/index.js +55 -1263
- package/dist/migration-lock.js +143 -0
- package/dist/migrations.js +528 -0
- package/dist/notification-tables.js +54 -0
- package/dist/query-logger.js +213 -0
- package/dist/query-parser.js +93 -0
- package/dist/rbac-tables.js +84 -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.js +144 -0
- package/dist/seeder.js +363 -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.js +62 -0
- package/dist/types.js +23 -0
- package/dist/unique-audit.js +174 -0
- package/dist/utils.js +163 -0
- package/dist/uuid-columns.js +68 -0
- package/dist/validators.js +122 -0
- package/package.json +11 -11
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { log } from "@stacksjs/logging";
|
|
2
|
+
function italic(str) {
|
|
3
|
+
return `\x1B[3m${str}\x1B[23m`;
|
|
4
|
+
}
|
|
5
|
+
import { app } from "@stacksjs/config";
|
|
6
|
+
import { db, SQLITE_BOOTSTRAP_PRAGMAS } from "../utils";
|
|
7
|
+
import { ok } from "@stacksjs/error-handling";
|
|
8
|
+
import { fetchOtherModelRelations, getModelName, getPivotTables, getTableName } from "@stacksjs/orm";
|
|
9
|
+
import { path } from "@stacksjs/path";
|
|
10
|
+
import { fs, globSync } from "@stacksjs/storage";
|
|
11
|
+
import { snakeCase } from "@stacksjs/strings";
|
|
12
|
+
import {
|
|
13
|
+
arrangeColumns,
|
|
14
|
+
checkPivotMigration,
|
|
15
|
+
deleteFrameworkModels,
|
|
16
|
+
deleteMigrationFiles,
|
|
17
|
+
fetchTables,
|
|
18
|
+
findDifferingKeys,
|
|
19
|
+
getLastMigrationFields,
|
|
20
|
+
getLikeableForeignKey,
|
|
21
|
+
getUpvoteTableName,
|
|
22
|
+
isArrayEqual,
|
|
23
|
+
mapFieldTypeToColumnType,
|
|
24
|
+
pluckChanges
|
|
25
|
+
} from "./helpers";
|
|
26
|
+
import { dropCommonTables } from "./defaults/traits";
|
|
27
|
+
export async function resetSqliteDatabase() {
|
|
28
|
+
await deleteFrameworkModels();
|
|
29
|
+
await deleteMigrationFiles();
|
|
30
|
+
await dropSqliteTables();
|
|
31
|
+
return ok("All tables dropped successfully!");
|
|
32
|
+
}
|
|
33
|
+
export async function configureSqlitePragmas() {
|
|
34
|
+
try {
|
|
35
|
+
for (const pragma of SQLITE_BOOTSTRAP_PRAGMAS)
|
|
36
|
+
await db.unsafe(pragma).execute();
|
|
37
|
+
} catch (err) {
|
|
38
|
+
log.debug(`[sqlite] Failed to apply pragmas: ${err.message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export async function dropSqliteTables() {
|
|
42
|
+
const userModelFiles = globSync([path.userModelsPath("*.ts"), path.storagePath("framework/defaults/app/Models/**/*.ts")], { absolute: !0 }), tables = await fetchTables(), safeName = /^[a-z_][\w]*$/i;
|
|
43
|
+
await db.unsafe("PRAGMA foreign_keys = OFF").execute();
|
|
44
|
+
try {
|
|
45
|
+
for (const table of tables) {
|
|
46
|
+
if (!safeName.test(table))
|
|
47
|
+
throw Error(`[sqlite] Refusing to drop table with unsafe name: ${table}`);
|
|
48
|
+
await db.unsafe(`DROP TABLE IF EXISTS "${table}"`).execute();
|
|
49
|
+
}
|
|
50
|
+
await db.unsafe('DROP TABLE IF EXISTS "migrations"').execute();
|
|
51
|
+
await dropCommonTables();
|
|
52
|
+
for (const userModel of userModelFiles) {
|
|
53
|
+
const userModelPath = (await import(userModel)).default, pivotTables = await getPivotTables(userModelPath, userModel);
|
|
54
|
+
for (const pivotTable of pivotTables) {
|
|
55
|
+
if (!safeName.test(pivotTable.table))
|
|
56
|
+
throw Error(`[sqlite] Refusing to drop pivot table with unsafe name: ${pivotTable.table}`);
|
|
57
|
+
await db.unsafe(`DROP TABLE IF EXISTS "${pivotTable.table}"`).execute();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} finally {
|
|
61
|
+
await db.unsafe("PRAGMA foreign_keys = ON").execute();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function fetchSqliteFile() {
|
|
65
|
+
if (app.env === "testing")
|
|
66
|
+
return fetchTestSqliteFile();
|
|
67
|
+
return path.userDatabasePath("stacks.sqlite");
|
|
68
|
+
}
|
|
69
|
+
export function fetchTestSqliteFile() {
|
|
70
|
+
return path.userDatabasePath("stacks_testing.sqlite");
|
|
71
|
+
}
|
|
72
|
+
export async function generateSqliteMigration(modelPath) {
|
|
73
|
+
const model = (await import(modelPath)).default, fileName = path.basename(modelPath), tableName = await getTableName(model, modelPath), fieldsString = JSON.stringify(model.attributes, null, 2), copiedModelPath = path.frameworkPath(`cache/models/${fileName}`);
|
|
74
|
+
let haveFieldsChanged = !1;
|
|
75
|
+
if (fs.existsSync(copiedModelPath)) {
|
|
76
|
+
log.debug(`Fields have already been generated for ${tableName}`);
|
|
77
|
+
const previousFields = await getLastMigrationFields(fileName);
|
|
78
|
+
if (JSON.stringify(previousFields, null, 2) === fieldsString) {
|
|
79
|
+
log.debug(`Fields have not changed for ${tableName}`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
haveFieldsChanged = !0;
|
|
83
|
+
log.debug(`Fields have changed for ${tableName}`);
|
|
84
|
+
} else
|
|
85
|
+
log.debug(`Fields have not been generated for ${tableName}`);
|
|
86
|
+
await Bun.$`mkdir -p ${path.frameworkPath("cache/models")} && cp ${modelPath} ${copiedModelPath}`;
|
|
87
|
+
const hasBeenMigrated = !1;
|
|
88
|
+
log.debug(`Has ${tableName} been migrated? ${hasBeenMigrated}`);
|
|
89
|
+
if (haveFieldsChanged)
|
|
90
|
+
await createAlterTableMigration(modelPath);
|
|
91
|
+
else
|
|
92
|
+
await createTableMigration(modelPath);
|
|
93
|
+
}
|
|
94
|
+
export async function copyModelFiles(modelPath) {
|
|
95
|
+
const model = (await import(modelPath)).default, fileName = path.basename(modelPath), tableName = await getTableName(model, modelPath), fieldsString = JSON.stringify(model.attributes, null, 2), copiedModelPath = path.frameworkPath(`cache/models/${fileName}`);
|
|
96
|
+
if (fs.existsSync(copiedModelPath)) {
|
|
97
|
+
log.debug(`Fields have already been generated for ${tableName}`);
|
|
98
|
+
const previousFields = await getLastMigrationFields(fileName);
|
|
99
|
+
if (JSON.stringify(previousFields, null, 2) === fieldsString) {
|
|
100
|
+
log.debug(`Fields have not changed for ${tableName}`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
await Bun.$`mkdir -p ${path.frameworkPath("cache/models")} && cp ${modelPath} ${copiedModelPath}`;
|
|
105
|
+
}
|
|
106
|
+
async function createTableMigration(modelPath) {
|
|
107
|
+
log.debug("createTableMigration modelPath:", modelPath);
|
|
108
|
+
const model = (await import(modelPath)).default, tableName = getTableName(model, modelPath), modelName = getModelName(model, modelPath), twoFactorEnabled = model.traits?.useAuth && typeof model.traits.useAuth !== "boolean" ? model.traits.useAuth.useTwoFactor : !1;
|
|
109
|
+
await createPivotTableMigration(model, modelPath);
|
|
110
|
+
const otherModelRelations = await fetchOtherModelRelations(modelName), useTimestamps = model?.traits?.useTimestamps ?? model?.traits?.timestampable ?? !0, useSocials = model?.traits?.useSocials && Array.isArray(model.traits.useSocials) && model.traits.useSocials.length > 0, useLikeable = Array.isArray(model?.traits?.likeable) ? model.traits.likeable.length > 0 : Boolean(model?.traits?.likeable), useSoftDeletes = model?.traits?.useSoftDeletes ?? model?.traits?.softDeletable ?? !1, usePasskey = (typeof model.traits?.useAuth === "object" && model.traits.useAuth.usePasskey) ?? !1, useBillable = model.traits?.billable || !1, useUuid = model.traits?.useUuid || !1;
|
|
111
|
+
let migrationContent = `import type { Database } from '@stacksjs/database'
|
|
112
|
+
`;
|
|
113
|
+
migrationContent += `import { sql } from '@stacksjs/database'
|
|
114
|
+
|
|
115
|
+
`;
|
|
116
|
+
migrationContent += `export async function up(db: Database<any>) {
|
|
117
|
+
`;
|
|
118
|
+
migrationContent += ` await (db as any).schema
|
|
119
|
+
`;
|
|
120
|
+
migrationContent += ` .createTable('${tableName}')
|
|
121
|
+
`;
|
|
122
|
+
migrationContent += ` .addColumn('id', 'integer', col => col.primaryKey().autoIncrement())
|
|
123
|
+
`;
|
|
124
|
+
if (useUuid)
|
|
125
|
+
migrationContent += ` .addColumn('uuid', 'text')
|
|
126
|
+
`;
|
|
127
|
+
if (useSocials) {
|
|
128
|
+
const socials = model.traits?.useSocials || [];
|
|
129
|
+
if (socials.includes("google"))
|
|
130
|
+
migrationContent += ` .addColumn('google_id', 'text')
|
|
131
|
+
`;
|
|
132
|
+
if (socials.includes("github"))
|
|
133
|
+
migrationContent += ` .addColumn('github_id', 'text')
|
|
134
|
+
`;
|
|
135
|
+
if (socials.includes("apple"))
|
|
136
|
+
migrationContent += ` .addColumn('apple_id', 'text')
|
|
137
|
+
`;
|
|
138
|
+
if (socials.includes("twitter"))
|
|
139
|
+
migrationContent += ` .addColumn('twitter_id', 'text')
|
|
140
|
+
`;
|
|
141
|
+
if (socials.includes("facebook"))
|
|
142
|
+
migrationContent += ` .addColumn('facebook_id', 'text')
|
|
143
|
+
`;
|
|
144
|
+
}
|
|
145
|
+
for (const [fieldName, options] of arrangeColumns(model.attributes)) {
|
|
146
|
+
const fieldOptions = options, fieldNameFormatted = snakeCase(fieldName), columnType = mapFieldTypeToColumnType(fieldOptions.validation?.rule, "sqlite");
|
|
147
|
+
migrationContent += ` .addColumn('${fieldNameFormatted}', ${columnType}`;
|
|
148
|
+
const isRequired = fieldOptions.validation?.rule.isRequired;
|
|
149
|
+
if (isRequired || fieldOptions.unique || fieldOptions.default !== void 0) {
|
|
150
|
+
migrationContent += ", col => col";
|
|
151
|
+
if (isRequired)
|
|
152
|
+
migrationContent += ".notNull()";
|
|
153
|
+
if (fieldOptions.unique)
|
|
154
|
+
migrationContent += ".unique()";
|
|
155
|
+
if (fieldOptions.default !== void 0)
|
|
156
|
+
if (typeof fieldOptions.default === "string")
|
|
157
|
+
migrationContent += `.defaultTo('${fieldOptions.default}')`;
|
|
158
|
+
else if (fieldOptions.default === null)
|
|
159
|
+
migrationContent += ".defaultTo(null)";
|
|
160
|
+
else
|
|
161
|
+
migrationContent += `.defaultTo(${fieldOptions.default})`;
|
|
162
|
+
migrationContent += "";
|
|
163
|
+
}
|
|
164
|
+
migrationContent += `)
|
|
165
|
+
`;
|
|
166
|
+
}
|
|
167
|
+
if (twoFactorEnabled !== !1 && twoFactorEnabled)
|
|
168
|
+
migrationContent += ` .addColumn('two_factor_secret', 'text')
|
|
169
|
+
`;
|
|
170
|
+
if (useBillable)
|
|
171
|
+
migrationContent += ` .addColumn('stripe_id', 'text')
|
|
172
|
+
`;
|
|
173
|
+
if (useSoftDeletes)
|
|
174
|
+
migrationContent += ` .addColumn('deleted_at', 'timestamp')
|
|
175
|
+
`;
|
|
176
|
+
if (usePasskey)
|
|
177
|
+
migrationContent += ` .addColumn('public_passkey', 'text')
|
|
178
|
+
`;
|
|
179
|
+
if (otherModelRelations?.length)
|
|
180
|
+
for (const modelRelation of otherModelRelations) {
|
|
181
|
+
if (!modelRelation.foreignKey)
|
|
182
|
+
continue;
|
|
183
|
+
migrationContent += ` .addColumn('${modelRelation.foreignKey}', 'integer', (col) =>
|
|
184
|
+
col.references('${modelRelation.relationTable}.id').onDelete('cascade')
|
|
185
|
+
)
|
|
186
|
+
`;
|
|
187
|
+
}
|
|
188
|
+
if (useTimestamps) {
|
|
189
|
+
migrationContent += " .addColumn('created_at', 'timestamp', col => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`))\n";
|
|
190
|
+
migrationContent += ` .addColumn('updated_at', 'timestamp')
|
|
191
|
+
`;
|
|
192
|
+
}
|
|
193
|
+
migrationContent += ` .execute()
|
|
194
|
+
|
|
195
|
+
`;
|
|
196
|
+
if (otherModelRelations?.length)
|
|
197
|
+
for (const modelRelation of otherModelRelations) {
|
|
198
|
+
if (!modelRelation.foreignKey)
|
|
199
|
+
continue;
|
|
200
|
+
migrationContent += generateForeignKeyIndexSQL(tableName, modelRelation.foreignKey);
|
|
201
|
+
}
|
|
202
|
+
if (model.indexes?.length) {
|
|
203
|
+
migrationContent += `
|
|
204
|
+
`;
|
|
205
|
+
for (const index of model.indexes)
|
|
206
|
+
migrationContent += generateIndexCreationSQL(tableName, index);
|
|
207
|
+
}
|
|
208
|
+
migrationContent += generatePrimaryKeyIndexSQL(tableName);
|
|
209
|
+
if (useLikeable) {
|
|
210
|
+
const upvoteTable = getUpvoteTableName(model, tableName);
|
|
211
|
+
if (upvoteTable) {
|
|
212
|
+
const foreignKey = getLikeableForeignKey(model, tableName);
|
|
213
|
+
migrationContent += `
|
|
214
|
+
// Create upvote table
|
|
215
|
+
`;
|
|
216
|
+
migrationContent += ` await (db as any).schema
|
|
217
|
+
`;
|
|
218
|
+
migrationContent += ` .createTable('${upvoteTable}')
|
|
219
|
+
`;
|
|
220
|
+
migrationContent += ` .addColumn('id', 'integer', col => col.primaryKey().autoIncrement())
|
|
221
|
+
`;
|
|
222
|
+
migrationContent += ` .addColumn('${foreignKey}', 'integer', col => col.notNull())
|
|
223
|
+
`;
|
|
224
|
+
migrationContent += ` .addColumn('user_id', 'integer', col => col.notNull())
|
|
225
|
+
`;
|
|
226
|
+
migrationContent += " .addColumn('created_at', 'timestamp', col => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`))\n";
|
|
227
|
+
migrationContent += ` .addColumn('updated_at', 'timestamp')
|
|
228
|
+
`;
|
|
229
|
+
migrationContent += ` .execute()
|
|
230
|
+
|
|
231
|
+
`;
|
|
232
|
+
migrationContent += ` // Add indexes for upvote table
|
|
233
|
+
`;
|
|
234
|
+
migrationContent += ` await (db as any).schema.createIndex('${upvoteTable}_${foreignKey}_index').on('${upvoteTable}').column('${foreignKey}').execute()
|
|
235
|
+
`;
|
|
236
|
+
migrationContent += ` await (db as any).schema.createIndex('${upvoteTable}_user_${foreignKey}_unique').on('${upvoteTable}').columns(['user_id', '${foreignKey}']).unique().execute()
|
|
237
|
+
`;
|
|
238
|
+
migrationContent += ` await (db as any).schema.createIndex('${upvoteTable}_id_index').on('${upvoteTable}').column('id').execute()
|
|
239
|
+
`;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
migrationContent += `}
|
|
243
|
+
`;
|
|
244
|
+
const migrationFileName = `${new Date().getTime().toString()}-create-${tableName}-table.ts`, migrationFilePath = path.userMigrationsPath(migrationFileName);
|
|
245
|
+
await Bun.write(migrationFilePath, migrationContent);
|
|
246
|
+
log.success(`Created migration: ${italic(migrationFileName)}`);
|
|
247
|
+
}
|
|
248
|
+
async function createPivotTableMigration(model, modelPath) {
|
|
249
|
+
const pivotTables = await getPivotTables(model, modelPath);
|
|
250
|
+
if (!pivotTables.length)
|
|
251
|
+
return;
|
|
252
|
+
for (const pivotTable of pivotTables) {
|
|
253
|
+
if (await checkPivotMigration(pivotTable.table))
|
|
254
|
+
return;
|
|
255
|
+
let migrationContent = `import type { Database } from '@stacksjs/database'
|
|
256
|
+
`;
|
|
257
|
+
migrationContent += `import { sql } from '@stacksjs/database'
|
|
258
|
+
|
|
259
|
+
`;
|
|
260
|
+
migrationContent += `export async function up(db: Database<any>) {
|
|
261
|
+
`;
|
|
262
|
+
migrationContent += ` await (db as any).schema
|
|
263
|
+
`;
|
|
264
|
+
migrationContent += ` .createTable('${pivotTable.table}')
|
|
265
|
+
`;
|
|
266
|
+
migrationContent += ` .addColumn('id', 'integer', col => col.primaryKey().autoIncrement())
|
|
267
|
+
`;
|
|
268
|
+
migrationContent += ` .addColumn('${pivotTable.firstForeignKey}', 'integer')
|
|
269
|
+
`;
|
|
270
|
+
migrationContent += ` .addColumn('${pivotTable.secondForeignKey}', 'integer')
|
|
271
|
+
`;
|
|
272
|
+
migrationContent += ` .addColumn('created_at', 'timestamp', col => col.defaultTo(sql.raw('CURRENT_TIMESTAMP')))
|
|
273
|
+
`;
|
|
274
|
+
migrationContent += ` .execute()
|
|
275
|
+
`;
|
|
276
|
+
migrationContent += ` }
|
|
277
|
+
`;
|
|
278
|
+
const migrationFileName = `${new Date().getTime().toString()}-create-${pivotTable.table}-table.ts`, migrationFilePath = path.userMigrationsPath(migrationFileName);
|
|
279
|
+
await Bun.write(migrationFilePath, migrationContent);
|
|
280
|
+
log.success(`Created pivot migration: ${migrationFileName}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async function createAlterTableMigration(modelPath) {
|
|
284
|
+
const model = (await import(modelPath)).default, modelName = getModelName(model, modelPath), tableName = getTableName(model, modelPath);
|
|
285
|
+
let hasChanged = !1;
|
|
286
|
+
const oldModel = (await import(path.frameworkPath(`cache/models/${modelName}.ts`))).default, lastFields = await getLastMigrationFields(modelName) ?? {}, currentFields = model.attributes, changes = pluckChanges(Object.keys(lastFields), Object.keys(currentFields)), fieldsToAdd = changes?.added || [], fieldsToRemove = changes?.removed || [];
|
|
287
|
+
let migrationContent = `import type { Database } from '@stacksjs/database'
|
|
288
|
+
`;
|
|
289
|
+
migrationContent += `import { sql } from '@stacksjs/database'
|
|
290
|
+
|
|
291
|
+
`;
|
|
292
|
+
migrationContent += `export async function up(db: Database<any>) {
|
|
293
|
+
`;
|
|
294
|
+
if (fieldsToAdd.length || fieldsToRemove.length) {
|
|
295
|
+
hasChanged = !0;
|
|
296
|
+
migrationContent += ` await (db as any).schema.alterTable('${tableName}')
|
|
297
|
+
`;
|
|
298
|
+
}
|
|
299
|
+
const fieldValidations = findDifferingKeys(lastFields, currentFields);
|
|
300
|
+
for (const fieldValidation of fieldValidations) {
|
|
301
|
+
hasChanged = !0;
|
|
302
|
+
const fieldNameFormatted = snakeCase(fieldValidation.key);
|
|
303
|
+
migrationContent += `await sql\`
|
|
304
|
+
ALTER TABLE ${tableName}
|
|
305
|
+
MODIFY COLUMN ${fieldNameFormatted} TEXT
|
|
306
|
+
\`.execute(db)
|
|
307
|
+
|
|
308
|
+
`;
|
|
309
|
+
}
|
|
310
|
+
for (const fieldName of fieldsToAdd) {
|
|
311
|
+
const options = currentFields[fieldName], columnType = mapFieldTypeToColumnType(options.validation?.rule, "sqlite"), formattedFieldName = snakeCase(fieldName), isRequired = options.validation?.rule.isRequired;
|
|
312
|
+
migrationContent += ` .addColumn('${formattedFieldName}', ${columnType}`;
|
|
313
|
+
if (isRequired || options.unique || options.default !== void 0) {
|
|
314
|
+
migrationContent += ", col => col";
|
|
315
|
+
if (isRequired)
|
|
316
|
+
migrationContent += ".notNull()";
|
|
317
|
+
if (options.unique)
|
|
318
|
+
migrationContent += ".unique()";
|
|
319
|
+
if (options.default !== void 0)
|
|
320
|
+
if (typeof options.default === "string")
|
|
321
|
+
migrationContent += `.defaultTo('${options.default}')`;
|
|
322
|
+
else if (options.default === null)
|
|
323
|
+
migrationContent += ".defaultTo(null)";
|
|
324
|
+
else
|
|
325
|
+
migrationContent += `.defaultTo(${options.default})`;
|
|
326
|
+
migrationContent += "";
|
|
327
|
+
}
|
|
328
|
+
migrationContent += `)
|
|
329
|
+
|
|
330
|
+
`;
|
|
331
|
+
}
|
|
332
|
+
for (const fieldName of fieldsToRemove)
|
|
333
|
+
migrationContent += ` .dropColumn('${fieldName}')
|
|
334
|
+
`;
|
|
335
|
+
if (fieldsToAdd.length || fieldsToRemove.length)
|
|
336
|
+
migrationContent += ` .execute();
|
|
337
|
+
`;
|
|
338
|
+
const lastFieldOrder = Object.values(lastFields).map((attr) => attr.order), currentFieldOrder = Object.values(currentFields).map((attr) => attr.order);
|
|
339
|
+
if (!isArrayEqual(lastFieldOrder, currentFieldOrder)) {
|
|
340
|
+
hasChanged = !0;
|
|
341
|
+
migrationContent += reArrangeColumns(model.attributes, tableName);
|
|
342
|
+
}
|
|
343
|
+
const oldIndexes = oldModel.indexes || [], newIndexes = model.indexes || [];
|
|
344
|
+
for (const oldIndex of oldIndexes)
|
|
345
|
+
if (!newIndexes.find((newIndex) => newIndex.name === oldIndex.name)) {
|
|
346
|
+
hasChanged = !0;
|
|
347
|
+
migrationContent += ` await (db as any).schema.dropIndex('${oldIndex.name}').execute()
|
|
348
|
+
`;
|
|
349
|
+
}
|
|
350
|
+
for (const newIndex of newIndexes)
|
|
351
|
+
if (!oldIndexes.find((oldIndex) => oldIndex.name === newIndex.name)) {
|
|
352
|
+
hasChanged = !0;
|
|
353
|
+
migrationContent += generateIndexCreationSQL(tableName, newIndex);
|
|
354
|
+
}
|
|
355
|
+
migrationContent += `}
|
|
356
|
+
`;
|
|
357
|
+
const migrationFileName = `${new Date().getTime().toString()}-update-${tableName}-table.ts`, migrationFilePath = path.userMigrationsPath(migrationFileName);
|
|
358
|
+
if (hasChanged) {
|
|
359
|
+
await Bun.write(migrationFilePath, migrationContent);
|
|
360
|
+
log.success(`Created migration: ${italic(migrationFileName)}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
function reArrangeColumns(attributes, tableName) {
|
|
364
|
+
const fields = arrangeColumns(attributes);
|
|
365
|
+
let migrationContent = "", previousField = "";
|
|
366
|
+
for (const [fieldName] of fields) {
|
|
367
|
+
const fieldNameFormatted = snakeCase(fieldName);
|
|
368
|
+
if (previousField)
|
|
369
|
+
migrationContent += `await sql\`
|
|
370
|
+
ALTER TABLE ${tableName}
|
|
371
|
+
MODIFY COLUMN ${fieldNameFormatted} TEXT NOT NULL AFTER ${snakeCase(previousField)};
|
|
372
|
+
\`.execute(db)
|
|
373
|
+
|
|
374
|
+
`;
|
|
375
|
+
previousField = fieldNameFormatted;
|
|
376
|
+
}
|
|
377
|
+
return migrationContent;
|
|
378
|
+
}
|
|
379
|
+
export function generateIndexCreationSQL(tableName, index) {
|
|
380
|
+
if (index.unique || index.where) {
|
|
381
|
+
const unique = index.unique ? "UNIQUE " : "", cols = index.columns.map((col) => snakeCase(col)).join(", "), whereClause = index.where ? ` WHERE ${index.where}` : "";
|
|
382
|
+
return ` await db.unsafe(\`CREATE ${unique}INDEX IF NOT EXISTS "${index.name}" ON "${tableName}" (${cols})${whereClause}\`).execute()
|
|
383
|
+
`;
|
|
384
|
+
}
|
|
385
|
+
const columnsStr = index.columns.map((col) => `\`${snakeCase(col)}\``).join(", ");
|
|
386
|
+
return ` await (db as any).schema.createIndex('${index.name}').on('${tableName}').columns([${columnsStr}]).execute()
|
|
387
|
+
`;
|
|
388
|
+
}
|
|
389
|
+
function generatePrimaryKeyIndexSQL(tableName) {
|
|
390
|
+
return ` await (db as any).schema.createIndex('${tableName}_id_index').on('${tableName}').column('id').execute()
|
|
391
|
+
`;
|
|
392
|
+
}
|
|
393
|
+
function generateForeignKeyIndexSQL(tableName, foreignKey) {
|
|
394
|
+
return ` await (db as any).schema.createIndex('${tableName}_${foreignKey}_index').on('${tableName}').column(\`${foreignKey}\`).execute()
|
|
395
|
+
|
|
396
|
+
`;
|
|
397
|
+
}
|
package/dist/factory.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { log } from "@stacksjs/logging";
|
|
2
|
+
import { seedModelDirect } from "./seeder";
|
|
3
|
+
function resolveDefinition(input) {
|
|
4
|
+
if (input && typeof input === "object") {
|
|
5
|
+
const obj = input;
|
|
6
|
+
if (obj._definition && typeof obj._definition === "object" && "name" in obj._definition)
|
|
7
|
+
return obj._definition;
|
|
8
|
+
if ("name" in obj && (("attributes" in obj) || ("table" in obj) || ("traits" in obj)))
|
|
9
|
+
return obj;
|
|
10
|
+
}
|
|
11
|
+
throw Error("factory.generate: expected a Stacks model (the default export of app/Models/*.ts, or a defineModel() return value). Got something without a `.name` field.");
|
|
12
|
+
}
|
|
13
|
+
function snakeCase(str) {
|
|
14
|
+
return str.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2").replace(/([a-z\d])([A-Z])/g, "$1_$2").replace(/(\d)([A-Za-z])/g, "$1_$2").toLowerCase();
|
|
15
|
+
}
|
|
16
|
+
function snakeCaseKeys(input) {
|
|
17
|
+
const out = {};
|
|
18
|
+
for (const [key, value] of Object.entries(input))
|
|
19
|
+
out[snakeCase(key)] = value;
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
export function buildSeederPayload(modelInput, options = {}) {
|
|
23
|
+
const def = resolveDefinition(modelInput), name = def.name, attributes = def.attributes ?? {}, useSeederConfig = def.traits?.useSeeder, seederDefault = useSeederConfig && typeof useSeederConfig === "object" ? useSeederConfig : void 0, count = options.count ?? seederDefault?.count ?? 10, globalOverrides = options.with ? snakeCaseKeys(options.with) : void 0, perRow = options.rows ?? seederDefault?.fixtures ?? [], fixtureCount = Math.max(count, perRow.length), fixtures = [];
|
|
24
|
+
for (let i = 0;i < fixtureCount; i++) {
|
|
25
|
+
const row = perRow[i];
|
|
26
|
+
if (!globalOverrides && !row)
|
|
27
|
+
continue;
|
|
28
|
+
fixtures[i] = { ...globalOverrides ?? {}, ...row ? snakeCaseKeys(row) : {} };
|
|
29
|
+
}
|
|
30
|
+
const table = def.table ?? `${snakeCase(name)}s`;
|
|
31
|
+
return { name, table, count: fixtureCount, fixtures, attributes, model: def };
|
|
32
|
+
}
|
|
33
|
+
export async function generate(modelInput, options = {}) {
|
|
34
|
+
const payload = buildSeederPayload(modelInput, options);
|
|
35
|
+
try {
|
|
36
|
+
return await seedModelDirect({
|
|
37
|
+
...payload,
|
|
38
|
+
filePath: ""
|
|
39
|
+
}, {
|
|
40
|
+
fresh: options.fresh,
|
|
41
|
+
verbose: options.verbose ?? !1
|
|
42
|
+
});
|
|
43
|
+
} catch (err) {
|
|
44
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
45
|
+
log.error(`[factory] generate(${payload.name}) failed: ${message}`);
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export const factory = {
|
|
50
|
+
generate
|
|
51
|
+
};
|
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
|
+
}
|