@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.
Files changed (83) hide show
  1. package/dist/auth-tables.d.ts +60 -0
  2. package/dist/auth-tables.js +220 -0
  3. package/dist/class-seeder.d.ts +65 -0
  4. package/dist/class-seeder.js +116 -0
  5. package/dist/column.d.ts +17 -0
  6. package/dist/column.js +26 -0
  7. package/dist/custom/audits.d.ts +16 -0
  8. package/dist/custom/audits.js +57 -0
  9. package/dist/custom/errors.d.ts +1 -0
  10. package/dist/custom/errors.js +48 -0
  11. package/dist/custom/index.d.ts +3 -0
  12. package/dist/custom/index.js +3 -0
  13. package/dist/custom/jobs.d.ts +3 -0
  14. package/dist/custom/jobs.js +449 -0
  15. package/dist/database.d.ts +89 -0
  16. package/dist/database.js +178 -0
  17. package/dist/defaults.d.ts +48 -0
  18. package/dist/defaults.js +48 -0
  19. package/dist/driver-config.d.ts +149 -0
  20. package/dist/driver-config.js +144 -0
  21. package/dist/drivers/defaults/index.d.ts +2 -0
  22. package/dist/drivers/defaults/index.js +2 -0
  23. package/dist/drivers/defaults/passwords.d.ts +4 -0
  24. package/dist/drivers/defaults/passwords.js +106 -0
  25. package/dist/drivers/defaults/traits.d.ts +33 -0
  26. package/dist/drivers/defaults/traits.js +1125 -0
  27. package/dist/drivers/dynamodb.d.ts +200 -0
  28. package/dist/drivers/dynamodb.js +607 -0
  29. package/dist/drivers/helpers.d.ts +35 -0
  30. package/dist/drivers/helpers.js +206 -0
  31. package/dist/drivers/index.d.ts +16 -0
  32. package/dist/drivers/index.js +9 -0
  33. package/dist/drivers/mysql.d.ts +7 -0
  34. package/dist/drivers/mysql.js +322 -0
  35. package/dist/drivers/postgres.d.ts +7 -0
  36. package/dist/drivers/postgres.js +411 -0
  37. package/dist/drivers/sqlite.d.ts +20 -0
  38. package/dist/drivers/sqlite.js +397 -0
  39. package/dist/factory.d.ts +41 -0
  40. package/dist/factory.js +51 -0
  41. package/dist/fk-audit.d.ts +101 -0
  42. package/dist/fk-audit.js +181 -0
  43. package/dist/index.d.ts +149 -0
  44. package/dist/index.js +55 -0
  45. package/dist/migration-lock.d.ts +23 -0
  46. package/dist/migration-lock.js +143 -0
  47. package/dist/migrations.d.ts +76 -0
  48. package/dist/migrations.js +549 -0
  49. package/dist/notification-tables.d.ts +20 -0
  50. package/dist/notification-tables.js +54 -0
  51. package/dist/query-logger.d.ts +26 -0
  52. package/dist/query-logger.js +213 -0
  53. package/dist/query-parser.d.ts +4 -0
  54. package/dist/query-parser.js +93 -0
  55. package/dist/rbac-tables.d.ts +17 -0
  56. package/dist/rbac-tables.js +84 -0
  57. package/dist/safe-migrations.d.ts +72 -0
  58. package/dist/safe-migrations.js +59 -0
  59. package/dist/schema.d.ts +4 -0
  60. package/dist/schema.js +10 -0
  61. package/dist/seed-scaffold.d.ts +34 -0
  62. package/dist/seed-scaffold.js +144 -0
  63. package/dist/seeder.d.ts +116 -0
  64. package/dist/seeder.js +363 -0
  65. package/dist/sql-helpers.d.ts +33 -0
  66. package/dist/sql-helpers.js +24 -0
  67. package/dist/table.d.ts +7 -0
  68. package/dist/table.js +26 -0
  69. package/dist/tools/setup.d.ts +1 -0
  70. package/dist/tools/setup.js +6 -0
  71. package/dist/transaction-context.d.ts +52 -0
  72. package/dist/transaction-context.js +62 -0
  73. package/dist/types.d.ts +151 -0
  74. package/dist/types.js +23 -0
  75. package/dist/unique-audit.d.ts +60 -0
  76. package/dist/unique-audit.js +174 -0
  77. package/dist/utils.d.ts +189 -0
  78. package/dist/utils.js +163 -0
  79. package/dist/uuid-columns.d.ts +22 -0
  80. package/dist/uuid-columns.js +68 -0
  81. package/dist/validators.d.ts +26 -0
  82. package/dist/validators.js +122 -0
  83. package/package.json +11 -11
@@ -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,4 @@
1
+ /**
2
+ * Parse and normalize a SQL query
3
+ */
4
+ export declare function parseQuery(sql: string): { normalized: string, type: string, tables: string[] };
@@ -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,17 @@
1
+ import { sqlHelpers } from './sql-helpers';
2
+ /** `roles` table — id + name + guard + timestamps with UNIQUE(name, guard_name). */
3
+ export declare function rolesTableSql(sql: SqlHelpers): string;
4
+ /** `permissions` table — same shape as `roles`. */
5
+ export declare function permissionsTableSql(sql: SqlHelpers): string;
6
+ /** `user_roles` pivot — composite PK makes double-assign a unique violation. */
7
+ export declare function userRolesTableSql(): string;
8
+ /** `user_permissions` pivot. */
9
+ export declare function userPermissionsTableSql(): string;
10
+ /** `role_permissions` pivot. */
11
+ export declare function rolePermissionsTableSql(): string;
12
+ /**
13
+ * Create the 5 RBAC tables. Idempotent (`IF NOT EXISTS`), so it's
14
+ * safe to run on every `buddy migrate`.
15
+ */
16
+ export declare function migrateRbacTables(options?: { verbose?: boolean }): Promise<{ success: boolean, error?: string }>;
17
+ declare type SqlHelpers = ReturnType<typeof sqlHelpers>;
@@ -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,72 @@
1
+ import { db } from './utils';
2
+ /**
3
+ * Add a column to `tableName` without taking a table-level lock long
4
+ * enough to disrupt traffic. The column is created nullable, backfilled
5
+ * in batches, and (if `notNull: true`) the constraint is added at the
6
+ * end.
7
+ *
8
+ * Pre-conditions:
9
+ * - `tableName` exists
10
+ * - `columnName` does NOT already exist (this helper doesn't gracefully
11
+ * handle the rerun case — wrap in `if (!columnExists)` if you need that)
12
+ *
13
+ * Caveats:
14
+ * - SQLite doesn't support adding NOT NULL columns to existing tables
15
+ * without a default; we work around it by always supplying one
16
+ * - Postgres < 11 rewrites the entire table when a default is added;
17
+ * this helper assumes ≥ 11
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * await addColumnSafely(db, 'users', 'email_verified', {
22
+ * type: 'boolean',
23
+ * defaultValue: false,
24
+ * notNull: true,
25
+ * })
26
+ * ```
27
+ */
28
+ export declare function addColumnSafely(db: Database, tableName: string, columnName: string, options: AddColumnSafelyOptions): Promise<void>;
29
+ /**
30
+ * Back-fill `columnName` with `value` for any row where it's currently
31
+ * NULL. Runs in batches so the UPDATE doesn't lock the entire table.
32
+ *
33
+ * Useful as a standalone helper when you want to backfill an *existing*
34
+ * column (e.g. populating a denormalized count) — `addColumnSafely`
35
+ * uses it internally.
36
+ */
37
+ export declare function backfillInBatches(db: Database, tableName: string, columnName: string, value: string | number | boolean | null, batchSize?: number): Promise<void>;
38
+ /**
39
+ * Rename a column safely on a table that's actively serving traffic.
40
+ *
41
+ * Most database engines DO support `RENAME COLUMN` as a metadata-only
42
+ * operation (no rewrite, no long lock), which means the headline
43
+ * concern is *application-side*: app code reads the old column name,
44
+ * the migration renames it, and the next request 500s.
45
+ *
46
+ * This helper wraps the rename in a multi-step sequence the framework
47
+ * docs can teach as the canonical pattern:
48
+ *
49
+ * 1. Add the new column
50
+ * 2. Backfill from old → new
51
+ * 3. Update writes to dual-write old AND new (app-level, deploy step)
52
+ * 4. Update reads to read from new (app-level, deploy step)
53
+ * 5. Drop the old column (separate migration)
54
+ *
55
+ * For the rare case where the rename *can* happen atomically (small
56
+ * table, no live traffic), pass `{ atomic: true }` and we'll just emit
57
+ * the RENAME COLUMN.
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * // Step 1 of the rename sequence — the rest is app-side coordination.
62
+ * await renameColumnSafely(db, 'users', 'name', 'full_name', { type: 'varchar(255)' })
63
+ * ```
64
+ */
65
+ export declare function renameColumnSafely(db: Database, tableName: string, oldName: string, newName: string, options: { type: string, atomic?: boolean }): Promise<void>;
66
+ declare interface AddColumnSafelyOptions {
67
+ type: string
68
+ defaultValue?: string | number | boolean | null
69
+ notNull?: boolean
70
+ batchSize?: number
71
+ }
72
+ declare type Database = typeof db;
@@ -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,34 @@
1
+ /**
2
+ * Remove a single `useSeeder` / `seedable` object-property from model
3
+ * source text (stacksjs/stacks#1929). Brace-aware (balances nested
4
+ * `{}` and skips string literals) and conservative: only strips the
5
+ * documented value shapes (`true`, `false`, or a `{ … }` object). For
6
+ * anything else (an identifier, a function call, a spread) it returns
7
+ * `changed: false` so the caller can flag it for manual cleanup
8
+ * instead of risking a mangled file.
9
+ *
10
+ * Exported for unit tests.
11
+ */
12
+ export declare function stripUseSeederTrait(source: string): { source: string, changed: boolean, skipped: boolean };
13
+ /**
14
+ * Walk the configured models directory, find every model whose
15
+ * `traits.useSeeder` is truthy, and write a class-seeder file for it.
16
+ * Returns a structured report so the CLI command can render a summary
17
+ * without re-parsing log lines.
18
+ */
19
+ export declare function scaffoldClassSeedersFromModels(options?: ScaffoldOptions): Promise<ScaffoldResult>;
20
+ /** Pure renderer — exported for unit tests. */
21
+ export declare function renderSeederFile(modelName: string, modelImportPath: string, count: number): string;
22
+ export declare interface ScaffoldOptions {
23
+ modelsDir?: string
24
+ seedersDir?: string
25
+ force?: boolean
26
+ dryRun?: boolean
27
+ }
28
+ export declare interface ScaffoldResult {
29
+ generated: Array<{ model: string, file: string }>
30
+ skipped: Array<{ model: string, file: string, reason: 'already-exists' | 'no-useseeder' }>
31
+ errors: Array<{ model: string, error: string }>
32
+ strippedTrait: Array<{ model: string, file: string }>
33
+ traitStripSkipped: Array<{ model: string, file: string }>
34
+ }