@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.
Files changed (46) hide show
  1. package/dist/auth-tables.js +220 -0
  2. package/dist/class-seeder.js +116 -0
  3. package/dist/column.d.ts +17 -0
  4. package/dist/column.js +26 -0
  5. package/dist/custom/audits.js +57 -0
  6. package/dist/custom/errors.js +48 -0
  7. package/dist/custom/index.js +3 -0
  8. package/dist/custom/jobs.js +449 -0
  9. package/dist/database.js +178 -0
  10. package/dist/defaults.js +48 -0
  11. package/dist/driver-config.js +144 -0
  12. package/dist/drivers/defaults/index.js +2 -0
  13. package/dist/drivers/defaults/passwords.js +106 -0
  14. package/dist/drivers/defaults/traits.js +1125 -0
  15. package/dist/drivers/dynamodb.js +607 -0
  16. package/dist/drivers/helpers.js +206 -0
  17. package/dist/drivers/index.js +9 -0
  18. package/dist/drivers/mysql.js +322 -0
  19. package/dist/drivers/postgres.js +411 -0
  20. package/dist/drivers/sqlite.js +397 -0
  21. package/dist/factory.js +51 -0
  22. package/dist/fk-audit.js +181 -0
  23. package/dist/index.js +55 -1263
  24. package/dist/migration-lock.js +143 -0
  25. package/dist/migrations.js +528 -0
  26. package/dist/notification-tables.js +54 -0
  27. package/dist/query-logger.js +213 -0
  28. package/dist/query-parser.js +93 -0
  29. package/dist/rbac-tables.js +84 -0
  30. package/dist/safe-migrations.js +59 -0
  31. package/dist/schema.d.ts +4 -0
  32. package/dist/schema.js +10 -0
  33. package/dist/seed-scaffold.js +144 -0
  34. package/dist/seeder.js +363 -0
  35. package/dist/sql-helpers.js +24 -0
  36. package/dist/table.d.ts +7 -0
  37. package/dist/table.js +26 -0
  38. package/dist/tools/setup.d.ts +1 -0
  39. package/dist/tools/setup.js +6 -0
  40. package/dist/transaction-context.js +62 -0
  41. package/dist/types.js +23 -0
  42. package/dist/unique-audit.js +174 -0
  43. package/dist/utils.js +163 -0
  44. package/dist/uuid-columns.js +68 -0
  45. package/dist/validators.js +122 -0
  46. package/package.json +11 -11
@@ -0,0 +1,54 @@
1
+ import { log } from "@stacksjs/logging";
2
+ import { env as envVars } from "@stacksjs/env";
3
+ import { db } from "./utils";
4
+ import { sqlHelpers } from "./sql-helpers";
5
+ function getDbDriver() {
6
+ return envVars.DB_CONNECTION || "sqlite";
7
+ }
8
+ export function notificationsTableSql(sql) {
9
+ const { pkColumn, nullableTimestamp } = sql;
10
+ return `CREATE TABLE IF NOT EXISTS notifications (
11
+ ${pkColumn},
12
+ user_id INTEGER NOT NULL,
13
+ type VARCHAR(255) NOT NULL,
14
+ data TEXT NOT NULL,
15
+ read_at ${nullableTimestamp},
16
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
17
+ updated_at ${nullableTimestamp}
18
+ )`;
19
+ }
20
+ export function notificationPreferencesTableSql(sql) {
21
+ const { pkColumn, boolTrue, nullableTimestamp } = sql;
22
+ return `CREATE TABLE IF NOT EXISTS notification_preferences (
23
+ ${pkColumn},
24
+ user_id INTEGER NOT NULL,
25
+ channel VARCHAR(50) NOT NULL,
26
+ enabled BOOLEAN NOT NULL DEFAULT ${boolTrue},
27
+ category VARCHAR(255),
28
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
29
+ updated_at ${nullableTimestamp},
30
+ UNIQUE (user_id, channel, category)
31
+ )`;
32
+ }
33
+ export async function migrateNotificationTables(options = {}) {
34
+ const dbDriver = getDbDriver(), sql = sqlHelpers(dbDriver);
35
+ if (options.verbose)
36
+ log.info(`Creating notification tables for ${dbDriver}...`);
37
+ try {
38
+ if (options.verbose)
39
+ log.info("Creating notifications table...");
40
+ await db.unsafe(notificationsTableSql(sql)).execute();
41
+ await db.unsafe("CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_id)").execute();
42
+ if (options.verbose)
43
+ log.info("Creating notification_preferences table...");
44
+ await db.unsafe(notificationPreferencesTableSql(sql)).execute();
45
+ await db.unsafe("CREATE INDEX IF NOT EXISTS idx_notification_preferences_user ON notification_preferences (user_id)").execute();
46
+ if (options.verbose)
47
+ log.success("Notification tables created");
48
+ return { success: !0 };
49
+ } catch (error) {
50
+ const message = error instanceof Error ? error.message : String(error);
51
+ log.error(`Failed to create notification tables: ${message}`);
52
+ return { success: !1, error: message };
53
+ }
54
+ }
@@ -0,0 +1,213 @@
1
+ import { memoryUsage } from "node:process";
2
+ import { config } from "@stacksjs/config";
3
+ import { log } from "@stacksjs/logging";
4
+ import { parseQuery } from "./query-parser";
5
+ import { db } from "./utils";
6
+ let trackQuery = () => {};
7
+ export function setQueryTracker(fn) {
8
+ trackQuery = fn;
9
+ }
10
+ let isLogging = !1;
11
+ export async function logQuery(event) {
12
+ if (isLogging)
13
+ return;
14
+ try {
15
+ const { query, durationMs, error, bindings } = extractQueryInfo(event);
16
+ try {
17
+ trackQuery(query, durationMs, config.database?.default || "unknown");
18
+ } catch {}
19
+ if (!config.database?.queryLogging?.enabled)
20
+ return;
21
+ const status = determineQueryStatus(durationMs, error), logRecord = await createQueryLogRecord(query, durationMs, status, error, bindings);
22
+ if (config.database?.queryLogging?.analysis?.enabled && (status === "slow" || config.database.queryLogging.analysis.analyzeAll))
23
+ await enhanceWithQueryAnalysis(logRecord);
24
+ isLogging = !0;
25
+ try {
26
+ await storeQueryLog(logRecord);
27
+ } finally {
28
+ isLogging = !1;
29
+ }
30
+ if (status !== "completed")
31
+ log[status === "failed" ? "error" : "warn"](`Query ${status}:`, {
32
+ query: logRecord.query,
33
+ duration: logRecord.duration,
34
+ connection: logRecord.connection,
35
+ ...error && { error }
36
+ });
37
+ } catch (err) {
38
+ log.error("Failed to log query:", err);
39
+ }
40
+ }
41
+ function extractQueryInfo(event) {
42
+ const query = event.query?.sql || "", durationMs = event.queryDurationMillis || 0, error = event.error;
43
+ let bindings;
44
+ if (event.query?.parameters)
45
+ try {
46
+ bindings = JSON.stringify(event.query.parameters);
47
+ } catch {
48
+ bindings = "[]";
49
+ }
50
+ return { query, durationMs, error, bindings };
51
+ }
52
+ function determineQueryStatus(durationMs, error) {
53
+ const slowThreshold = config.database?.queryLogging?.slowThreshold || 100;
54
+ if (error)
55
+ return "failed";
56
+ if (durationMs > slowThreshold)
57
+ return "slow";
58
+ return "completed";
59
+ }
60
+ async function createQueryLogRecord(query, durationMs, status, error, bindings) {
61
+ const connection = config.database.default || "unknown", normalizedQuery = parseQuery(query).normalized || query, { trace, caller } = extractTraceInfo();
62
+ return {
63
+ query,
64
+ normalized_query: normalizedQuery,
65
+ duration: durationMs,
66
+ connection,
67
+ status,
68
+ error: error ? String(error) : void 0,
69
+ executed_at: new Date().toISOString(),
70
+ bindings,
71
+ trace,
72
+ ...caller,
73
+ memory_usage: memoryUsage().heapUsed / 1024 / 1024
74
+ };
75
+ }
76
+ const SECRET_PATTERNS = [
77
+ /\b(?:sk|pk|rk|api|secret|token|bearer|password|pwd|key)[_-]?[A-Za-z0-9]{12,}/gi,
78
+ /\bsk_(?:live|test)_[A-Za-z0-9]{20,}/g,
79
+ /\bAKIA[0-9A-Z]{16}/g,
80
+ /\b[A-Za-z0-9_-]{40,}\b(?=\s|$|['"])/g
81
+ ];
82
+ function sanitizeStackTrace(stack) {
83
+ let out = stack;
84
+ for (const pattern of SECRET_PATTERNS)
85
+ out = out.replace(pattern, "<redacted>");
86
+ return out;
87
+ }
88
+ function extractTraceInfo() {
89
+ try {
90
+ const stack = Error("Stack trace capture").stack || "", callerLine = stack.split(`
91
+ `).slice(1).find((line) => !line.includes("query-logger.ts"));
92
+ let caller = {};
93
+ if (callerLine) {
94
+ const methodMatch = callerLine.match(/at (.+?) \(/), fileMatch = callerLine.match(/\((.+?):(\d+):(\d+)\)/);
95
+ if (methodMatch && methodMatch[1]) {
96
+ const methodParts = methodMatch[1].split(".");
97
+ caller = {
98
+ model: methodParts.length > 1 ? methodParts[0] : void 0,
99
+ method: methodParts.length > 1 ? methodParts[1] : methodParts[0]
100
+ };
101
+ }
102
+ if (fileMatch && fileMatch[1] && fileMatch[2])
103
+ caller = {
104
+ ...caller,
105
+ file: fileMatch[1],
106
+ line: Number.parseInt(fileMatch[2], 10)
107
+ };
108
+ }
109
+ return {
110
+ trace: sanitizeStackTrace(stack),
111
+ caller
112
+ };
113
+ } catch {
114
+ return { trace: "", caller: {} };
115
+ }
116
+ }
117
+ async function enhanceWithQueryAnalysis(logRecord) {
118
+ try {
119
+ const { tables, type } = parseQuery(logRecord.query);
120
+ logRecord.affected_tables = JSON.stringify(tables || []);
121
+ if (type === "SELECT" && config.database?.queryLogging?.analysis?.explainPlan) {
122
+ const explainResult = await getExplainPlan(logRecord.query);
123
+ if (explainResult) {
124
+ logRecord.explain_plan = explainResult.plan;
125
+ logRecord.indexes_used = JSON.stringify(explainResult.indexesUsed || []);
126
+ logRecord.missing_indexes = JSON.stringify(explainResult.missingIndexes || []);
127
+ if (config.database?.queryLogging?.analysis?.suggestions)
128
+ logRecord.optimization_suggestions = JSON.stringify(generateOptimizationSuggestions(explainResult, logRecord));
129
+ }
130
+ }
131
+ const tags = [type];
132
+ if (tables && tables.length > 0)
133
+ tags.push(...tables.map((table) => `table:${table}`));
134
+ logRecord.tags = JSON.stringify(tags);
135
+ } catch (error) {
136
+ log.debug("Error during query analysis:", error);
137
+ }
138
+ }
139
+ const EXPLAIN_DIALECTS = new Set(["sqlite", "mysql", "postgres"]);
140
+ async function getExplainPlan(query) {
141
+ try {
142
+ const rawDriver = (await import("@stacksjs/config")).config?.database?.default, driver = typeof rawDriver === "string" ? rawDriver : "sqlite";
143
+ if (!EXPLAIN_DIALECTS.has(driver))
144
+ return null;
145
+ let sqlText;
146
+ if (driver === "mysql")
147
+ sqlText = `EXPLAIN FORMAT=JSON ${query}`;
148
+ else if (driver === "postgres")
149
+ sqlText = `EXPLAIN (FORMAT JSON) ${query}`;
150
+ else
151
+ sqlText = `EXPLAIN QUERY PLAN ${query}`;
152
+ const result = await db.unsafe?.(sqlText);
153
+ if (!result)
154
+ return null;
155
+ const rows = Array.isArray(result) ? result : result.rows ?? [], planText = JSON.stringify(rows), indexesUsed = [], missingIndexes = [];
156
+ if (driver === "sqlite")
157
+ for (const row of rows) {
158
+ const detail = row?.detail || "", idxMatch = detail.match(/USING (?:COVERING )?INDEX (\w+)/i);
159
+ if (idxMatch && idxMatch[1])
160
+ indexesUsed.push(idxMatch[1]);
161
+ else {
162
+ const scanMatch = detail.match(/^SCAN\s+(\w+)/i);
163
+ if (scanMatch && scanMatch[1])
164
+ missingIndexes.push(scanMatch[1]);
165
+ }
166
+ }
167
+ else if (driver === "mysql") {
168
+ const text = planText;
169
+ for (const m of text.matchAll(/"key"\s*:\s*"([^"]+)"/g))
170
+ if (m[1])
171
+ indexesUsed.push(m[1]);
172
+ for (const m of text.matchAll(/"table_name"\s*:\s*"([^"]+)"[^}]*"access_type"\s*:\s*"ALL"/g))
173
+ if (m[1])
174
+ missingIndexes.push(m[1]);
175
+ } else if (driver === "postgres") {
176
+ const text = planText;
177
+ for (const m of text.matchAll(/"Index Name"\s*:\s*"([^"]+)"/g))
178
+ if (m[1])
179
+ indexesUsed.push(m[1]);
180
+ for (const m of text.matchAll(/"Node Type"\s*:\s*"Seq Scan"[^}]*"Relation Name"\s*:\s*"([^"]+)"/g))
181
+ if (m[1])
182
+ missingIndexes.push(m[1]);
183
+ }
184
+ return {
185
+ plan: planText.length > 4000 ? `${planText.slice(0, 4000)}\u2026` : planText,
186
+ indexesUsed: Array.from(new Set(indexesUsed)),
187
+ missingIndexes: Array.from(new Set(missingIndexes))
188
+ };
189
+ } catch (err) {
190
+ log.debug("[query-logger] EXPLAIN failed (non-fatal):", err);
191
+ return null;
192
+ }
193
+ }
194
+ function generateOptimizationSuggestions(explainResult, logRecord) {
195
+ const suggestions = [];
196
+ if (explainResult.missingIndexes && explainResult.missingIndexes.length > 0)
197
+ suggestions.push(`Consider adding index on ${explainResult.missingIndexes.join(", ")}`);
198
+ if (logRecord.status === "slow") {
199
+ suggestions.push("Consider optimizing this query to reduce execution time");
200
+ if (logRecord.query.toLowerCase().includes("select *"))
201
+ suggestions.push("Specify only needed columns instead of using SELECT *");
202
+ if (!logRecord.query.toLowerCase().includes("limit"))
203
+ suggestions.push("Consider adding LIMIT clause to reduce result set size");
204
+ }
205
+ return suggestions;
206
+ }
207
+ async function storeQueryLog(logRecord) {
208
+ try {
209
+ await db.insertInto("query_logs").values(logRecord).execute();
210
+ } catch (error) {
211
+ log.error("Failed to store query log:", error);
212
+ }
213
+ }
@@ -0,0 +1,93 @@
1
+ export function parseQuery(sql) {
2
+ const result = {
3
+ normalized: "",
4
+ type: "OTHER",
5
+ tables: []
6
+ };
7
+ if (!sql)
8
+ return result;
9
+ try {
10
+ const upperQuery = sql.trim().toUpperCase();
11
+ if (upperQuery.startsWith("SELECT"))
12
+ result.type = "SELECT";
13
+ else if (upperQuery.startsWith("INSERT"))
14
+ result.type = "INSERT";
15
+ else if (upperQuery.startsWith("UPDATE"))
16
+ result.type = "UPDATE";
17
+ else if (upperQuery.startsWith("DELETE"))
18
+ result.type = "DELETE";
19
+ result.tables = extractTables(sql, result.type);
20
+ result.normalized = normalizeQuery(sql);
21
+ return result;
22
+ } catch {
23
+ return {
24
+ normalized: sql,
25
+ type: "OTHER",
26
+ tables: []
27
+ };
28
+ }
29
+ }
30
+ function buildIdentifierRegex(keyword, flags = "i") {
31
+ return new RegExp(`${keyword}\\s+(?:"([^"]+)"|\`([^\`]+)\`|\\[([^\\]]+)\\]|([\\w.]+))`, flags);
32
+ }
33
+ function pickMatchedIdentifier(match) {
34
+ if (!match)
35
+ return null;
36
+ const ident = match[1] ?? match[2] ?? match[3] ?? match[4];
37
+ if (!ident)
38
+ return null;
39
+ return ident.split(".").pop() ?? null;
40
+ }
41
+ function extractTables(sql, type) {
42
+ const tables = [];
43
+ try {
44
+ switch (type) {
45
+ case "SELECT": {
46
+ const fromMatch = sql.match(buildIdentifierRegex("from")), fromTable = pickMatchedIdentifier(fromMatch);
47
+ if (fromTable)
48
+ tables.push(fromTable);
49
+ const joinRegex = buildIdentifierRegex("join", "gi");
50
+ for (const m of sql.matchAll(joinRegex)) {
51
+ const joinTable = pickMatchedIdentifier(m);
52
+ if (joinTable)
53
+ tables.push(joinTable);
54
+ }
55
+ break;
56
+ }
57
+ case "INSERT": {
58
+ const intoMatch = sql.match(buildIdentifierRegex("into")), intoTable = pickMatchedIdentifier(intoMatch);
59
+ if (intoTable)
60
+ tables.push(intoTable);
61
+ break;
62
+ }
63
+ case "UPDATE": {
64
+ const updateMatch = sql.match(buildIdentifierRegex("update")), updateTable = pickMatchedIdentifier(updateMatch);
65
+ if (updateTable)
66
+ tables.push(updateTable);
67
+ break;
68
+ }
69
+ case "DELETE": {
70
+ const deleteFromMatch = sql.match(buildIdentifierRegex("from")), deleteTable = pickMatchedIdentifier(deleteFromMatch);
71
+ if (deleteTable)
72
+ tables.push(deleteTable);
73
+ break;
74
+ }
75
+ }
76
+ } catch {}
77
+ return [...new Set(tables)].filter(Boolean);
78
+ }
79
+ function normalizeQuery(sql) {
80
+ try {
81
+ let normalizedSql = sql;
82
+ normalizedSql = normalizedSql.replace(/(?<![a-zA-Z_])\b\d+\b(?![a-zA-Z_])/g, "?");
83
+ normalizedSql = normalizedSql.replace(/'([^']|'')*'/g, "?");
84
+ normalizedSql = normalizedSql.replace(/"([^"]|"")*"/g, "?");
85
+ normalizedSql = normalizedSql.replace(/\btrue\b/gi, "?");
86
+ normalizedSql = normalizedSql.replace(/\bfalse\b/gi, "?");
87
+ normalizedSql = normalizedSql.replace(/\bnull\b/gi, "?");
88
+ normalizedSql = normalizedSql.replace(/\s+/g, " ").trim();
89
+ return normalizedSql;
90
+ } catch {
91
+ return sql;
92
+ }
93
+ }
@@ -0,0 +1,84 @@
1
+ import { log } from "@stacksjs/logging";
2
+ import { env as envVars } from "@stacksjs/env";
3
+ import { db } from "./utils";
4
+ import { sqlHelpers } from "./sql-helpers";
5
+ function getDbDriver() {
6
+ return envVars.DB_CONNECTION || "sqlite";
7
+ }
8
+ export function rolesTableSql(sql) {
9
+ const { pkColumn, nullableTimestamp } = sql;
10
+ return `CREATE TABLE IF NOT EXISTS roles (
11
+ ${pkColumn},
12
+ name VARCHAR(255) NOT NULL,
13
+ guard_name VARCHAR(255) NOT NULL DEFAULT 'web',
14
+ description TEXT,
15
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
16
+ updated_at ${nullableTimestamp},
17
+ UNIQUE (name, guard_name)
18
+ )`;
19
+ }
20
+ export function permissionsTableSql(sql) {
21
+ const { pkColumn, nullableTimestamp } = sql;
22
+ return `CREATE TABLE IF NOT EXISTS permissions (
23
+ ${pkColumn},
24
+ name VARCHAR(255) NOT NULL,
25
+ guard_name VARCHAR(255) NOT NULL DEFAULT 'web',
26
+ description TEXT,
27
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
28
+ updated_at ${nullableTimestamp},
29
+ UNIQUE (name, guard_name)
30
+ )`;
31
+ }
32
+ export function userRolesTableSql() {
33
+ return `CREATE TABLE IF NOT EXISTS user_roles (
34
+ user_id INTEGER NOT NULL,
35
+ role_id INTEGER NOT NULL,
36
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
37
+ PRIMARY KEY (user_id, role_id)
38
+ )`;
39
+ }
40
+ export function userPermissionsTableSql() {
41
+ return `CREATE TABLE IF NOT EXISTS user_permissions (
42
+ user_id INTEGER NOT NULL,
43
+ permission_id INTEGER NOT NULL,
44
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
45
+ PRIMARY KEY (user_id, permission_id)
46
+ )`;
47
+ }
48
+ export function rolePermissionsTableSql() {
49
+ return `CREATE TABLE IF NOT EXISTS role_permissions (
50
+ role_id INTEGER NOT NULL,
51
+ permission_id INTEGER NOT NULL,
52
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
53
+ PRIMARY KEY (role_id, permission_id)
54
+ )`;
55
+ }
56
+ export async function migrateRbacTables(options = {}) {
57
+ const dbDriver = getDbDriver(), sql = sqlHelpers(dbDriver);
58
+ if (options.verbose)
59
+ log.info(`Creating RBAC tables for ${dbDriver}...`);
60
+ try {
61
+ if (options.verbose)
62
+ log.info("Creating roles table...");
63
+ await db.unsafe(rolesTableSql(sql)).execute();
64
+ if (options.verbose)
65
+ log.info("Creating permissions table...");
66
+ await db.unsafe(permissionsTableSql(sql)).execute();
67
+ if (options.verbose)
68
+ log.info("Creating user_roles pivot...");
69
+ await db.unsafe(userRolesTableSql()).execute();
70
+ if (options.verbose)
71
+ log.info("Creating user_permissions pivot...");
72
+ await db.unsafe(userPermissionsTableSql()).execute();
73
+ if (options.verbose)
74
+ log.info("Creating role_permissions pivot...");
75
+ await db.unsafe(rolePermissionsTableSql()).execute();
76
+ if (options.verbose)
77
+ log.success("RBAC tables created");
78
+ return { success: !0 };
79
+ } catch (error) {
80
+ const message = error instanceof Error ? error.message : String(error);
81
+ log.error(`Failed to create RBAC tables: ${message}`);
82
+ return { success: !1, error: message };
83
+ }
84
+ }
@@ -0,0 +1,59 @@
1
+ import { db } from "./utils";
2
+ import { sql } from "./types";
3
+ export async function addColumnSafely(db, tableName, columnName, options) {
4
+ const { type, defaultValue, notNull = !1, batchSize = 1000 } = options, dbAny = db, defaultSql = defaultValue === void 0 ? "" : ` DEFAULT ${formatDefault(defaultValue)}`;
5
+ await execRaw(dbAny, `ALTER TABLE ${quote(tableName)} ADD COLUMN ${quote(columnName)} ${type}${defaultSql}`);
6
+ if (defaultValue !== void 0)
7
+ await backfillInBatches(db, tableName, columnName, defaultValue, batchSize);
8
+ if (notNull)
9
+ await execRaw(dbAny, `ALTER TABLE ${quote(tableName)} ALTER COLUMN ${quote(columnName)} SET NOT NULL`);
10
+ }
11
+ async function execRaw(dbAny, statement) {
12
+ if (typeof dbAny.unsafe === "function")
13
+ return await dbAny.unsafe(statement) ?? {};
14
+ await sql`${sql.raw(statement)}`.execute(dbAny);
15
+ return {};
16
+ }
17
+ export async function backfillInBatches(db, tableName, columnName, value, batchSize = 1000) {
18
+ const dbAny = db;
19
+ let updated = 0, total = 0;
20
+ do {
21
+ const batchSql = `
22
+ UPDATE ${quote(tableName)} SET ${quote(columnName)} = ${formatDefault(value)}
23
+ WHERE ${quote(columnName)} IS NULL
24
+ AND ${rowIdColumnFor(dbAny)} IN (
25
+ SELECT ${rowIdColumnFor(dbAny)} FROM ${quote(tableName)}
26
+ WHERE ${quote(columnName)} IS NULL
27
+ LIMIT ${batchSize}
28
+ )
29
+ `, result = await execRaw(dbAny, batchSql);
30
+ updated = result.numAffectedRows != null ? Number(result.numAffectedRows) : 0;
31
+ total += updated;
32
+ } while (updated > 0);
33
+ }
34
+ export async function renameColumnSafely(db, tableName, oldName, newName, options) {
35
+ const dbAny = db;
36
+ if (options.atomic) {
37
+ await execRaw(dbAny, `ALTER TABLE ${quote(tableName)} RENAME COLUMN ${quote(oldName)} TO ${quote(newName)}`);
38
+ return;
39
+ }
40
+ await execRaw(dbAny, `ALTER TABLE ${quote(tableName)} ADD COLUMN ${quote(newName)} ${options.type}`);
41
+ await execRaw(dbAny, `UPDATE ${quote(tableName)} SET ${quote(newName)} = ${quote(oldName)}`);
42
+ }
43
+ function quote(name) {
44
+ if (!/^[a-z_][a-z0-9_]*$/i.test(name))
45
+ throw Error(`Refusing to quote unsafe identifier: ${JSON.stringify(name)}`);
46
+ return `"${name}"`;
47
+ }
48
+ function formatDefault(value) {
49
+ if (value === null)
50
+ return "NULL";
51
+ if (typeof value === "number")
52
+ return String(value);
53
+ if (typeof value === "boolean")
54
+ return value ? "TRUE" : "FALSE";
55
+ return `'${String(value).replace(/'/g, "''")}'`;
56
+ }
57
+ function rowIdColumnFor(_db) {
58
+ return "id";
59
+ }
@@ -0,0 +1,4 @@
1
+ /** @defaultValue `{ createTable: () => Promise<unknown> }` */
2
+ export declare const Schema: {
3
+ createTable: (tableName: string, callback: (table: Table) => void) => Promise<void>
4
+ };
package/dist/schema.js ADDED
@@ -0,0 +1,10 @@
1
+ import { log } from "@stacksjs/logging";
2
+ import { Table } from "./table";
3
+ export const Schema = {
4
+ async createTable(tableName, callback) {
5
+ const table = new Table;
6
+ callback(table);
7
+ table.execute();
8
+ log.success(`Table "${tableName}" created.`);
9
+ }
10
+ };
@@ -0,0 +1,144 @@
1
+ import { log } from "@stacksjs/logging";
2
+ import { path } from "@stacksjs/path";
3
+ import { fs } from "@stacksjs/storage";
4
+ export function stripUseSeederTrait(source) {
5
+ let out = source, changed = !1, skipped = !1;
6
+ for (const name of ["useSeeder", "seedable"]) {
7
+ const m = new RegExp(`\\b${name}\\s*:`).exec(out);
8
+ if (!m)
9
+ continue;
10
+ const keyStart = m.index;
11
+ let i = keyStart + m[0].length;
12
+ while (i < out.length && /\s/.test(out[i]))
13
+ i++;
14
+ if (out[i] === "{") {
15
+ let depth = 0, inStr = null;
16
+ for (;i < out.length; i++) {
17
+ const ch = out[i];
18
+ if (inStr) {
19
+ if (ch === "\\") {
20
+ i++;
21
+ continue;
22
+ }
23
+ if (ch === inStr)
24
+ inStr = null;
25
+ continue;
26
+ }
27
+ if (ch === '"' || ch === "'" || ch === "`") {
28
+ inStr = ch;
29
+ continue;
30
+ }
31
+ if (ch === "{")
32
+ depth++;
33
+ else if (ch === "}") {
34
+ depth--;
35
+ if (depth === 0) {
36
+ i++;
37
+ break;
38
+ }
39
+ }
40
+ }
41
+ } else if (out.startsWith("true", i) || out.startsWith("false", i))
42
+ i += out.startsWith("true", i) ? 4 : 5;
43
+ else {
44
+ skipped = !0;
45
+ continue;
46
+ }
47
+ let end = i;
48
+ while (end < out.length && (out[end] === " " || out[end] === "\t"))
49
+ end++;
50
+ if (out[end] === ",")
51
+ end++;
52
+ while (end < out.length && (out[end] === " " || out[end] === "\t"))
53
+ end++;
54
+ if (out[end] === "/" && out[end + 1] === "/")
55
+ while (end < out.length && out[end] !== `
56
+ `)
57
+ end++;
58
+ let start = keyStart;
59
+ while (start > 0 && (out[start - 1] === " " || out[start - 1] === "\t"))
60
+ start--;
61
+ if (start > 0 && out[start - 1] === `
62
+ ` && out[end] === `
63
+ `)
64
+ end++;
65
+ out = out.slice(0, start) + out.slice(end);
66
+ changed = !0;
67
+ }
68
+ return { source: out, changed, skipped: skipped && !changed };
69
+ }
70
+ const SEEDER_TEMPLATE = (modelName, modelImportPath, count) => `import { factory, Seeder } from '@stacksjs/database'
71
+ import ${modelName} from '${modelImportPath}'
72
+
73
+ export default class ${modelName}Seeder extends Seeder {
74
+ async run(): Promise<void> {
75
+ await factory.generate(${modelName}, { count: ${count} })
76
+ }
77
+ }
78
+ `;
79
+ function relativeModelImport(seedersDir, modelFilePath) {
80
+ const noExt = path.relative(seedersDir, modelFilePath).replace(/\\/g, "/").replace(/\.ts$/, "");
81
+ return noExt.startsWith(".") ? noExt : `./${noExt}`;
82
+ }
83
+ export async function scaffoldClassSeedersFromModels(options = {}) {
84
+ const modelsDir = options.modelsDir ?? path.userModelsPath(), seedersDir = options.seedersDir ?? path.projectPath("database/seeders"), result = { generated: [], skipped: [], errors: [], strippedTrait: [], traitStripSkipped: [] };
85
+ if (!fs.existsSync(modelsDir)) {
86
+ log.warn(`[seed:scaffold] No models directory at ${modelsDir}`);
87
+ return result;
88
+ }
89
+ if (!options.dryRun && !fs.existsSync(seedersDir))
90
+ fs.mkdirSync(seedersDir, { recursive: !0 });
91
+ const entries = fs.readdirSync(modelsDir, { withFileTypes: !0 });
92
+ for (const entry of entries) {
93
+ if (!entry.isFile() || !entry.name.endsWith(".ts"))
94
+ continue;
95
+ if (entry.name.startsWith("_") || entry.name.startsWith("index"))
96
+ continue;
97
+ const modelFilePath = path.join(modelsDir, entry.name);
98
+ let modelDef;
99
+ try {
100
+ const module = await import(modelFilePath);
101
+ modelDef = module.default || module;
102
+ } catch (err) {
103
+ result.errors.push({ model: entry.name, error: err.message });
104
+ continue;
105
+ }
106
+ if (!modelDef || !modelDef.name) {
107
+ result.errors.push({ model: entry.name, error: "missing default export with `name` field" });
108
+ continue;
109
+ }
110
+ const useSeeder = modelDef.traits?.useSeeder ?? modelDef.traits?.seedable;
111
+ if (!useSeeder) {
112
+ result.skipped.push({ model: modelDef.name, file: "", reason: "no-useseeder" });
113
+ continue;
114
+ }
115
+ const count = typeof useSeeder === "object" && "count" in useSeeder ? useSeeder.count : 10, seederFileName = `${modelDef.name}Seeder.ts`, seederFilePath = path.join(seedersDir, seederFileName);
116
+ if (fs.existsSync(seederFilePath) && !options.force)
117
+ result.skipped.push({ model: modelDef.name, file: seederFilePath, reason: "already-exists" });
118
+ else {
119
+ const importPath = relativeModelImport(seedersDir, modelFilePath), content = SEEDER_TEMPLATE(modelDef.name, importPath, count);
120
+ if (options.dryRun)
121
+ log.info(`[seed:scaffold] would write ${seederFilePath}`);
122
+ else
123
+ fs.writeFileSync(seederFilePath, content, "utf-8");
124
+ result.generated.push({ model: modelDef.name, file: seederFilePath });
125
+ }
126
+ try {
127
+ const modelSource = fs.readFileSync(modelFilePath, "utf-8"), { source: stripped, changed, skipped } = stripUseSeederTrait(modelSource);
128
+ if (changed) {
129
+ if (options.dryRun)
130
+ log.info(`[seed:scaffold] would strip useSeeder trait from ${modelFilePath}`);
131
+ else
132
+ fs.writeFileSync(modelFilePath, stripped, "utf-8");
133
+ result.strippedTrait.push({ model: modelDef.name, file: modelFilePath });
134
+ } else if (skipped)
135
+ result.traitStripSkipped.push({ model: modelDef.name, file: modelFilePath });
136
+ } catch (err) {
137
+ result.errors.push({ model: modelDef.name, error: `trait strip failed: ${err.message}` });
138
+ }
139
+ }
140
+ return result;
141
+ }
142
+ export function renderSeederFile(modelName, modelImportPath, count) {
143
+ return SEEDER_TEMPLATE(modelName, modelImportPath, count);
144
+ }