@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,60 @@
|
|
|
1
|
+
import { sqlHelpers } from './sql-helpers';
|
|
2
|
+
/**
|
|
3
|
+
* Defensive ALTER guaranteeing `users.email_verified_at` — the column
|
|
4
|
+
* `verifyEmail()` writes and the `verified` middleware reads, but which
|
|
5
|
+
* no generated users migration ever creates (stacksjs/stacks#1948).
|
|
6
|
+
* Pure builder so tests can assert per-dialect DDL without a live DB.
|
|
7
|
+
*/
|
|
8
|
+
export declare function usersEmailVerifiedAtSql(sql: SqlHelpers): string;
|
|
9
|
+
/**
|
|
10
|
+
* Defensive ALTER guaranteeing `users.password_changed_at` — the column
|
|
11
|
+
* `resetPassword()` stamps and the token-validation paths read to bind a
|
|
12
|
+
* token's validity to the user's credential state (stacksjs/stacks#1957,
|
|
13
|
+
* a #1947 follow-up). No generated users migration creates it, so the
|
|
14
|
+
* same pure-builder + try/catch-swallow pattern as
|
|
15
|
+
* {@link usersEmailVerifiedAtSql} guarantees it from both schema paths.
|
|
16
|
+
*/
|
|
17
|
+
export declare function usersPasswordChangedAtSql(sql: SqlHelpers): string;
|
|
18
|
+
/**
|
|
19
|
+
* Defensive ALTER guaranteeing `users.two_factor_secret` and
|
|
20
|
+
* `users.two_factor_enabled` — storage/framework/core/auth/src/
|
|
21
|
+
* authenticator.ts's TOTP helpers (generateTwoFactorSecret,
|
|
22
|
+
* verifyTwoFactorCode) have existed since early in the framework's
|
|
23
|
+
* history, but nothing ever created the columns a caller would persist
|
|
24
|
+
* them to, or the `passkeys`/`webauthn_challenges` tables
|
|
25
|
+
* storage/framework/core/auth/src/passkey.ts's WebAuthn helpers
|
|
26
|
+
* unconditionally query against — every passkey/2FA call site was
|
|
27
|
+
* dead code pointed at tables that never existed on any install,
|
|
28
|
+
* `buddy new` included. Same defensive ALTER + swallow pattern as
|
|
29
|
+
* {@link usersEmailVerifiedAtSql}.
|
|
30
|
+
*/
|
|
31
|
+
export declare function usersTwoFactorColumnsSql(sql: SqlHelpers): string[];
|
|
32
|
+
/**
|
|
33
|
+
* Defensive ALTER guaranteeing `users.stripe_id` — the `billable`
|
|
34
|
+
* model trait's methods (createStripeCustomer/createOrGetStripeUser,
|
|
35
|
+
* used by `user.checkout(...)`) read and write this column
|
|
36
|
+
* unconditionally, but it's runtime-mixin-only (createBillableMethods
|
|
37
|
+
* in orm/define-model.ts, gated behind `traits.billable`): nothing in
|
|
38
|
+
* migration codegen ever creates the column itself, on any model, with
|
|
39
|
+
* or without the trait enabled. Every Stripe checkout call site was
|
|
40
|
+
* dead code pointed at a column that never existed, `buddy new`
|
|
41
|
+
* included — same shape as the passkeys/two_factor gap above (see
|
|
42
|
+
* stacksjs/status#1 Phase 9).
|
|
43
|
+
*/
|
|
44
|
+
export declare function usersStripeIdSql(): string;
|
|
45
|
+
/**
|
|
46
|
+
* Runs every `users` guarantee-column ALTER (email_verified_at,
|
|
47
|
+
* password_changed_at, two_factor_secret, two_factor_enabled,
|
|
48
|
+
* stripe_id), each independently try/catch-swallowed so one
|
|
49
|
+
* already-existing column (or a not-yet-existing `users` table) never
|
|
50
|
+
* skips the others. Exported so `buddy migrate`/`migrate:fresh` can
|
|
51
|
+
* call it a second time after the numbered model migrations run — see
|
|
52
|
+
* the call site in {@link migrateAuthTables} for why a single call
|
|
53
|
+
* isn't enough.
|
|
54
|
+
*/
|
|
55
|
+
export declare function ensureUsersAuthColumns(sql: SqlHelpers, options?: { verbose?: boolean }): Promise<void>;
|
|
56
|
+
/**
|
|
57
|
+
* Create all authentication tables
|
|
58
|
+
*/
|
|
59
|
+
export declare function migrateAuthTables(options?: { verbose?: boolean }): Promise<{ success: boolean, error?: string }>;
|
|
60
|
+
declare type SqlHelpers = ReturnType<typeof sqlHelpers>;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { log } from "@stacksjs/logging";
|
|
4
|
+
import { env as envVars } from "@stacksjs/env";
|
|
5
|
+
import { db } from "./utils";
|
|
6
|
+
import { sqlHelpers } from "./sql-helpers";
|
|
7
|
+
function getDbDriver() {
|
|
8
|
+
return envVars.DB_CONNECTION || "sqlite";
|
|
9
|
+
}
|
|
10
|
+
export function usersEmailVerifiedAtSql(sql) {
|
|
11
|
+
return `ALTER TABLE users ADD COLUMN email_verified_at ${sql.nullableTimestamp}`;
|
|
12
|
+
}
|
|
13
|
+
export function usersPasswordChangedAtSql(sql) {
|
|
14
|
+
return `ALTER TABLE users ADD COLUMN password_changed_at ${sql.nullableTimestamp}`;
|
|
15
|
+
}
|
|
16
|
+
export function usersTwoFactorColumnsSql(sql) {
|
|
17
|
+
return [
|
|
18
|
+
"ALTER TABLE users ADD COLUMN two_factor_secret VARCHAR(255)",
|
|
19
|
+
`ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN NOT NULL DEFAULT ${sql.boolFalse}`,
|
|
20
|
+
"ALTER TABLE users ADD COLUMN two_factor_last_used_step BIGINT"
|
|
21
|
+
];
|
|
22
|
+
}
|
|
23
|
+
export function usersStripeIdSql() {
|
|
24
|
+
return "ALTER TABLE users ADD COLUMN stripe_id VARCHAR(255)";
|
|
25
|
+
}
|
|
26
|
+
export async function ensureUsersAuthColumns(sql, options = {}) {
|
|
27
|
+
const alters = [
|
|
28
|
+
usersEmailVerifiedAtSql(sql),
|
|
29
|
+
usersPasswordChangedAtSql(sql),
|
|
30
|
+
...usersTwoFactorColumnsSql(sql),
|
|
31
|
+
usersStripeIdSql()
|
|
32
|
+
];
|
|
33
|
+
for (const alterSql of alters)
|
|
34
|
+
try {
|
|
35
|
+
await db.unsafe(alterSql).execute();
|
|
36
|
+
} catch {
|
|
37
|
+
if (options.verbose)
|
|
38
|
+
log.debug(`[auth-tables] Skipped (already applied or users missing): ${alterSql}`);
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
await db.unsafe("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_stripe_id ON users(stripe_id)").execute();
|
|
42
|
+
} catch {
|
|
43
|
+
if (options.verbose)
|
|
44
|
+
log.debug("[auth-tables] Skipped users.stripe_id unique index (already applied or users missing)");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export async function migrateAuthTables(options = {}) {
|
|
48
|
+
const dbDriver = getDbDriver(), sql = sqlHelpers(dbDriver), { isPostgres, boolTrue, now, pkColumn, nullableTimestamp } = sql;
|
|
49
|
+
if (options.verbose)
|
|
50
|
+
log.info(`Creating auth tables for ${dbDriver}...`);
|
|
51
|
+
try {
|
|
52
|
+
if (options.verbose)
|
|
53
|
+
log.info("Creating oauth_clients table...");
|
|
54
|
+
await db.unsafe(`
|
|
55
|
+
CREATE TABLE IF NOT EXISTS oauth_clients (
|
|
56
|
+
${pkColumn},
|
|
57
|
+
name VARCHAR(255) NOT NULL,
|
|
58
|
+
secret VARCHAR(100),
|
|
59
|
+
provider VARCHAR(255),
|
|
60
|
+
redirect VARCHAR(2000) NOT NULL,
|
|
61
|
+
personal_access_client BOOLEAN NOT NULL DEFAULT ${sql.boolFalse},
|
|
62
|
+
password_client BOOLEAN NOT NULL DEFAULT ${sql.boolFalse},
|
|
63
|
+
revoked BOOLEAN NOT NULL DEFAULT ${sql.boolFalse},
|
|
64
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
65
|
+
updated_at ${nullableTimestamp}
|
|
66
|
+
)
|
|
67
|
+
`).execute();
|
|
68
|
+
if (options.verbose)
|
|
69
|
+
log.info("Creating oauth_access_tokens table...");
|
|
70
|
+
await db.unsafe(`
|
|
71
|
+
CREATE TABLE IF NOT EXISTS oauth_access_tokens (
|
|
72
|
+
${pkColumn},
|
|
73
|
+
user_id INTEGER NOT NULL,
|
|
74
|
+
oauth_client_id INTEGER NOT NULL,
|
|
75
|
+
token TEXT NOT NULL,
|
|
76
|
+
name VARCHAR(255),
|
|
77
|
+
scopes TEXT,
|
|
78
|
+
revoked BOOLEAN NOT NULL DEFAULT ${sql.boolFalse},
|
|
79
|
+
expires_at ${nullableTimestamp},
|
|
80
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
81
|
+
updated_at ${nullableTimestamp}
|
|
82
|
+
)
|
|
83
|
+
`).execute();
|
|
84
|
+
await createTokenIndex("idx_oauth_access_tokens_token", "oauth_access_tokens", "token");
|
|
85
|
+
if (options.verbose)
|
|
86
|
+
log.info("Creating oauth_refresh_tokens table...");
|
|
87
|
+
await db.unsafe(`
|
|
88
|
+
CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
|
|
89
|
+
${pkColumn},
|
|
90
|
+
access_token_id INTEGER NOT NULL,
|
|
91
|
+
token TEXT NOT NULL,
|
|
92
|
+
revoked BOOLEAN NOT NULL DEFAULT ${sql.boolFalse},
|
|
93
|
+
expires_at ${nullableTimestamp},
|
|
94
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
95
|
+
)
|
|
96
|
+
`).execute();
|
|
97
|
+
await createTokenIndex("idx_oauth_refresh_tokens_token", "oauth_refresh_tokens", "token");
|
|
98
|
+
if (options.verbose)
|
|
99
|
+
log.info("Creating password_resets table...");
|
|
100
|
+
await db.unsafe(`
|
|
101
|
+
CREATE TABLE IF NOT EXISTS password_resets (
|
|
102
|
+
${pkColumn},
|
|
103
|
+
email VARCHAR(255) NOT NULL,
|
|
104
|
+
token VARCHAR(255) NOT NULL,
|
|
105
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
106
|
+
)
|
|
107
|
+
`).execute();
|
|
108
|
+
try {
|
|
109
|
+
await db.unsafe(`
|
|
110
|
+
CREATE INDEX IF NOT EXISTS idx_password_resets_email ON password_resets(email)
|
|
111
|
+
`).execute();
|
|
112
|
+
} catch {}
|
|
113
|
+
if (options.verbose)
|
|
114
|
+
log.info("Creating passkeys table...");
|
|
115
|
+
await db.unsafe(`
|
|
116
|
+
CREATE TABLE IF NOT EXISTS passkeys (
|
|
117
|
+
id VARCHAR(255) PRIMARY KEY,
|
|
118
|
+
cred_public_key TEXT NOT NULL,
|
|
119
|
+
user_id INTEGER NOT NULL,
|
|
120
|
+
webauthn_user_id VARCHAR(255) NOT NULL,
|
|
121
|
+
counter INTEGER NOT NULL DEFAULT 0,
|
|
122
|
+
credential_type VARCHAR(50),
|
|
123
|
+
device_type VARCHAR(50),
|
|
124
|
+
backup_eligible BOOLEAN NOT NULL DEFAULT ${sql.boolFalse},
|
|
125
|
+
backup_status BOOLEAN NOT NULL DEFAULT ${sql.boolFalse},
|
|
126
|
+
transports TEXT,
|
|
127
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
128
|
+
last_used_at ${nullableTimestamp}
|
|
129
|
+
)
|
|
130
|
+
`).execute();
|
|
131
|
+
try {
|
|
132
|
+
await db.unsafe(`
|
|
133
|
+
CREATE INDEX IF NOT EXISTS idx_passkeys_user_id ON passkeys(user_id)
|
|
134
|
+
`).execute();
|
|
135
|
+
} catch {}
|
|
136
|
+
if (options.verbose)
|
|
137
|
+
log.info("Creating webauthn_challenges table...");
|
|
138
|
+
await db.unsafe(`
|
|
139
|
+
CREATE TABLE IF NOT EXISTS webauthn_challenges (
|
|
140
|
+
${pkColumn},
|
|
141
|
+
user_id INTEGER NOT NULL,
|
|
142
|
+
challenge TEXT NOT NULL,
|
|
143
|
+
purpose VARCHAR(20) NOT NULL,
|
|
144
|
+
expires_at TIMESTAMP NOT NULL,
|
|
145
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
146
|
+
)
|
|
147
|
+
`).execute();
|
|
148
|
+
try {
|
|
149
|
+
await db.unsafe(`
|
|
150
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_challenges_user_purpose ON webauthn_challenges(user_id, purpose)
|
|
151
|
+
`).execute();
|
|
152
|
+
} catch {}
|
|
153
|
+
if (options.verbose)
|
|
154
|
+
log.info("Creating two_factor_challenges table...");
|
|
155
|
+
await db.unsafe(`
|
|
156
|
+
CREATE TABLE IF NOT EXISTS two_factor_challenges (
|
|
157
|
+
id VARCHAR(255) PRIMARY KEY,
|
|
158
|
+
user_id INTEGER NOT NULL,
|
|
159
|
+
expires_at TIMESTAMP NOT NULL,
|
|
160
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
161
|
+
)
|
|
162
|
+
`).execute();
|
|
163
|
+
try {
|
|
164
|
+
await db.unsafe(`
|
|
165
|
+
CREATE INDEX IF NOT EXISTS idx_two_factor_challenges_user_id ON two_factor_challenges(user_id)
|
|
166
|
+
`).execute();
|
|
167
|
+
} catch {}
|
|
168
|
+
if (options.verbose)
|
|
169
|
+
log.info("Creating two_factor_pending_secrets table...");
|
|
170
|
+
await db.unsafe(`
|
|
171
|
+
CREATE TABLE IF NOT EXISTS two_factor_pending_secrets (
|
|
172
|
+
user_id INTEGER PRIMARY KEY,
|
|
173
|
+
secret VARCHAR(255) NOT NULL,
|
|
174
|
+
expires_at TIMESTAMP NOT NULL,
|
|
175
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
176
|
+
)
|
|
177
|
+
`).execute();
|
|
178
|
+
if (options.verbose)
|
|
179
|
+
log.info("Ensuring users auth columns exist (users may not exist yet on a fresh install \u2014 see ensureUsersAuthColumns)...");
|
|
180
|
+
await ensureUsersAuthColumns(sql, options);
|
|
181
|
+
if (options.verbose)
|
|
182
|
+
log.info("Ensuring personal access client exists...");
|
|
183
|
+
if ((await db.unsafe(`
|
|
184
|
+
SELECT id FROM oauth_clients WHERE personal_access_client = ${boolTrue} LIMIT 1
|
|
185
|
+
`).execute())?.length === 0) {
|
|
186
|
+
const secret = randomBytes(40).toString("hex");
|
|
187
|
+
if (isPostgres)
|
|
188
|
+
await db.unsafe(`
|
|
189
|
+
INSERT INTO oauth_clients (name, secret, provider, redirect, personal_access_client, password_client, revoked, created_at)
|
|
190
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
|
191
|
+
`, ["Personal Access Client", secret, "local", "http://localhost", !0, !1, !1]).execute();
|
|
192
|
+
else
|
|
193
|
+
await db.unsafe(`
|
|
194
|
+
INSERT INTO oauth_clients (name, secret, provider, redirect, personal_access_client, password_client, revoked, created_at)
|
|
195
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ${now})
|
|
196
|
+
`, ["Personal Access Client", secret, "local", "http://localhost", 1, 0, 0]).execute();
|
|
197
|
+
if (options.verbose)
|
|
198
|
+
log.success("Personal access client created");
|
|
199
|
+
}
|
|
200
|
+
log.debug("Auth tables migrated successfully");
|
|
201
|
+
return { success: !0 };
|
|
202
|
+
} catch (error) {
|
|
203
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
204
|
+
log.error("Failed to migrate auth tables:", errorMessage);
|
|
205
|
+
return { success: !1, error: errorMessage };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function createTokenIndex(indexName, tableName, column) {
|
|
209
|
+
try {
|
|
210
|
+
await db.unsafe(`
|
|
211
|
+
CREATE INDEX IF NOT EXISTS ${indexName} ON ${tableName}(${column}(255))
|
|
212
|
+
`).execute();
|
|
213
|
+
} catch {
|
|
214
|
+
try {
|
|
215
|
+
await db.unsafe(`
|
|
216
|
+
CREATE INDEX IF NOT EXISTS ${indexName} ON ${tableName}(${column})
|
|
217
|
+
`).execute();
|
|
218
|
+
} catch {}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Topologically sort seeders by their declared `dependencies`. Ties
|
|
3
|
+
* (and dependency-free seeders) come out in alphabetical order so the
|
|
4
|
+
* result is deterministic across filesystems and runs.
|
|
5
|
+
*
|
|
6
|
+
* Unknown dependency names are dropped from the graph with a warning —
|
|
7
|
+
* they may refer to model-factory seeders that ran earlier in the
|
|
8
|
+
* `buddy seed` pipeline, or be stale references from a rename. The run
|
|
9
|
+
* doesn't fail because of them.
|
|
10
|
+
*
|
|
11
|
+
* Cycles throw with the offending class names in the error message.
|
|
12
|
+
*
|
|
13
|
+
* Exported for testing.
|
|
14
|
+
*/
|
|
15
|
+
export declare function topoSortSeeders(seeders: Array<{ name: string, dependencies?: string[] }>): string[];
|
|
16
|
+
/*.ts`; an explicit `--class` filters to one.
|
|
17
|
+
*
|
|
18
|
+
* Ordering:
|
|
19
|
+
* 1. Files matching `*.ts` (excluding `_*.ts`) are imported.
|
|
20
|
+
* 2. If any seeder declares `dependencies`, the runnable set is
|
|
21
|
+
* topologically sorted (alphabetical tie-break).
|
|
22
|
+
* 3. Otherwise, the alphabetical order from `Array.sort()` wins —
|
|
23
|
+
* cheaper than the topo path and predictable across filesystems.
|
|
24
|
+
*
|
|
25
|
+
* Class-name filtering via `options.class` short-circuits both paths
|
|
26
|
+
* and runs only that one seeder. Cross-seeder dependencies are NOT
|
|
27
|
+
* resolved transitively in that mode — the caller takes responsibility
|
|
28
|
+
* for whatever prereqs are needed.
|
|
29
|
+
*
|
|
30
|
+
* See stacksjs/stacks#1855 for the original report of unsorted FS
|
|
31
|
+
* iteration producing zero-row seed runs.
|
|
32
|
+
*/
|
|
33
|
+
export declare function runClassSeeders(options?: RunOptions): Promise<{ ran: string[], skipped: string[] }>;
|
|
34
|
+
declare interface RunOptions {
|
|
35
|
+
class?: string
|
|
36
|
+
dir?: string
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Base class for class-based seeders. Subclass this and implement
|
|
40
|
+
* `async run()`. Seeders may call `this.call()` to invoke other
|
|
41
|
+
* seeders, mirroring Laravel's nested-seeder pattern.
|
|
42
|
+
*
|
|
43
|
+
* Cross-seeder ordering can be declared explicitly via `dependencies`:
|
|
44
|
+
*
|
|
45
|
+
* ```ts
|
|
46
|
+
* export default class JudgeSeeder extends Seeder {
|
|
47
|
+
* dependencies = ['CourtHouseSeeder']
|
|
48
|
+
* async run() { ... }
|
|
49
|
+
* }
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* The class name of each dependency is matched against the class names
|
|
53
|
+
* `runClassSeeders` discovered in the seeders directory. Unknown
|
|
54
|
+
* dependency names are warned about but don't fail the run (they may
|
|
55
|
+
* refer to a model-factory seeder run earlier in `buddy seed`).
|
|
56
|
+
*
|
|
57
|
+
* When no `dependencies` are declared, seeders run in alphabetical
|
|
58
|
+
* order — predictable across filesystems and good enough for projects
|
|
59
|
+
* that name seeders by data flow (CourtHouse → Judge → Review).
|
|
60
|
+
*/
|
|
61
|
+
export declare abstract class Seeder {
|
|
62
|
+
dependencies?: string[];
|
|
63
|
+
abstract run(): Promise<void> | void;
|
|
64
|
+
protected call(other: new () => Seeder): Promise<void>;
|
|
65
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { log } from "@stacksjs/logging";
|
|
2
|
+
import { path } from "@stacksjs/path";
|
|
3
|
+
import { fs } from "@stacksjs/storage";
|
|
4
|
+
|
|
5
|
+
export class Seeder {
|
|
6
|
+
dependencies;
|
|
7
|
+
async call(other) {
|
|
8
|
+
await new other().run();
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function topoSortSeeders(seeders) {
|
|
12
|
+
const known = new Set(seeders.map((s) => s.name)), effectiveDeps = new Map;
|
|
13
|
+
for (const s of seeders) {
|
|
14
|
+
const deps = new Set;
|
|
15
|
+
for (const dep of s.dependencies ?? []) {
|
|
16
|
+
if (dep === s.name)
|
|
17
|
+
continue;
|
|
18
|
+
if (!known.has(dep)) {
|
|
19
|
+
log.warn(`[seeder] ${s.name} depends on '${dep}' but no seeder by that name was discovered \u2014 ignoring`);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
deps.add(dep);
|
|
23
|
+
}
|
|
24
|
+
effectiveDeps.set(s.name, deps);
|
|
25
|
+
}
|
|
26
|
+
const indegree = new Map, successors = new Map;
|
|
27
|
+
for (const s of seeders) {
|
|
28
|
+
indegree.set(s.name, effectiveDeps.get(s.name).size);
|
|
29
|
+
successors.set(s.name, new Set);
|
|
30
|
+
}
|
|
31
|
+
for (const s of seeders)
|
|
32
|
+
for (const dep of effectiveDeps.get(s.name))
|
|
33
|
+
successors.get(dep).add(s.name);
|
|
34
|
+
const ready = seeders.filter((s) => indegree.get(s.name) === 0).map((s) => s.name).sort(), result = [];
|
|
35
|
+
while (ready.length > 0) {
|
|
36
|
+
const next = ready.shift();
|
|
37
|
+
result.push(next);
|
|
38
|
+
const newlyReady = [];
|
|
39
|
+
for (const succ of successors.get(next)) {
|
|
40
|
+
const left = (indegree.get(succ) ?? 0) - 1;
|
|
41
|
+
indegree.set(succ, left);
|
|
42
|
+
if (left === 0)
|
|
43
|
+
newlyReady.push(succ);
|
|
44
|
+
}
|
|
45
|
+
if (newlyReady.length > 0) {
|
|
46
|
+
ready.push(...newlyReady);
|
|
47
|
+
ready.sort();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (result.length !== seeders.length) {
|
|
51
|
+
const unresolved = seeders.map((s) => s.name).filter((n) => !result.includes(n));
|
|
52
|
+
throw Error(`[seeder] Cycle in seeder \`dependencies\` among: ${unresolved.join(", ")}`);
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
export async function runClassSeeders(options = {}) {
|
|
57
|
+
try {
|
|
58
|
+
const { injectGlobalAutoImports } = await import("@stacksjs/server");
|
|
59
|
+
await injectGlobalAutoImports();
|
|
60
|
+
} catch {}
|
|
61
|
+
const dir = options.dir ?? path.projectPath("database/seeders"), ran = [], skipped = [];
|
|
62
|
+
if (!fs.existsSync(dir)) {
|
|
63
|
+
log.info(`[seeder] No class seeders directory at ${dir}`);
|
|
64
|
+
return { ran, skipped };
|
|
65
|
+
}
|
|
66
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".ts") && !f.startsWith("_")).sort(), loaded = [];
|
|
67
|
+
for (const file of files) {
|
|
68
|
+
const className = file.replace(/\.ts$/, "");
|
|
69
|
+
try {
|
|
70
|
+
const mod = await import(`${dir}/${file}`), Klass = mod.default ?? mod[className];
|
|
71
|
+
if (!Klass) {
|
|
72
|
+
log.warn(`[seeder] ${file} has no default export`);
|
|
73
|
+
skipped.push(className);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const inst = new Klass;
|
|
77
|
+
if (typeof inst.run !== "function") {
|
|
78
|
+
log.warn(`[seeder] ${className} does not implement run()`);
|
|
79
|
+
skipped.push(className);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
loaded.push({ className, inst });
|
|
83
|
+
} catch (err) {
|
|
84
|
+
log.error(`[seeder] ${className} failed to load:`, err);
|
|
85
|
+
skipped.push(className);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const declaresDeps = loaded.some((l) => (l.inst.dependencies?.length ?? 0) > 0);
|
|
89
|
+
let order;
|
|
90
|
+
if (declaresDeps)
|
|
91
|
+
try {
|
|
92
|
+
order = topoSortSeeders(loaded.map((l) => ({ name: l.className, dependencies: l.inst.dependencies })));
|
|
93
|
+
} catch (err) {
|
|
94
|
+
log.error(err instanceof Error ? err.message : String(err));
|
|
95
|
+
return { ran, skipped: [...skipped, ...loaded.map((l) => l.className)] };
|
|
96
|
+
}
|
|
97
|
+
else
|
|
98
|
+
order = loaded.map((l) => l.className);
|
|
99
|
+
const byName = new Map(loaded.map((l) => [l.className, l.inst]));
|
|
100
|
+
for (const className of order) {
|
|
101
|
+
if (options.class && className !== options.class) {
|
|
102
|
+
skipped.push(className);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const inst = byName.get(className);
|
|
106
|
+
try {
|
|
107
|
+
log.info(`[seeder] Running ${className}\u2026`);
|
|
108
|
+
await inst.run();
|
|
109
|
+
ran.push(className);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
log.error(`[seeder] ${className} failed:`, err);
|
|
112
|
+
skipped.push(className);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { ran, skipped };
|
|
116
|
+
}
|
package/dist/column.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
declare interface Options {
|
|
2
|
+
notNull?: boolean
|
|
3
|
+
default?: any
|
|
4
|
+
primaryKey?: boolean
|
|
5
|
+
autoIncrement?: boolean
|
|
6
|
+
}
|
|
7
|
+
declare type ColumnType = 'integer' | 'varchar' | 'timestamp' | `varchar(${number})`;
|
|
8
|
+
export declare class Column {
|
|
9
|
+
public name: string;
|
|
10
|
+
public type: ColumnType;
|
|
11
|
+
public options?: Options;
|
|
12
|
+
constructor(name: string, type: ColumnType, options?: Options);
|
|
13
|
+
notNullable(): this;
|
|
14
|
+
defaultTo(value: any): this;
|
|
15
|
+
primary(): this;
|
|
16
|
+
autoIncrement(): this;
|
|
17
|
+
}
|
package/dist/column.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export class Column {
|
|
2
|
+
name;
|
|
3
|
+
type;
|
|
4
|
+
options;
|
|
5
|
+
constructor(name, type, options = {}) {
|
|
6
|
+
this.name = name;
|
|
7
|
+
this.type = type;
|
|
8
|
+
this.options = options;
|
|
9
|
+
}
|
|
10
|
+
notNullable() {
|
|
11
|
+
this.options.notNull = !0;
|
|
12
|
+
return this;
|
|
13
|
+
}
|
|
14
|
+
defaultTo(value) {
|
|
15
|
+
this.options.default = value;
|
|
16
|
+
return this;
|
|
17
|
+
}
|
|
18
|
+
primary() {
|
|
19
|
+
this.options.primaryKey = !0;
|
|
20
|
+
return this;
|
|
21
|
+
}
|
|
22
|
+
autoIncrement() {
|
|
23
|
+
this.options.autoIncrement = !0;
|
|
24
|
+
return this;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create the `model_audits` table used by the `useAudit` ORM trait.
|
|
3
|
+
*
|
|
4
|
+
* The trait writes one row per create/update/delete on any model that
|
|
5
|
+
* declares `traits: { useAudit: true }`. The schema is intentionally
|
|
6
|
+
* polymorphic — `auditable_type` + `auditable_id` rather than per-table
|
|
7
|
+
* audit columns — so adding the trait to a new model never requires a
|
|
8
|
+
* follow-up migration.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { createModelAuditsTable } from '@stacksjs/database'
|
|
13
|
+
* await createModelAuditsTable() // idempotent — checks before writing
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export declare function createModelAuditsTable(): Promise<void>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { log } from "@stacksjs/logging";
|
|
2
|
+
import { database } from "@stacksjs/config";
|
|
3
|
+
import { path } from "@stacksjs/path";
|
|
4
|
+
import { hasTableBeenMigrated } from "../drivers/helpers";
|
|
5
|
+
export async function createModelAuditsTable() {
|
|
6
|
+
if (!["sqlite", "mysql"].includes(getDriver()))
|
|
7
|
+
return;
|
|
8
|
+
if (await hasTableBeenMigrated("model_audits"))
|
|
9
|
+
return;
|
|
10
|
+
let migrationContent = `import type { Database } from '@stacksjs/database'
|
|
11
|
+
import { sql } from '@stacksjs/database'
|
|
12
|
+
|
|
13
|
+
`;
|
|
14
|
+
migrationContent += `export async function up(db: Database<any>) {
|
|
15
|
+
`;
|
|
16
|
+
migrationContent += ` await db.schema
|
|
17
|
+
`;
|
|
18
|
+
migrationContent += ` .createTable('model_audits')
|
|
19
|
+
`;
|
|
20
|
+
migrationContent += ` .addColumn('id', 'integer', col => col.primaryKey().autoIncrement())
|
|
21
|
+
`;
|
|
22
|
+
migrationContent += ` .addColumn('auditable_type', 'varchar(255)', col => col.notNull())
|
|
23
|
+
`;
|
|
24
|
+
migrationContent += ` .addColumn('auditable_id', 'varchar(255)', col => col.notNull())
|
|
25
|
+
`;
|
|
26
|
+
migrationContent += ` .addColumn('event', 'varchar(32)', col => col.notNull())
|
|
27
|
+
`;
|
|
28
|
+
migrationContent += ` .addColumn('old_values', 'text')
|
|
29
|
+
`;
|
|
30
|
+
migrationContent += ` .addColumn('new_values', 'text')
|
|
31
|
+
`;
|
|
32
|
+
migrationContent += ` .addColumn('user_id', 'integer')
|
|
33
|
+
`;
|
|
34
|
+
migrationContent += ` .addColumn('created_at', 'timestamp', col => col.notNull().defaultTo(sql.raw('CURRENT_TIMESTAMP')))
|
|
35
|
+
`;
|
|
36
|
+
migrationContent += ` .execute()
|
|
37
|
+
|
|
38
|
+
`;
|
|
39
|
+
migrationContent += ` await db.schema
|
|
40
|
+
`;
|
|
41
|
+
migrationContent += ` .createIndex('idx_model_audits_lookup')
|
|
42
|
+
`;
|
|
43
|
+
migrationContent += ` .on('model_audits')
|
|
44
|
+
`;
|
|
45
|
+
migrationContent += ` .columns(['auditable_type', 'auditable_id', 'created_at'])
|
|
46
|
+
`;
|
|
47
|
+
migrationContent += ` .execute()
|
|
48
|
+
`;
|
|
49
|
+
migrationContent += `}
|
|
50
|
+
`;
|
|
51
|
+
const migrationFileName = `${new Date().getTime().toString()}-create-model-audits-table.ts`, migrationFilePath = path.userMigrationsPath(migrationFileName);
|
|
52
|
+
await Bun.write(migrationFilePath, migrationContent);
|
|
53
|
+
log.success("Created model_audits table migration");
|
|
54
|
+
}
|
|
55
|
+
function getDriver() {
|
|
56
|
+
return database.default || "";
|
|
57
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function createErrorsTable(): Promise<void>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { log } from "@stacksjs/logging";
|
|
2
|
+
import { database } from "@stacksjs/config";
|
|
3
|
+
import { path } from "@stacksjs/path";
|
|
4
|
+
import { hasTableBeenMigrated } from "../drivers/helpers";
|
|
5
|
+
export async function createErrorsTable() {
|
|
6
|
+
if (["sqlite", "mysql"].includes(getDriver())) {
|
|
7
|
+
if (await hasTableBeenMigrated("errors"))
|
|
8
|
+
return;
|
|
9
|
+
let migrationContent = `import type { Database } from '@stacksjs/database'
|
|
10
|
+
import { sql } from '@stacksjs/database'
|
|
11
|
+
|
|
12
|
+
`;
|
|
13
|
+
migrationContent += `export async function up(db: Database<any>) {
|
|
14
|
+
`;
|
|
15
|
+
migrationContent += ` await db.schema
|
|
16
|
+
`;
|
|
17
|
+
migrationContent += ` .createTable('errors')
|
|
18
|
+
`;
|
|
19
|
+
migrationContent += ` .addColumn('id', 'integer', col => col.primaryKey().autoIncrement())
|
|
20
|
+
`;
|
|
21
|
+
migrationContent += ` .addColumn('type', 'varchar(255)', col => col.notNull())
|
|
22
|
+
`;
|
|
23
|
+
migrationContent += ` .addColumn('message', 'text', col => col.notNull())
|
|
24
|
+
`;
|
|
25
|
+
migrationContent += ` .addColumn('stack', 'text')
|
|
26
|
+
`;
|
|
27
|
+
migrationContent += ` .addColumn('status', 'integer', col => col.notNull().defaultTo(0))
|
|
28
|
+
`;
|
|
29
|
+
migrationContent += ` .addColumn('user_id', 'integer')
|
|
30
|
+
`;
|
|
31
|
+
migrationContent += ` .addColumn('additional_info', 'text')
|
|
32
|
+
`;
|
|
33
|
+
migrationContent += ` .addColumn('created_at', 'timestamp', col => col.notNull().defaultTo(sql.raw('CURRENT_TIMESTAMP')))
|
|
34
|
+
`;
|
|
35
|
+
migrationContent += ` .addColumn('updated_at', 'timestamp')
|
|
36
|
+
`;
|
|
37
|
+
migrationContent += ` .execute()
|
|
38
|
+
`;
|
|
39
|
+
migrationContent += `}
|
|
40
|
+
`;
|
|
41
|
+
const migrationFileName = `${new Date().getTime().toString()}-create-errors-table.ts`, migrationFilePath = path.userMigrationsPath(migrationFileName);
|
|
42
|
+
await Bun.write(migrationFilePath, migrationContent);
|
|
43
|
+
log.success("Created errors table");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function getDriver() {
|
|
47
|
+
return database.default || "";
|
|
48
|
+
}
|