@stacksjs/database 0.70.88 → 0.70.91
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 +549 -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,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
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Attribute, Model } from '@stacksjs/types';
|
|
2
|
+
import type { SeedResult } from './seeder';
|
|
3
|
+
/**
|
|
4
|
+
* Build the internal `SeederModel`-shaped payload that `seedModelDirect`
|
|
5
|
+
* expects from a public-API call. Pure function — exported separately
|
|
6
|
+
* so tests can assert the override-precedence rules without touching
|
|
7
|
+
* the database.
|
|
8
|
+
*
|
|
9
|
+
* Precedence (lowest → highest): per-attribute `factory` output →
|
|
10
|
+
* global `options.with` → per-row `options.rows[i]`. All keys are
|
|
11
|
+
* snake-cased before insert so callers can use the model's camelCase
|
|
12
|
+
* attribute names without thinking about column naming.
|
|
13
|
+
*/
|
|
14
|
+
export declare function buildSeederPayload(modelInput: unknown, options?: GenerateOptions): {
|
|
15
|
+
name: string
|
|
16
|
+
table: string
|
|
17
|
+
count: number
|
|
18
|
+
fixtures: Array<Record<string, unknown>>
|
|
19
|
+
attributes: Record<string, Attribute>
|
|
20
|
+
model: Model
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Generate factory rows for a model and insert them. Designed to be
|
|
24
|
+
* called from a class seeder.
|
|
25
|
+
*
|
|
26
|
+
* Honours the model's per-attribute `factory: faker => …` declarations
|
|
27
|
+
* — exactly the same code path as the legacy auto-walker — but without
|
|
28
|
+
* the implicit "every model with `useSeeder` fires on every run"
|
|
29
|
+
* coupling. See stacksjs/stacks#1919 for the rationale.
|
|
30
|
+
*/
|
|
31
|
+
export declare function generate(modelInput: unknown, options?: GenerateOptions): Promise<SeedResult>;
|
|
32
|
+
export declare const factory: {
|
|
33
|
+
generate: typeof generate
|
|
34
|
+
};
|
|
35
|
+
export declare interface GenerateOptions {
|
|
36
|
+
count?: number
|
|
37
|
+
fresh?: boolean
|
|
38
|
+
verbose?: boolean
|
|
39
|
+
with?: Record<string, unknown>
|
|
40
|
+
rows?: Array<Record<string, unknown>>
|
|
41
|
+
}
|
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
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Glob, but resilient to "directory doesn't exist yet" (the userland
|
|
3
|
+
* `app/Models/` directory in particular often doesn't exist when the
|
|
4
|
+
* audit runs in framework tests or scaffolded apps that haven't
|
|
5
|
+
* created any models yet). Avoids the Bun Glob ENOENT.
|
|
6
|
+
*/
|
|
7
|
+
export declare function safeGlob(pattern: string): string[];
|
|
8
|
+
/**
|
|
9
|
+
* Walk every model file (user + framework defaults) and return the
|
|
10
|
+
* full list of declared `belongsTo` foreign keys.
|
|
11
|
+
*
|
|
12
|
+
* Convention: `Comment` with `belongsTo: ['Post']` implies the FK
|
|
13
|
+
* `comments.post_id → posts.id`. Same shape the migration generator
|
|
14
|
+
* uses, so we keep it consistent here. Other relationship types
|
|
15
|
+
* (`hasOne`, `hasMany`, `belongsToMany` through tables) imply FKs in
|
|
16
|
+
* the *other* direction or in pivot tables — we only check
|
|
17
|
+
* `belongsTo` here for the simplest, highest-signal audit.
|
|
18
|
+
*/
|
|
19
|
+
export declare function getDeclaredFKs(): Promise<DeclaredFK[]>;
|
|
20
|
+
/**
|
|
21
|
+
* Query the live database for every foreign key constraint, returning
|
|
22
|
+
* a normalised shape. Dialect-aware: PRAGMA on SQLite,
|
|
23
|
+
* information_schema on MySQL / PostgreSQL.
|
|
24
|
+
*/
|
|
25
|
+
export declare function getLiveFKs(): Promise<LiveFK[]>;
|
|
26
|
+
/**
|
|
27
|
+
* Diff declared FKs against live FKs. Returns the declared FKs that
|
|
28
|
+
* have no matching row in the live database — these are the silent
|
|
29
|
+
* "FK should be enforcing referential integrity but isn't" cases that
|
|
30
|
+
* motivated this audit.
|
|
31
|
+
*
|
|
32
|
+
* Match key is `fromTable.fromColumn → toTable.toColumn` (case-
|
|
33
|
+
* insensitive). Extra live FKs (present in DB but not in any model)
|
|
34
|
+
* are not reported — those usually come from manual migrations or
|
|
35
|
+
* external tooling, both legitimate.
|
|
36
|
+
*/
|
|
37
|
+
export declare function auditForeignKeys(): Promise<FkAuditResult>;
|
|
38
|
+
/**
|
|
39
|
+
* Scan the live database for rows whose foreign key references a
|
|
40
|
+
* parent row that doesn't exist. SQLite's `PRAGMA foreign_key_check`
|
|
41
|
+
* does this regardless of the `foreign_keys` pragma state and excludes
|
|
42
|
+
* NULL-FK rows. The violating column is resolved by joining the
|
|
43
|
+
* reported `fkid` against `PRAGMA foreign_key_list(table)`.
|
|
44
|
+
*/
|
|
45
|
+
export declare function findFkOrphans(dialect?: 'sqlite' | 'mysql' | 'postgres' | 'other'): Promise<FkOrphanReport>;
|
|
46
|
+
// stacksjs/stacks#1916 — Foreign-key audit. Compares each model's
|
|
47
|
+
// declared `belongsTo` relationships against the FKs that actually
|
|
48
|
+
// exist in the live database. Drives `buddy doctor`'s FK integrity
|
|
49
|
+
// check, surfaces the "you flipped DB_CONNECTION but the FKs didn't
|
|
50
|
+
// follow" failure mode that motivated #1915 and #1916.
|
|
51
|
+
//
|
|
52
|
+
// Two halves:
|
|
53
|
+
//
|
|
54
|
+
// 1. Declared FKs: walk model files, look at `belongsTo`, compute
|
|
55
|
+
// the implied FK shape `{ fromTable, fromColumn, toTable,
|
|
56
|
+
// toColumn }`. Convention is `<related>_id` → `<related>.id`,
|
|
57
|
+
// same as the migration generator.
|
|
58
|
+
//
|
|
59
|
+
// 2. Live FKs: query the live database. SQLite via
|
|
60
|
+
// `PRAGMA foreign_key_list("…")`, MySQL/Postgres via
|
|
61
|
+
// `information_schema.key_column_usage`.
|
|
62
|
+
export declare interface DeclaredFK {
|
|
63
|
+
fromTable: string
|
|
64
|
+
fromColumn: string
|
|
65
|
+
toTable: string
|
|
66
|
+
toColumn: string
|
|
67
|
+
model: string
|
|
68
|
+
}
|
|
69
|
+
export declare interface LiveFK {
|
|
70
|
+
fromTable: string
|
|
71
|
+
fromColumn: string
|
|
72
|
+
toTable: string
|
|
73
|
+
toColumn: string
|
|
74
|
+
}
|
|
75
|
+
export declare interface FkAuditResult {
|
|
76
|
+
declared: DeclaredFK[]
|
|
77
|
+
live: LiveFK[]
|
|
78
|
+
missing: DeclaredFK[]
|
|
79
|
+
}
|
|
80
|
+
// stacksjs/stacks#1951 — FK orphan detection. FK enforcement flipped
|
|
81
|
+
// ON (utils.ts bootstrap pragmas) against databases that were written
|
|
82
|
+
// while `foreign_keys = OFF`, so legacy rows can reference parents that
|
|
83
|
+
// no longer exist. Those orphans silently turn previously-working
|
|
84
|
+
// deletes/inserts into runtime FK failures. This READ-ONLY scan finds
|
|
85
|
+
// them so `buddy doctor` can report before they bite.
|
|
86
|
+
//
|
|
87
|
+
// SQLite-specific: MySQL/Postgres enforce FKs natively, so the
|
|
88
|
+
// FK-off-legacy-data failure mode can't arise there — we degrade to
|
|
89
|
+
// `supported: false` rather than run an expensive per-FK anti-join.
|
|
90
|
+
export declare interface FkOrphan {
|
|
91
|
+
table: string
|
|
92
|
+
column: string
|
|
93
|
+
parent: string
|
|
94
|
+
count: number
|
|
95
|
+
sampleRowids: number[]
|
|
96
|
+
}
|
|
97
|
+
export declare interface FkOrphanReport {
|
|
98
|
+
supported: boolean
|
|
99
|
+
total: number
|
|
100
|
+
orphans: FkOrphan[]
|
|
101
|
+
}
|