@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
package/dist/seeder.js
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { log } from "@stacksjs/logging";
|
|
2
|
+
import { db, ensureDatabaseConfigLoaded } from "./utils";
|
|
3
|
+
import { faker } from "@stacksjs/faker";
|
|
4
|
+
import { path } from "@stacksjs/path";
|
|
5
|
+
import { hashMake } from "@stacksjs/security";
|
|
6
|
+
import { fs } from "@stacksjs/storage";
|
|
7
|
+
function defaultModelsPath(subpath) {
|
|
8
|
+
return path.frameworkPath(`defaults/app/Models/${subpath || ""}`);
|
|
9
|
+
}
|
|
10
|
+
export const PROTECTED_MODELS = Object.freeze([
|
|
11
|
+
"OauthClient",
|
|
12
|
+
"OauthAccessToken",
|
|
13
|
+
"OauthRefreshToken",
|
|
14
|
+
"PersonalAccessToken"
|
|
15
|
+
]);
|
|
16
|
+
export function isProtectedModel(name) {
|
|
17
|
+
return PROTECTED_MODELS.includes(name);
|
|
18
|
+
}
|
|
19
|
+
function snakeCase(str) {
|
|
20
|
+
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();
|
|
21
|
+
}
|
|
22
|
+
async function loadModelsFromDir(modelsDir, recursive = !1) {
|
|
23
|
+
const models = [];
|
|
24
|
+
if (!fs.existsSync(modelsDir))
|
|
25
|
+
return models;
|
|
26
|
+
const entries = fs.readdirSync(modelsDir, { withFileTypes: !0 });
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
const fullPath = path.join(modelsDir, entry.name);
|
|
29
|
+
if (entry.isDirectory() && recursive) {
|
|
30
|
+
const subModels = await loadModelsFromDir(fullPath, !0);
|
|
31
|
+
models.push(...subModels);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (!entry.name.endsWith(".ts") || entry.name.startsWith("index") || entry.name.startsWith("_"))
|
|
35
|
+
continue;
|
|
36
|
+
try {
|
|
37
|
+
const module = await import(fullPath), modelDef = module.default || module;
|
|
38
|
+
if (!modelDef)
|
|
39
|
+
continue;
|
|
40
|
+
const useSeeder = modelDef.traits?.useSeeder ?? modelDef.traits?.seedable;
|
|
41
|
+
if (!useSeeder)
|
|
42
|
+
continue;
|
|
43
|
+
let count = 10, fixtures = [];
|
|
44
|
+
if (typeof useSeeder === "object" && "count" in useSeeder) {
|
|
45
|
+
const opts = useSeeder;
|
|
46
|
+
count = opts.count;
|
|
47
|
+
fixtures = opts.fixtures ?? [];
|
|
48
|
+
}
|
|
49
|
+
const modelName = modelDef.name || entry.name.replace(".ts", ""), tableName = modelDef.table || snakeCase(modelName) + "s";
|
|
50
|
+
models.push({
|
|
51
|
+
name: modelName,
|
|
52
|
+
table: tableName,
|
|
53
|
+
count: Math.max(count, fixtures.length),
|
|
54
|
+
fixtures,
|
|
55
|
+
attributes: modelDef.attributes || {},
|
|
56
|
+
model: modelDef,
|
|
57
|
+
filePath: fullPath
|
|
58
|
+
});
|
|
59
|
+
} catch (err) {
|
|
60
|
+
log.error(`Failed to load model ${entry.name}:`, err);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return models;
|
|
64
|
+
}
|
|
65
|
+
async function loadAllModels(userModelsDir, verbose = !1, includeDefaults = !1) {
|
|
66
|
+
const defaultDir = defaultModelsPath(), userModels = await loadModelsFromDir(userModelsDir, !1);
|
|
67
|
+
if (userModels.length > 0 && !includeDefaults)
|
|
68
|
+
return userModels;
|
|
69
|
+
const defaultModels = await loadModelsFromDir(defaultDir, !0), modelMap = new Map;
|
|
70
|
+
for (const model of defaultModels)
|
|
71
|
+
modelMap.set(model.name, model);
|
|
72
|
+
for (const model of userModels) {
|
|
73
|
+
if (modelMap.has(model.name) && verbose)
|
|
74
|
+
log.info(` User model "${model.name}" overrides default`);
|
|
75
|
+
modelMap.set(model.name, model);
|
|
76
|
+
}
|
|
77
|
+
return Array.from(modelMap.values());
|
|
78
|
+
}
|
|
79
|
+
async function loadModels(modelsDir) {
|
|
80
|
+
return loadModelsFromDir(modelsDir, !1);
|
|
81
|
+
}
|
|
82
|
+
function isPasswordField(fieldName, attr) {
|
|
83
|
+
const lowerName = fieldName.toLowerCase();
|
|
84
|
+
if (lowerName === "password" || lowerName.endsWith("_password") || lowerName.endsWith("password"))
|
|
85
|
+
return !0;
|
|
86
|
+
if (attr.hidden === !0 && lowerName.includes("pass"))
|
|
87
|
+
return !0;
|
|
88
|
+
return !1;
|
|
89
|
+
}
|
|
90
|
+
async function generateRecord(attributes, modelName, verbose = !1) {
|
|
91
|
+
const record = {};
|
|
92
|
+
for (const [fieldName, attr] of Object.entries(attributes)) {
|
|
93
|
+
const columnName = snakeCase(fieldName);
|
|
94
|
+
let value;
|
|
95
|
+
if (attr.factory && typeof attr.factory === "function")
|
|
96
|
+
try {
|
|
97
|
+
value = attr.factory(faker);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
100
|
+
if (verbose)
|
|
101
|
+
log.warn(` Factory failed for ${modelName}.${fieldName}: ${errorMsg}`);
|
|
102
|
+
if (attr.default !== void 0)
|
|
103
|
+
value = attr.default;
|
|
104
|
+
else
|
|
105
|
+
value = inferDefaultValue(fieldName);
|
|
106
|
+
}
|
|
107
|
+
else if (attr.default !== void 0)
|
|
108
|
+
value = attr.default;
|
|
109
|
+
else
|
|
110
|
+
continue;
|
|
111
|
+
if (isPasswordField(fieldName, attr) && typeof value === "string")
|
|
112
|
+
try {
|
|
113
|
+
value = await hashMake(value, { algorithm: "bcrypt" });
|
|
114
|
+
} catch (err) {
|
|
115
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
116
|
+
if (verbose)
|
|
117
|
+
log.warn(` Failed to hash password for ${modelName}.${fieldName}: ${errorMsg}`);
|
|
118
|
+
}
|
|
119
|
+
record[columnName] = value;
|
|
120
|
+
}
|
|
121
|
+
return record;
|
|
122
|
+
}
|
|
123
|
+
function inferDefaultValue(fieldName) {
|
|
124
|
+
const lowerName = fieldName.toLowerCase();
|
|
125
|
+
if (lowerName.startsWith("is") || lowerName.startsWith("has") || lowerName.endsWith("able"))
|
|
126
|
+
return !1;
|
|
127
|
+
if (lowerName.includes("count") || lowerName.includes("amount") || lowerName.includes("quantity"))
|
|
128
|
+
return 0;
|
|
129
|
+
if (lowerName.includes("url") || lowerName.includes("link"))
|
|
130
|
+
return "https://example.com";
|
|
131
|
+
if (lowerName.includes("email"))
|
|
132
|
+
return faker.internet.email();
|
|
133
|
+
if (lowerName.includes("name"))
|
|
134
|
+
return faker.person.fullName();
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
function fixtureToColumns(fixture) {
|
|
138
|
+
const out = {};
|
|
139
|
+
for (const [key, value] of Object.entries(fixture))
|
|
140
|
+
out[snakeCase(key)] = value;
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
async function generateRecords(model, verbose = !1) {
|
|
144
|
+
const records = [];
|
|
145
|
+
for (let i = 0;i < model.count; i++) {
|
|
146
|
+
const record = await generateRecord(model.attributes, model.name, verbose && i === 0), fixture = model.fixtures[i];
|
|
147
|
+
records.push(fixture ? { ...record, ...fixtureToColumns(fixture) } : record);
|
|
148
|
+
}
|
|
149
|
+
return records;
|
|
150
|
+
}
|
|
151
|
+
export function seedModelDirect(model, options) {
|
|
152
|
+
return seedModel(model, options);
|
|
153
|
+
}
|
|
154
|
+
async function seedModel(model, options) {
|
|
155
|
+
const startTime = Date.now();
|
|
156
|
+
try {
|
|
157
|
+
try {
|
|
158
|
+
await db.selectFrom(model.table).limit(0).execute();
|
|
159
|
+
} catch (tableErr) {
|
|
160
|
+
const msg = tableErr?.message || "";
|
|
161
|
+
if (msg.includes("does not exist") || msg.includes("no such table") || msg.includes("doesn't exist")) {
|
|
162
|
+
log.info(` Skipping ${model.name}: table "${model.table}" does not exist`);
|
|
163
|
+
return {
|
|
164
|
+
model: model.name,
|
|
165
|
+
table: model.table,
|
|
166
|
+
count: 0,
|
|
167
|
+
success: !0,
|
|
168
|
+
duration: Date.now() - startTime
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
throw tableErr;
|
|
172
|
+
}
|
|
173
|
+
if (!options.fresh) {
|
|
174
|
+
if (await db.selectFrom(model.table).selectAll().limit(1).executeTakeFirst()) {
|
|
175
|
+
if (options.verbose)
|
|
176
|
+
log.info(` ${model.name}: table already has rows \u2014 skipping (use --fresh to replace)`);
|
|
177
|
+
return {
|
|
178
|
+
model: model.name,
|
|
179
|
+
table: model.table,
|
|
180
|
+
count: 0,
|
|
181
|
+
success: !0,
|
|
182
|
+
duration: Date.now() - startTime
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const records = await generateRecords(model, options.verbose);
|
|
187
|
+
if (records.length === 0)
|
|
188
|
+
return {
|
|
189
|
+
model: model.name,
|
|
190
|
+
table: model.table,
|
|
191
|
+
count: 0,
|
|
192
|
+
success: !0,
|
|
193
|
+
duration: Date.now() - startTime
|
|
194
|
+
};
|
|
195
|
+
if (options.fresh)
|
|
196
|
+
try {
|
|
197
|
+
await db.deleteFrom(model.table).execute();
|
|
198
|
+
if (options.verbose)
|
|
199
|
+
log.info(` Truncated table: ${model.table}`);
|
|
200
|
+
} catch {}
|
|
201
|
+
const batchSize = 100;
|
|
202
|
+
let inserted = 0;
|
|
203
|
+
for (let i = 0;i < records.length; i += batchSize) {
|
|
204
|
+
const batch = records.slice(i, i + batchSize);
|
|
205
|
+
await db.insertInto(model.table).values(batch).execute();
|
|
206
|
+
inserted += batch.length;
|
|
207
|
+
}
|
|
208
|
+
if (options.verbose)
|
|
209
|
+
log.success(` Seeded ${model.name}: ${inserted} records`);
|
|
210
|
+
return {
|
|
211
|
+
model: model.name,
|
|
212
|
+
table: model.table,
|
|
213
|
+
count: inserted,
|
|
214
|
+
success: !0,
|
|
215
|
+
duration: Date.now() - startTime
|
|
216
|
+
};
|
|
217
|
+
} catch (err) {
|
|
218
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
219
|
+
if (options.verbose)
|
|
220
|
+
log.error(` Failed to seed ${model.name}: ${errorMessage}`);
|
|
221
|
+
return {
|
|
222
|
+
model: model.name,
|
|
223
|
+
table: model.table,
|
|
224
|
+
count: 0,
|
|
225
|
+
success: !1,
|
|
226
|
+
error: errorMessage,
|
|
227
|
+
duration: Date.now() - startTime
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function sortModelsByDependencies(models) {
|
|
232
|
+
const priority = {
|
|
233
|
+
User: 0,
|
|
234
|
+
Team: 1,
|
|
235
|
+
Project: 2
|
|
236
|
+
};
|
|
237
|
+
return models.sort((a, b) => {
|
|
238
|
+
const priorityA = priority[a.name] ?? 10, priorityB = priority[b.name] ?? 10;
|
|
239
|
+
return priorityA - priorityB;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
export async function seed(config = {}) {
|
|
243
|
+
const startTime = Date.now();
|
|
244
|
+
await ensureDatabaseConfigLoaded();
|
|
245
|
+
const modelsDir = config.modelsDir || path.userModelsPath(), verbose = config.verbose ?? !0;
|
|
246
|
+
if (verbose) {
|
|
247
|
+
log.info("Seeding database using model factories...");
|
|
248
|
+
log.info(`User models directory: ${modelsDir}`);
|
|
249
|
+
log.info(`Default models directory: ${defaultModelsPath()}`);
|
|
250
|
+
}
|
|
251
|
+
let models = await loadAllModels(modelsDir, verbose, config.includeDefaults ?? !1);
|
|
252
|
+
if (models.length === 0) {
|
|
253
|
+
log.warn("No seedable models found in defaults or user directories");
|
|
254
|
+
return {
|
|
255
|
+
total: 0,
|
|
256
|
+
successful: 0,
|
|
257
|
+
failed: 0,
|
|
258
|
+
results: [],
|
|
259
|
+
duration: Date.now() - startTime
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
log.warn(`[seed] The \`useSeeder\` trait + auto-walker is deprecated (stacksjs/stacks#1919, #1929). Run \`./buddy seed:scaffold\` to generate a class seeder per \`useSeeder\` model AND strip the trait from the model in one pass. The walker + trait are scheduled for removal in the next major. Affected: ${models.map((m) => m.name).join(", ")}`);
|
|
263
|
+
if (config.only && config.only.length > 0)
|
|
264
|
+
models = models.filter((m) => config.only.includes(m.name));
|
|
265
|
+
if (config.except && config.except.length > 0)
|
|
266
|
+
models = models.filter((m) => !config.except.includes(m.name));
|
|
267
|
+
if (!config.fresh && !config.allowProtected) {
|
|
268
|
+
const skipped = [];
|
|
269
|
+
models = models.filter((m) => {
|
|
270
|
+
if (isProtectedModel(m.name)) {
|
|
271
|
+
skipped.push(m);
|
|
272
|
+
return !1;
|
|
273
|
+
}
|
|
274
|
+
return !0;
|
|
275
|
+
});
|
|
276
|
+
if (skipped.length > 0) {
|
|
277
|
+
log.info(`Skipped ${skipped.length} protected auth model(s) to avoid invalidating live sessions: ${skipped.map((m) => m.name).join(", ")}`);
|
|
278
|
+
log.info(" Re-run with --fresh (truncates tables first) or --allow-protected to include them.");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
models = sortModelsByDependencies(models);
|
|
282
|
+
if (verbose)
|
|
283
|
+
log.info(`Found ${models.length} seedable model(s)`);
|
|
284
|
+
const results = [];
|
|
285
|
+
for (const model of models) {
|
|
286
|
+
if (verbose)
|
|
287
|
+
log.info(`Seeding ${model.name} (${model.count} records)...`);
|
|
288
|
+
try {
|
|
289
|
+
const result = await seedModel(model, config);
|
|
290
|
+
results.push(result);
|
|
291
|
+
} catch (err) {
|
|
292
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
293
|
+
if (verbose)
|
|
294
|
+
log.error(` Unexpected error seeding ${model.name}: ${errorMessage}`);
|
|
295
|
+
results.push({
|
|
296
|
+
model: model.name,
|
|
297
|
+
table: model.table,
|
|
298
|
+
count: 0,
|
|
299
|
+
success: !1,
|
|
300
|
+
error: errorMessage,
|
|
301
|
+
duration: 0
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const successful = results.filter((r) => r.success).length, failed = results.filter((r) => !r.success).length, totalRecords = results.reduce((sum, r) => sum + r.count, 0);
|
|
306
|
+
if (verbose) {
|
|
307
|
+
log.info("");
|
|
308
|
+
if (failed === 0) {
|
|
309
|
+
log.success("Database seeded successfully!");
|
|
310
|
+
log.info(` Total records: ${totalRecords}`);
|
|
311
|
+
log.info(` Models seeded: ${successful}`);
|
|
312
|
+
} else {
|
|
313
|
+
log.warn(`Seeding completed with ${failed} failure(s)`);
|
|
314
|
+
log.info(` Successful: ${successful}`);
|
|
315
|
+
log.info(` Failed: ${failed}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
total: results.length,
|
|
320
|
+
successful,
|
|
321
|
+
failed,
|
|
322
|
+
results,
|
|
323
|
+
duration: Date.now() - startTime
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
export async function seedModel$(modelName, options = {}) {
|
|
327
|
+
const modelsDir = path.userModelsPath(), model = (await loadAllModels(modelsDir, options.verbose)).find((m) => m.name === modelName);
|
|
328
|
+
if (!model)
|
|
329
|
+
throw Error(`Model not found: ${modelName}`);
|
|
330
|
+
if (options.count)
|
|
331
|
+
model.count = options.count;
|
|
332
|
+
return seedModel(model, {
|
|
333
|
+
fresh: options.fresh,
|
|
334
|
+
verbose: options.verbose ?? !0
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
export async function freshSeed(config = {}) {
|
|
338
|
+
return seed({ ...config, fresh: !0 });
|
|
339
|
+
}
|
|
340
|
+
export async function listSeedableModels() {
|
|
341
|
+
const modelsDir = path.userModelsPath(), defaultDir = defaultModelsPath(), defaultModels = await loadModelsFromDir(defaultDir, !0), userModels = await loadModelsFromDir(modelsDir, !1), result = [], seen = new Set;
|
|
342
|
+
for (const m of userModels) {
|
|
343
|
+
result.push({
|
|
344
|
+
name: m.name,
|
|
345
|
+
table: m.table,
|
|
346
|
+
count: m.count,
|
|
347
|
+
source: "user"
|
|
348
|
+
});
|
|
349
|
+
seen.add(m.name);
|
|
350
|
+
}
|
|
351
|
+
for (const m of defaultModels)
|
|
352
|
+
if (!seen.has(m.name))
|
|
353
|
+
result.push({
|
|
354
|
+
name: m.name,
|
|
355
|
+
table: m.table,
|
|
356
|
+
count: m.count,
|
|
357
|
+
source: "default"
|
|
358
|
+
});
|
|
359
|
+
return result;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export { seed as runSeeders };
|
|
363
|
+
export { freshSeed as freshWithSeed };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function sqlHelpers(driver) {
|
|
2
|
+
const isPostgres = driver === "postgres", isMysql = driver === "mysql" || driver === "singlestore";
|
|
3
|
+
return {
|
|
4
|
+
driver,
|
|
5
|
+
isPostgres,
|
|
6
|
+
isMysql,
|
|
7
|
+
isSqlite: !isPostgres && !isMysql,
|
|
8
|
+
now: isPostgres || isMysql ? "NOW()" : "datetime('now')",
|
|
9
|
+
boolTrue: isPostgres ? "true" : "1",
|
|
10
|
+
boolFalse: isPostgres ? "false" : "0",
|
|
11
|
+
autoIncrement: isPostgres ? "SERIAL" : "INTEGER",
|
|
12
|
+
primaryKey: isPostgres ? "PRIMARY KEY" : isMysql ? "PRIMARY KEY AUTO_INCREMENT" : "PRIMARY KEY AUTOINCREMENT",
|
|
13
|
+
pkColumn: isPostgres ? "id SERIAL PRIMARY KEY" : isMysql ? "id INTEGER PRIMARY KEY AUTO_INCREMENT" : "id INTEGER PRIMARY KEY AUTOINCREMENT",
|
|
14
|
+
nullableTimestamp: isMysql ? "TIMESTAMP NULL" : "TIMESTAMP",
|
|
15
|
+
param(index) {
|
|
16
|
+
return isPostgres ? `$${index}` : "?";
|
|
17
|
+
},
|
|
18
|
+
params(...values) {
|
|
19
|
+
if (isPostgres)
|
|
20
|
+
return { sql: values.map((_, i) => `$${i + 1}`).join(", "), values };
|
|
21
|
+
return { sql: values.map(() => "?").join(", "), values };
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
package/dist/table.d.ts
ADDED
package/dist/table.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { log } from "@stacksjs/logging";
|
|
2
|
+
import { Column } from "./column";
|
|
3
|
+
|
|
4
|
+
export class Table {
|
|
5
|
+
columns = [];
|
|
6
|
+
increments(name) {
|
|
7
|
+
const column = new Column(name, "integer", {
|
|
8
|
+
primaryKey: !0,
|
|
9
|
+
autoIncrement: !0
|
|
10
|
+
});
|
|
11
|
+
this.columns.push(column);
|
|
12
|
+
return column;
|
|
13
|
+
}
|
|
14
|
+
string(name, varchar = 255) {
|
|
15
|
+
const column = new Column(name, `varchar(${varchar})`);
|
|
16
|
+
this.columns.push(column);
|
|
17
|
+
return column;
|
|
18
|
+
}
|
|
19
|
+
timestamps() {
|
|
20
|
+
this.columns.push(new Column("created_at", "timestamp"));
|
|
21
|
+
this.columns.push(new Column("updated_at", "timestamp"));
|
|
22
|
+
}
|
|
23
|
+
execute() {
|
|
24
|
+
log.info(`Creating table with columns: ${this.columns.map((col) => col.name).join(", ")}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
const transactionStorage = new AsyncLocalStorage;
|
|
3
|
+
export function isInTransaction() {
|
|
4
|
+
return transactionStorage.getStore() !== void 0;
|
|
5
|
+
}
|
|
6
|
+
export function enqueueAfterCommit(callback) {
|
|
7
|
+
const scope = transactionStorage.getStore();
|
|
8
|
+
if (!scope)
|
|
9
|
+
return !1;
|
|
10
|
+
scope.pending.push(callback);
|
|
11
|
+
return !0;
|
|
12
|
+
}
|
|
13
|
+
export async function runInTransactionScope(fn, options = {}) {
|
|
14
|
+
const existing = transactionStorage.getStore();
|
|
15
|
+
if (existing) {
|
|
16
|
+
existing.depth += 1;
|
|
17
|
+
try {
|
|
18
|
+
return await fn();
|
|
19
|
+
} finally {
|
|
20
|
+
existing.depth -= 1;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const scope = {
|
|
24
|
+
pending: [],
|
|
25
|
+
depth: 1,
|
|
26
|
+
onError: options.onError
|
|
27
|
+
};
|
|
28
|
+
let result;
|
|
29
|
+
try {
|
|
30
|
+
result = await transactionStorage.run(scope, fn);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
scope.pending.length = 0;
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
await flushScope(scope);
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
async function flushScope(scope) {
|
|
39
|
+
for (let i = 0;i < scope.pending.length; i++)
|
|
40
|
+
try {
|
|
41
|
+
await scope.pending[i]();
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (scope.onError)
|
|
44
|
+
try {
|
|
45
|
+
scope.onError(err, i);
|
|
46
|
+
} catch {}
|
|
47
|
+
else
|
|
48
|
+
console.error("[transaction-context] after-commit callback threw:", err);
|
|
49
|
+
}
|
|
50
|
+
scope.pending.length = 0;
|
|
51
|
+
}
|
|
52
|
+
export async function __flushAfterCommitNow() {
|
|
53
|
+
const scope = transactionStorage.getStore();
|
|
54
|
+
if (!scope)
|
|
55
|
+
return 0;
|
|
56
|
+
const count = scope.pending.length;
|
|
57
|
+
await flushScope(scope);
|
|
58
|
+
return count;
|
|
59
|
+
}
|
|
60
|
+
export function __pendingAfterCommitCount() {
|
|
61
|
+
return transactionStorage.getStore()?.pending.length ?? 0;
|
|
62
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function sql(strings, ...values) {
|
|
2
|
+
const sqlParts = [], parameters = [];
|
|
3
|
+
for (let i = 0;i < strings.length; i++) {
|
|
4
|
+
sqlParts.push(strings[i]);
|
|
5
|
+
if (i < values.length)
|
|
6
|
+
if (values[i] && typeof values[i] === "object" && "raw" in values[i])
|
|
7
|
+
sqlParts.push(values[i].raw);
|
|
8
|
+
else {
|
|
9
|
+
sqlParts.push("?");
|
|
10
|
+
parameters.push(values[i]);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
sql: sqlParts.join(""),
|
|
15
|
+
parameters
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
sql.raw = function raw(value) {
|
|
19
|
+
return { raw: value };
|
|
20
|
+
};
|
|
21
|
+
sql.ref = function ref(column) {
|
|
22
|
+
return { raw: column };
|
|
23
|
+
};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { path } from "@stacksjs/path";
|
|
2
|
+
import { plural, snakeCase } from "@stacksjs/strings";
|
|
3
|
+
import { safeGlob } from "./fk-audit";
|
|
4
|
+
export async function getDeclaredUniques() {
|
|
5
|
+
const modelFiles = [
|
|
6
|
+
...safeGlob(path.userModelsPath("*.ts")),
|
|
7
|
+
...safeGlob(path.storagePath("framework/defaults/app/Models/**/*.ts"))
|
|
8
|
+
], declared = [];
|
|
9
|
+
for (const modelFile of modelFiles) {
|
|
10
|
+
let model;
|
|
11
|
+
try {
|
|
12
|
+
model = (await import(modelFile)).default;
|
|
13
|
+
} catch {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (!model || typeof model !== "object")
|
|
17
|
+
continue;
|
|
18
|
+
const table = model.table || plural(snakeCase(model.name || "")), modelName = model.name || "", attributes = model.attributes;
|
|
19
|
+
if (attributes && typeof attributes === "object") {
|
|
20
|
+
for (const [field, attr] of Object.entries(attributes))
|
|
21
|
+
if (attr && typeof attr === "object" && attr.unique === !0)
|
|
22
|
+
declared.push({
|
|
23
|
+
table,
|
|
24
|
+
columns: [snakeCase(field)],
|
|
25
|
+
model: modelName,
|
|
26
|
+
source: "attribute"
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const indexes = model.indexes;
|
|
30
|
+
if (Array.isArray(indexes)) {
|
|
31
|
+
for (const index of indexes)
|
|
32
|
+
if (index && index.unique === !0 && Array.isArray(index.columns) && index.columns.length > 0)
|
|
33
|
+
declared.push({
|
|
34
|
+
table,
|
|
35
|
+
columns: index.columns.map((c) => snakeCase(c)),
|
|
36
|
+
model: modelName,
|
|
37
|
+
source: "index",
|
|
38
|
+
indexName: index.name
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return declared;
|
|
43
|
+
}
|
|
44
|
+
export async function getLiveUniqueIndexes(dialect) {
|
|
45
|
+
const { db } = await import("./utils"), d = dialect ?? await currentDialect();
|
|
46
|
+
if (d === "sqlite")
|
|
47
|
+
return getSqliteLiveUniques(db);
|
|
48
|
+
if (d === "mysql")
|
|
49
|
+
return getMysqlLiveUniques(db);
|
|
50
|
+
if (d === "postgres")
|
|
51
|
+
return getPostgresLiveUniques(db);
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
export async function auditUniqueIndexes(dialect) {
|
|
55
|
+
const d = dialect ?? await currentDialect();
|
|
56
|
+
if (d !== "sqlite" && d !== "mysql" && d !== "postgres")
|
|
57
|
+
return { supported: !1, declared: [], live: [], missing: [], skippedTables: [] };
|
|
58
|
+
const declared = await getDeclaredUniques(), live = await getLiveUniqueIndexes(d), liveTables = await getLiveTables(d), liveByTable = new Map;
|
|
59
|
+
for (const idx of live) {
|
|
60
|
+
const t = idx.table.toLowerCase(), key = columnSetKey(idx.columns);
|
|
61
|
+
if (!liveByTable.has(t))
|
|
62
|
+
liveByTable.set(t, new Set);
|
|
63
|
+
liveByTable.get(t).add(key);
|
|
64
|
+
}
|
|
65
|
+
const missing = [], skippedTables = new Set;
|
|
66
|
+
for (const decl of declared) {
|
|
67
|
+
const t = decl.table.toLowerCase();
|
|
68
|
+
if (!liveTables.has(t)) {
|
|
69
|
+
skippedTables.add(decl.table);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const key = columnSetKey(decl.columns);
|
|
73
|
+
if (!(liveByTable.get(t)?.has(key) ?? !1))
|
|
74
|
+
missing.push(decl);
|
|
75
|
+
}
|
|
76
|
+
return { supported: !0, declared, live, missing, skippedTables: [...skippedTables] };
|
|
77
|
+
}
|
|
78
|
+
async function getLiveTables(dialect) {
|
|
79
|
+
const { db } = await import("./utils");
|
|
80
|
+
let rows = [];
|
|
81
|
+
if (dialect === "sqlite") {
|
|
82
|
+
const r = await db.unsafe("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").execute();
|
|
83
|
+
rows = (Array.isArray(r) ? r : []).map((x) => x.name);
|
|
84
|
+
} else if (dialect === "mysql") {
|
|
85
|
+
const r = await db.unsafe("SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()").execute();
|
|
86
|
+
rows = (Array.isArray(r) ? r : []).map((x) => x.name ?? x.TABLE_NAME);
|
|
87
|
+
} else if (dialect === "postgres") {
|
|
88
|
+
const r = await db.unsafe("SELECT tablename AS name FROM pg_tables WHERE schemaname = 'public'").execute();
|
|
89
|
+
rows = (Array.isArray(r) ? r : []).map((x) => x.name ?? x.tablename);
|
|
90
|
+
}
|
|
91
|
+
return new Set(rows.filter((n) => typeof n === "string" && n.length > 0).map((n) => n.toLowerCase()));
|
|
92
|
+
}
|
|
93
|
+
function columnSetKey(columns) {
|
|
94
|
+
return [...columns].map((c) => c.toLowerCase()).sort().join(",");
|
|
95
|
+
}
|
|
96
|
+
async function currentDialect() {
|
|
97
|
+
const driver = ((await import("@stacksjs/env")).env?.DB_CONNECTION ?? "sqlite").toLowerCase();
|
|
98
|
+
if (driver === "sqlite" || driver === "mysql" || driver === "postgres")
|
|
99
|
+
return driver;
|
|
100
|
+
return "other";
|
|
101
|
+
}
|
|
102
|
+
async function getSqliteLiveUniques(db) {
|
|
103
|
+
const tables = await db.unsafe("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").execute(), rows = Array.isArray(tables) ? tables : [], out = [];
|
|
104
|
+
for (const row of rows) {
|
|
105
|
+
const table = row.name;
|
|
106
|
+
if (!table)
|
|
107
|
+
continue;
|
|
108
|
+
if (!/^[a-z_]\w*$/i.test(table))
|
|
109
|
+
continue;
|
|
110
|
+
const indexRows = await db.unsafe(`PRAGMA index_list("${table}")`).execute();
|
|
111
|
+
for (const idx of Array.isArray(indexRows) ? indexRows : []) {
|
|
112
|
+
const r = idx;
|
|
113
|
+
if (Number(r.unique) !== 1 || !r.name)
|
|
114
|
+
continue;
|
|
115
|
+
if (!/^[a-z_]\w*$/i.test(r.name))
|
|
116
|
+
continue;
|
|
117
|
+
const colRows = await db.unsafe(`PRAGMA index_info("${r.name}")`).execute(), columns = (Array.isArray(colRows) ? colRows : []).map((c) => String(c.name ?? "")).filter((c) => c.length > 0);
|
|
118
|
+
if (columns.length > 0)
|
|
119
|
+
out.push({ table, name: r.name, columns });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
async function getMysqlLiveUniques(db) {
|
|
125
|
+
const rows = await db.unsafe(`
|
|
126
|
+
SELECT TABLE_NAME, INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX
|
|
127
|
+
FROM information_schema.STATISTICS
|
|
128
|
+
WHERE TABLE_SCHEMA = DATABASE()
|
|
129
|
+
AND NON_UNIQUE = 0
|
|
130
|
+
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX
|
|
131
|
+
`).execute();
|
|
132
|
+
return groupIndexRows(Array.isArray(rows) ? rows : [], (r) => ({
|
|
133
|
+
table: String(r.TABLE_NAME ?? r.table_name ?? ""),
|
|
134
|
+
name: String(r.INDEX_NAME ?? r.index_name ?? ""),
|
|
135
|
+
column: String(r.COLUMN_NAME ?? r.column_name ?? "")
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
async function getPostgresLiveUniques(db) {
|
|
139
|
+
const rows = await db.unsafe(`
|
|
140
|
+
SELECT
|
|
141
|
+
t.relname AS table_name,
|
|
142
|
+
ix.relname AS index_name,
|
|
143
|
+
a.attname AS column_name,
|
|
144
|
+
k.ord AS seq_in_index
|
|
145
|
+
FROM pg_index i
|
|
146
|
+
JOIN pg_class t ON t.oid = i.indrelid
|
|
147
|
+
JOIN pg_class ix ON ix.oid = i.indexrelid
|
|
148
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
149
|
+
JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord) ON true
|
|
150
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
|
|
151
|
+
WHERE i.indisunique = true
|
|
152
|
+
AND n.nspname = 'public'
|
|
153
|
+
ORDER BY table_name, index_name, seq_in_index
|
|
154
|
+
`).execute();
|
|
155
|
+
return groupIndexRows(Array.isArray(rows) ? rows : [], (r) => ({
|
|
156
|
+
table: String(r.table_name ?? ""),
|
|
157
|
+
name: String(r.index_name ?? ""),
|
|
158
|
+
column: String(r.column_name ?? "")
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
function groupIndexRows(rows, pick) {
|
|
162
|
+
const map = new Map;
|
|
163
|
+
for (const row of rows) {
|
|
164
|
+
const { table, name, column } = pick(row);
|
|
165
|
+
if (!table || !name || !column)
|
|
166
|
+
continue;
|
|
167
|
+
const key = `${table} ${name}`, existing = map.get(key);
|
|
168
|
+
if (existing)
|
|
169
|
+
existing.columns.push(column);
|
|
170
|
+
else
|
|
171
|
+
map.set(key, { table, name, columns: [column] });
|
|
172
|
+
}
|
|
173
|
+
return [...map.values()];
|
|
174
|
+
}
|