@stacksjs/database 0.75.318 → 0.75.320
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/framework-schema.d.ts +111 -3
- package/dist/migrations.js +1 -1
- package/dist/seeder.js +1 -1
- package/dist/shadowed-models.js +1 -1
- package/dist/sql-helpers.d.ts +28 -0
- package/dist/sql-helpers.js +1 -1
- package/package.json +17 -17
|
@@ -148,6 +148,7 @@ export declare interface FrameworkSchema {
|
|
|
148
148
|
color: string
|
|
149
149
|
position: number
|
|
150
150
|
archived: number
|
|
151
|
+
team_id: number
|
|
151
152
|
}
|
|
152
153
|
campaign_sends: {
|
|
153
154
|
id: number
|
|
@@ -233,6 +234,17 @@ export declare interface FrameworkSchema {
|
|
|
233
234
|
assigned_by_user_id: number | null
|
|
234
235
|
created_at: string | null
|
|
235
236
|
}
|
|
237
|
+
card_attachments: {
|
|
238
|
+
id: number
|
|
239
|
+
uuid: string
|
|
240
|
+
created_at: string
|
|
241
|
+
updated_at: string | null
|
|
242
|
+
disk: string
|
|
243
|
+
path: string
|
|
244
|
+
mime_type: string
|
|
245
|
+
size_bytes: number
|
|
246
|
+
card_id: number
|
|
247
|
+
}
|
|
236
248
|
card_comments: {
|
|
237
249
|
id: number
|
|
238
250
|
uuid: string
|
|
@@ -296,6 +308,14 @@ export declare interface FrameworkSchema {
|
|
|
296
308
|
currency: string
|
|
297
309
|
notes: string
|
|
298
310
|
applied_coupon_id: string
|
|
311
|
+
session_token: string
|
|
312
|
+
checkout_step: "cart" | "shipping" | "payment" | "placed"
|
|
313
|
+
email: string
|
|
314
|
+
shipping_name: string
|
|
315
|
+
shipping_address: string
|
|
316
|
+
shipping_city: string
|
|
317
|
+
shipping_state: string
|
|
318
|
+
shipping_zip: string
|
|
299
319
|
customer_id: number
|
|
300
320
|
coupon_id: number
|
|
301
321
|
}
|
|
@@ -309,7 +329,7 @@ export declare interface FrameworkSchema {
|
|
|
309
329
|
slug: string
|
|
310
330
|
image_url: string
|
|
311
331
|
is_active: number
|
|
312
|
-
parent_category_id:
|
|
332
|
+
parent_category_id: number
|
|
313
333
|
display_order: number
|
|
314
334
|
}
|
|
315
335
|
categorizable_models: {
|
|
@@ -364,8 +384,6 @@ export declare interface FrameworkSchema {
|
|
|
364
384
|
commentables_id: number
|
|
365
385
|
commentables_type: string
|
|
366
386
|
user_id: number | null
|
|
367
|
-
author_name: string | null
|
|
368
|
-
author_email: string | null
|
|
369
387
|
is_active: number | null
|
|
370
388
|
created_at: string
|
|
371
389
|
updated_at: string | null
|
|
@@ -624,6 +642,7 @@ export declare interface FrameworkSchema {
|
|
|
624
642
|
message: string
|
|
625
643
|
stack: string
|
|
626
644
|
status: number
|
|
645
|
+
resolution: "unresolved" | "resolved" | "ignored"
|
|
627
646
|
additional_info: string
|
|
628
647
|
}
|
|
629
648
|
failed_jobs: {
|
|
@@ -640,6 +659,19 @@ export declare interface FrameworkSchema {
|
|
|
640
659
|
duration_ms: number
|
|
641
660
|
failed_at: string
|
|
642
661
|
}
|
|
662
|
+
feedback_tokens: {
|
|
663
|
+
id: number
|
|
664
|
+
uuid: string
|
|
665
|
+
created_at: string
|
|
666
|
+
updated_at: string | null
|
|
667
|
+
token: string
|
|
668
|
+
label: string
|
|
669
|
+
revoked_at: string
|
|
670
|
+
expires_at: string
|
|
671
|
+
last_used_at: string
|
|
672
|
+
created_by_user_id: number
|
|
673
|
+
board_id: number
|
|
674
|
+
}
|
|
643
675
|
form_fields: {
|
|
644
676
|
id: number
|
|
645
677
|
created_at: string
|
|
@@ -681,6 +713,18 @@ export declare interface FrameworkSchema {
|
|
|
681
713
|
settings: unknown
|
|
682
714
|
site_id: number
|
|
683
715
|
}
|
|
716
|
+
gdpr_requests: {
|
|
717
|
+
id: number
|
|
718
|
+
uuid: string
|
|
719
|
+
created_at: string
|
|
720
|
+
updated_at: string | null
|
|
721
|
+
type: "access" | "erasure" | "retention"
|
|
722
|
+
subject_id: number
|
|
723
|
+
actor: string
|
|
724
|
+
status: "completed" | "failed"
|
|
725
|
+
summary: unknown
|
|
726
|
+
occurred_at: string
|
|
727
|
+
}
|
|
684
728
|
gift_cards: {
|
|
685
729
|
id: number
|
|
686
730
|
uuid: string
|
|
@@ -789,6 +833,7 @@ export declare interface FrameworkSchema {
|
|
|
789
833
|
description: string
|
|
790
834
|
expiry_date: string
|
|
791
835
|
is_used: number
|
|
836
|
+
customer_id: number
|
|
792
837
|
}
|
|
793
838
|
loyalty_rewards: {
|
|
794
839
|
id: number
|
|
@@ -900,6 +945,26 @@ export declare interface FrameworkSchema {
|
|
|
900
945
|
read_at: string
|
|
901
946
|
user_id: number
|
|
902
947
|
}
|
|
948
|
+
oauth_access_tokens: {
|
|
949
|
+
id: number
|
|
950
|
+
created_at: string
|
|
951
|
+
updated_at: string | null
|
|
952
|
+
tokenable_type: string
|
|
953
|
+
tokenable_id: number
|
|
954
|
+
name: string
|
|
955
|
+
token: string
|
|
956
|
+
scopes: string
|
|
957
|
+
revoked: number
|
|
958
|
+
expires_at: string
|
|
959
|
+
user_agent: string
|
|
960
|
+
ip_address: string
|
|
961
|
+
user_id: number
|
|
962
|
+
oauth_client_id: number
|
|
963
|
+
oauth_grant_id: string
|
|
964
|
+
resources: string
|
|
965
|
+
audiences: string
|
|
966
|
+
workspace_id: string
|
|
967
|
+
}
|
|
903
968
|
order_idempotency: {
|
|
904
969
|
id: number
|
|
905
970
|
created_at: string
|
|
@@ -1060,6 +1125,7 @@ export declare interface FrameworkSchema {
|
|
|
1060
1125
|
level: string
|
|
1061
1126
|
status: "pending" | "confirmed" | "cancelled"
|
|
1062
1127
|
auction_id: number
|
|
1128
|
+
customer_id: number
|
|
1063
1129
|
}
|
|
1064
1130
|
posts: {
|
|
1065
1131
|
id: number
|
|
@@ -1128,6 +1194,7 @@ export declare interface FrameworkSchema {
|
|
|
1128
1194
|
created_at: string
|
|
1129
1195
|
updated_at: string | null
|
|
1130
1196
|
name: string
|
|
1197
|
+
slug: string
|
|
1131
1198
|
description: string
|
|
1132
1199
|
price: number
|
|
1133
1200
|
image_url: string
|
|
@@ -1199,6 +1266,23 @@ export declare interface FrameworkSchema {
|
|
|
1199
1266
|
source: "slug-change" | "manual"
|
|
1200
1267
|
site_id: number
|
|
1201
1268
|
}
|
|
1269
|
+
referral_codes: {
|
|
1270
|
+
id: number
|
|
1271
|
+
created_at: string
|
|
1272
|
+
updated_at: string | null
|
|
1273
|
+
user_id: number
|
|
1274
|
+
code: string
|
|
1275
|
+
}
|
|
1276
|
+
referrals: {
|
|
1277
|
+
id: number
|
|
1278
|
+
created_at: string
|
|
1279
|
+
updated_at: string | null
|
|
1280
|
+
referrer_id: number
|
|
1281
|
+
referred_user_id: number
|
|
1282
|
+
code: string
|
|
1283
|
+
status: "registered" | "qualified"
|
|
1284
|
+
qualified_at: string | null
|
|
1285
|
+
}
|
|
1202
1286
|
releases: {
|
|
1203
1287
|
id: number
|
|
1204
1288
|
uuid: string
|
|
@@ -1242,6 +1326,7 @@ export declare interface FrameworkSchema {
|
|
|
1242
1326
|
images: string
|
|
1243
1327
|
product_id: number
|
|
1244
1328
|
customer_id: number
|
|
1329
|
+
order_id: number
|
|
1245
1330
|
}
|
|
1246
1331
|
role_permissions: {
|
|
1247
1332
|
role_id: number
|
|
@@ -1360,6 +1445,29 @@ export declare interface FrameworkSchema {
|
|
|
1360
1445
|
external_id: string
|
|
1361
1446
|
user_id: number
|
|
1362
1447
|
}
|
|
1448
|
+
storage_item_tasks: {
|
|
1449
|
+
id: number
|
|
1450
|
+
uuid: string
|
|
1451
|
+
created_at: string
|
|
1452
|
+
updated_at: string | null
|
|
1453
|
+
disk: string
|
|
1454
|
+
path: string
|
|
1455
|
+
kind: "optimize" | "transcode" | "tag" | "preview"
|
|
1456
|
+
state: "queued" | "running" | "done" | "failed" | "skipped"
|
|
1457
|
+
attempts: number
|
|
1458
|
+
error: string
|
|
1459
|
+
started_at: string
|
|
1460
|
+
finished_at: string
|
|
1461
|
+
}
|
|
1462
|
+
storage_items: {
|
|
1463
|
+
id: number
|
|
1464
|
+
uuid: string
|
|
1465
|
+
created_at: string
|
|
1466
|
+
updated_at: string | null
|
|
1467
|
+
disk: string
|
|
1468
|
+
path: string
|
|
1469
|
+
favorite: number
|
|
1470
|
+
}
|
|
1363
1471
|
subscriber_emails: {
|
|
1364
1472
|
id: number
|
|
1365
1473
|
uuid: string
|
package/dist/migrations.js
CHANGED
|
@@ -9,7 +9,7 @@ ${missing.join(`
|
|
|
9
9
|
migration TEXT NOT NULL UNIQUE,
|
|
10
10
|
executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
11
11
|
)`);const insert=writeDb.prepare("INSERT OR IGNORE INTO migrations (migration) VALUES (?)");for(const migration of droppedMigrations)insert.run(migration);const unrecord=writeDb.prepare("DELETE FROM migrations WHERE migration = ?");for(const migration of replayMigrations)unrecord.run(migration)}finally{writeDb.close()}}catch(e){log.debug(`[migration] Could not record dropped migrations as executed: ${e}`)}return quarantined}function mayCreateMissingDatabase(){const signal=process.env.STACKS_CREATE_DATABASE;if(signal==="1")return!0;if(signal==="0")return!1;const policy=String(process.env.DB_CREATE_DATABASE||"").toLowerCase();return!(policy==="never"||policy==="false"||policy==="0")}function describeProbeFailure(target,kind,error){const where=describeTarget(target),detail=error instanceof Error?error.message:String(error??"");switch(kind){case"missing-role":return`The user "${target.username}" does not exist on ${where}. Set DB_USERNAME to a role that exists, or create it with: createuser -s ${target.username}`;case"auth-failed":return`Authentication failed for user "${target.username}" on ${where}. Check DB_USERNAME and DB_PASSWORD.`;case"server-unreachable":return`Could not reach the database server at ${target.host}:${target.port}. Check that it is running and that DB_HOST and DB_PORT are correct.`;case"timeout":return`Timed out connecting to the database server at ${target.host}:${target.port}. ${detail}`;case"permission-denied":return`The user "${target.username}" is not allowed to connect to "${target.database}" on ${where}.`;default:return`Could not connect to the database "${target.database}" on ${where}. ${detail}`}}async function ensureDatabaseExists(){const target=resolveConnectionTarget();if(!target)return;const probe=await probeTargetDatabase(target);if(probe.ok)return;if(probe.kind!=="missing-database")throw Error(describeProbeFailure(target,probe.kind,probe.error));if(!mayCreateMissingDatabase())throw Error(`The database "${target.database}" does not exist on ${describeTarget(target)}. Create it with: ${manualCreateHint(target)}`);const result=await createDatabase(target);if(!result.created&&result.error)throw Error(`The database "${target.database}" does not exist on ${describeTarget(target)} and it could not be created automatically. ${describeProbeFailure(target,result.kind,result.error)}
|
|
12
|
-
Create it yourself with: ${manualCreateHint(target)}`);if(result.created)log.success(`Created database "${target.database}" on ${target.host}:${target.port}`)}let databaseBootstrapped=!1;export async function ensureDatabaseReady(){await ensureDatabaseConfigLoaded();if(databaseBootstrapped)return;await ensureDatabaseExists();databaseBootstrapped=!0}export function resetDatabaseBootstrapCache(){databaseBootstrapped=!1}export async function featureGatedMigrationFiles(dir=migrationDirectory()){const gated=[];try{const{appModelClaimsTable,FEATURE_NAMES,migrationFeature,migrationTable}=await import("@stacksjs/features"),{feature:isFeatureEnabled}=await import("@stacksjs/config");if(!existsSync(dir))return gated;const disabledFeatures=new Set(FEATURE_NAMES.filter((f)=>!isFeatureEnabled(f)));if(disabledFeatures.size===0)return gated;for(const file of readdirSync(dir).filter((f)=>f.endsWith(".sql"))){const owner=migrationFeature(file);if(!owner||!disabledFeatures.has(owner))continue;const table=migrationTable(file);if(table&&appModelClaimsTable(table))continue;gated.push({file,feature:owner,table})}}catch{}return gated}export async function featureGateForLedger(dir=migrationDirectory(getDialect())){const gated=await featureGatedMigrationFiles(dir),tables=new Set(gated.flatMap((entry)=>entry.table?[entry.table.toLowerCase()]:[]));return{exclude:new Set(gated.map((entry)=>entry.file)),rewrite:(sql)=>tables.size>0?withoutGatedStatements(sql,tables):sql}}async function hideDisabledFeatureMigrations(){const hidden=[];try{const fs=await import("node:fs/promises"),migrationsDir=migrationDirectory();if(!existsSync(migrationsDir))return hidden;const gated=await featureGatedMigrationFiles(migrationsDir);if(gated.length===0)return hidden;const files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql")),gatedTables=new Set;for(const{file,feature,table}of gated){if(table)gatedTables.add(table.toLowerCase());const original=join(migrationsDir,file),hiddenPath=`${original}.disabled`;await fs.rename(original,hiddenPath);hidden.push({original,hidden:hiddenPath,feature})}for(const file of files){const filePath=join(migrationsDir,file);if(!existsSync(filePath))continue;const sql=readFileSync(filePath,"utf8"),filtered=withoutGatedStatements(sql,gatedTables);if(filtered===sql)continue;const backup=`${filePath}.ungated`;await fs.rename(filePath,backup);writeFileSync(filePath,filtered);hidden.push({original:filePath,hidden:backup,feature:"mixed"})}if(hidden.length>0){const summary=Object.entries(hidden.reduce((acc,h)=>{acc[h.feature]=(acc[h.feature]??0)+1;return acc},{})).map(([f,n])=>`${f}: ${n}`).join(", ");log.info(`[migration] Skipping ${hidden.length} migration(s) for disabled features (${summary}). Run \`./buddy <feature>:install\` to enable.`)}}catch{}return hidden}function afterCommonTableExpressions(statement){if(!/^\s*WITH\b/i.test(statement))return statement;let depth=0;for(let index=0;index<statement.length;index++){const char=statement[index];if(char==="("){depth++;continue}if(char!==")")continue;depth--;if(depth!==0)continue;const rest=statement.slice(index+1);if(/^\s*,/.test(rest))continue;return rest}return statement}export function statementTable(statement){const body=afterCommonTableExpressions(statement),patterns=[/^\s*ALTER\s+TABLE\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?([a-z0-9_]+)["`]?/i,/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["`]?([a-z0-9_]+)["`]?/i,/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?[a-z0-9_]+["`]?\s+ON\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*UPDATE\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*INSERT\s+(?:OR\s+\w+\s+)?INTO\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*DELETE\s+FROM\s+["`]?([a-z0-9_]+)["`]?/i];for(const pattern of patterns){const match=pattern.exec(body);if(match)return match[1].toLowerCase()}return null}export function statementReferencesTable(statement,table){const escaped=table.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return new RegExp(`\\b(?:FROM|JOIN|UPDATE|INTO)\\s+["\`\\[]?${escaped}["\`\\]]?\\b`,"i").test(statement)}function rebuiltTable(statement){const rename=/^\s*ALTER\s+TABLE\s+["`]?([a-z0-9_]+)["`]?\s+RENAME\s+TO\s+["`]?([a-z0-9_]+)["`]?/i.exec(statement);if(rename?.[2])return rename[2].toLowerCase();const table=statementTable(statement);if(table?.startsWith("_qb_tmp_"))return table.slice(8);return null}export function withoutGatedStatements(sql,gated){if(gated.size===0)return sql;const statements=sqlStatementsOf(sql),kept=statements.filter((statement)=>{const table=rebuiltTable(statement)??statementTable(statement);if(table&&
|
|
12
|
+
Create it yourself with: ${manualCreateHint(target)}`);if(result.created)log.success(`Created database "${target.database}" on ${target.host}:${target.port}`)}let databaseBootstrapped=!1;export async function ensureDatabaseReady(){await ensureDatabaseConfigLoaded();if(databaseBootstrapped)return;await ensureDatabaseExists();databaseBootstrapped=!0}export function resetDatabaseBootstrapCache(){databaseBootstrapped=!1}export async function featureGatedMigrationFiles(dir=migrationDirectory()){const gated=[];try{const{appModelClaimsTable,FEATURE_NAMES,migrationFeature,migrationTable}=await import("@stacksjs/features"),{feature:isFeatureEnabled}=await import("@stacksjs/config");if(!existsSync(dir))return gated;const disabledFeatures=new Set(FEATURE_NAMES.filter((f)=>!isFeatureEnabled(f)));if(disabledFeatures.size===0)return gated;for(const file of readdirSync(dir).filter((f)=>f.endsWith(".sql"))){const owner=migrationFeature(file);if(!owner||!disabledFeatures.has(owner))continue;const table=migrationTable(file);if(table&&appModelClaimsTable(table))continue;gated.push({file,feature:owner,table})}}catch{}return gated}export async function featureGateForLedger(dir=migrationDirectory(getDialect())){const gated=await featureGatedMigrationFiles(dir),tables=new Set(gated.flatMap((entry)=>entry.table?[entry.table.toLowerCase()]:[]));return{exclude:new Set(gated.map((entry)=>entry.file)),rewrite:(sql)=>tables.size>0?withoutGatedStatements(sql,tables):sql}}async function hideDisabledFeatureMigrations(){const hidden=[];try{const fs=await import("node:fs/promises"),migrationsDir=migrationDirectory();if(!existsSync(migrationsDir))return hidden;const gated=await featureGatedMigrationFiles(migrationsDir);if(gated.length===0)return hidden;const files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql")),gatedTables=new Set;for(const{file,feature,table}of gated){if(table)gatedTables.add(table.toLowerCase());const original=join(migrationsDir,file),hiddenPath=`${original}.disabled`;await fs.rename(original,hiddenPath);hidden.push({original,hidden:hiddenPath,feature})}for(const file of files){const filePath=join(migrationsDir,file);if(!existsSync(filePath))continue;const sql=readFileSync(filePath,"utf8"),filtered=withoutGatedStatements(sql,gatedTables);if(filtered===sql)continue;const backup=`${filePath}.ungated`;await fs.rename(filePath,backup);writeFileSync(filePath,filtered);hidden.push({original:filePath,hidden:backup,feature:"mixed"})}if(hidden.length>0){const summary=Object.entries(hidden.reduce((acc,h)=>{acc[h.feature]=(acc[h.feature]??0)+1;return acc},{})).map(([f,n])=>`${f}: ${n}`).join(", ");log.info(`[migration] Skipping ${hidden.length} migration(s) for disabled features (${summary}). Run \`./buddy <feature>:install\` to enable.`)}}catch{}return hidden}function afterCommonTableExpressions(statement){if(!/^\s*WITH\b/i.test(statement))return statement;let depth=0;for(let index=0;index<statement.length;index++){const char=statement[index];if(char==="("){depth++;continue}if(char!==")")continue;depth--;if(depth!==0)continue;const rest=statement.slice(index+1);if(/^\s*,/.test(rest))continue;return rest}return statement}export function statementTable(statement){const body=afterCommonTableExpressions(statement),patterns=[/^\s*ALTER\s+TABLE\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*CREATE\s+(?:(?:TEMP|TEMPORARY)\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?([a-z0-9_]+)["`]?/i,/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["`]?([a-z0-9_]+)["`]?/i,/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?[a-z0-9_]+["`]?\s+ON\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*UPDATE\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*INSERT\s+(?:OR\s+\w+\s+)?INTO\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*DELETE\s+FROM\s+["`]?([a-z0-9_]+)["`]?/i];for(const pattern of patterns){const match=pattern.exec(body);if(match)return match[1].toLowerCase()}return null}export function statementReferencesTable(statement,table){const escaped=table.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return new RegExp(`\\b(?:FROM|JOIN|UPDATE|INTO)\\s+["\`\\[]?${escaped}["\`\\]]?\\b`,"i").test(statement)}function rebuiltTable(statement){const rename=/^\s*ALTER\s+TABLE\s+["`]?([a-z0-9_]+)["`]?\s+RENAME\s+TO\s+["`]?([a-z0-9_]+)["`]?/i.exec(statement);if(rename?.[2])return rename[2].toLowerCase();const table=statementTable(statement);if(table?.startsWith("_qb_tmp_"))return table.slice(8);return null}export function withoutGatedStatements(sql,gated){if(gated.size===0)return sql;const statements=sqlStatementsOf(sql),excluded=new Set(gated),temporary=new Set(statements.filter((statement)=>/^\s*CREATE\s+(?:TEMP|TEMPORARY)\s+TABLE\b/i.test(statement)).map((statement)=>statementTable(statement)).filter((table)=>table!==null));let changed=!0;while(changed){changed=!1;for(const statement of statements){const table=statementTable(statement);if(!table||!temporary.has(table)||excluded.has(table))continue;if([...excluded].some((source)=>statementReferencesTable(statement,source))){excluded.add(table);changed=!0}}}const kept=statements.filter((statement)=>{const table=rebuiltTable(statement)??statementTable(statement);if(table&&excluded.has(table))return!1;for(const candidate of excluded)if(statementReferencesTable(statement,candidate))return!1;return!0});if(kept.length===statements.length)return sql;return kept.length===0?"":`${kept.join(`;
|
|
13
13
|
`)};
|
|
14
14
|
`}async function restoreHiddenMigrations(hidden){const fs=await import("node:fs/promises");for(const{original,hidden:h}of hidden)try{if(h.endsWith(".ungated"))await fs.rm(original,{force:!0});await fs.rename(h,original)}catch{}}export async function countAppliedMigrations(){await ensureDatabaseConfigLoaded();try{const rows=await db.unsafe("SELECT COUNT(*) AS n FROM migrations").execute(),row=Array.isArray(rows)?rows[0]:void 0;if(!row)return 0;const n=Number(row.n??row.N??0);return Number.isFinite(n)?n:0}catch{return 0}}async function writeMigrateMarker(appliedCount){try{const fs=await import("node:fs/promises"),dir=path.frameworkRuntimePath();await fs.mkdir(dir,{recursive:!0});const file=`${dir}/last-migrate-result.json`,body=JSON.stringify({appliedCount,completedAt:new Date().toISOString()});await fs.writeFile(file,body,"utf8")}catch{}}export function idempotentSql(sql){const header=/^(?:[^\S\n]*--[^\n]*\n)+/.exec(sql)?.[0]??"",bodies=[],stmts=sql.slice(header.length).replace(/\$([A-Za-z_]*)\$[\s\S]*?\$\1\$/g,(body)=>`\x00${bodies.push(body)-1}\x00`).split(";").map((s)=>s.trim().replace(/\u0000(\d+)\u0000/g,(_m,index)=>bodies[Number(index)])).filter(Boolean);if(stmts.length===0)return sql;const out=[];for(const raw of stmts){const stmt=raw.replace(/\bADD\s+COLUMN\s+(?!IF\s+NOT\s+EXISTS\b)/gi,"ADD COLUMN IF NOT EXISTS ").replace(/\bDROP\s+COLUMN\s+(?!IF\s+EXISTS\b)/gi,"DROP COLUMN IF EXISTS "),m=/^ALTER\s+TABLE\s+("?\w+"?)\s+ADD\s+CONSTRAINT\s+("?\w+"?)/i.exec(stmt);if(m){const drops=[`ALTER TABLE ${m[1]} DROP CONSTRAINT IF EXISTS ${m[2]}`],fk=/\bFOREIGN\s+KEY\s*\(\s*"?(\w+)"?\s*\)/i.exec(stmt);if(fk){const table=m[1].replace(/"/g,"");drops.push(`ALTER TABLE ${m[1]} DROP CONSTRAINT IF EXISTS "${table}_${fk[1]}_fkey"`)}const already=new Set;for(let i=out.length-1;i>=0;i--){const previous=out[i];if(!/^ALTER\s+TABLE\s+"?\w+"?\s+DROP\s+CONSTRAINT\b/i.test(previous))break;already.add(previous.toUpperCase())}for(const drop of drops)if(!already.has(drop.toUpperCase()))out.push(drop)}out.push(stmt)}return`${header}${out.join(`;
|
|
15
15
|
`)};
|
package/dist/seeder.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{existsSync,readdirSync}from"node:fs";import{extname,relative}from"node:path";import{pathToFileURL}from"node:url";import{log}from"@stacksjs/logging";import{db,ensureDatabaseConfigLoaded}from"./utils";import{faker}from"@stacksjs/faker";import{path}from"@stacksjs/path";import{hashMake}from"@stacksjs/security";import{fs}from"@stacksjs/storage";export class Seeder{static order=0;static tags=[]}const SEEDER_EXTENSIONS=new Set([".js",".mjs",".ts"]);function applicationSeederFiles(directory){if(!existsSync(directory))return[];const files=[],visit=(current)=>{const entries=readdirSync(current,{withFileTypes:!0}).sort((a,b)=>a.name.localeCompare(b.name));for(const entry of entries){if(entry.name.startsWith("."))continue;const file=`${current}/${entry.name}`;if(entry.isDirectory()){visit(file);continue}if(!entry.isFile()||entry.name.endsWith(".d.ts")||!SEEDER_EXTENSIONS.has(extname(entry.name)))continue;files.push(file)}};visit(directory);return files}export async function runApplicationSeeders(config={}){const startTime=Date.now(),directory=config.directory||path.userDatabasePath("seeders"),verbose=config.verbose??!0,results=[],loaded=[];for(const file of applicationSeederFiles(directory)){const displayFile=relative(directory,file),fallbackName=displayFile.replace(/\.(?:m?js|ts)$/,"");try{const SeederClass=(await import(pathToFileURL(file).href)).default,order=typeof SeederClass?.order==="number"?SeederClass.order:0,tags=Array.isArray(SeederClass?.tags)?SeederClass.tags.map(String):[];loaded.push({file,displayFile,name:SeederClass?.name||fallbackName,order,tags,SeederClass})}catch(error){loaded.push({file,displayFile,name:fallbackName,order:0,tags:[],loadError:error})}}const only=config.only?new Set(config.only):void 0,except=config.except?new Set(config.except):void 0,tags=config.tags?.length?new Set(config.tags):void 0,selected=loaded.filter((entry)=>{if(only&&!only.has(entry.name))return!1;if(tags&&!entry.tags.some((tag)=>tags.has(tag)))return!1;return!except?.has(entry.name)});selected.sort((a,b)=>a.order-b.order);for(const entry of selected){const startedAt=Date.now(),{displayFile}=entry;let seeder=entry.name;try{if(entry.loadError)throw entry.loadError;const SeederClass=entry.SeederClass;if(typeof SeederClass!=="function")throw TypeError("The default export must be a Seeder class.");const instance=new SeederClass;if(!(instance instanceof Seeder))throw TypeError("The default export must extend Seeder from @stacksjs/database.");seeder=SeederClass.name||seeder;if(verbose)log.info(`Running application seeder ${seeder}...`);await instance.run();results.push({seeder,file:displayFile,success:!0,duration:Date.now()-startedAt})}catch(error){const message=error instanceof Error?error.message:String(error);if(verbose)log.error(`Application seeder ${seeder} failed: ${message}`);results.push({seeder,file:displayFile,success:!1,error:message,duration:Date.now()-startedAt})}}return{total:results.length,successful:results.filter((result)=>result.success).length,failed:results.filter((result)=>!result.success).length,results,duration:Date.now()-startTime}}export function defaultModelsPath(subpath){return path.frameworkPath(`defaults/app/Models/${subpath||""}`)}export const PROTECTED_MODELS=Object.freeze(["OauthClient","OauthAccessToken","OauthRefreshToken","PersonalAccessToken"]),ACCOUNT_MODELS=Object.freeze(["User","Team","Customer"]);export function isAccountModel(name){return ACCOUNT_MODELS.includes(name)}export function isProtectedModel(name){return PROTECTED_MODELS.includes(name)}function snakeCase(str){return str.replace(/([A-Z]+)([A-Z][a-z])/g,"$1_$2").replace(/([a-z\d])([A-Z])/g,"$1_$2").toLowerCase()}async function loadModelsFromDir(modelsDir,recursive=!1,includeNonSeeding=!1){const models=[];if(!fs.existsSync(modelsDir))return models;const entries=fs.readdirSync(modelsDir,{withFileTypes:!0});for(const entry of entries){const fullPath=path.join(modelsDir,entry.name);if(entry.isDirectory()&&recursive){const subModels=await loadModelsFromDir(fullPath,!0,includeNonSeeding);models.push(...subModels);continue}if(!entry.name.endsWith(".ts")||entry.name.startsWith("index")||entry.name.startsWith("_"))continue;try{const module=await import(fullPath),modelDef=module.default||module;if(!modelDef)continue;const useSeeder=modelDef.traits?.useSeeder??modelDef.traits?.seedable;if(!useSeeder&&!includeNonSeeding)continue;const seedable=Boolean(useSeeder);let count=10,fixtures=[],after=[];if(typeof useSeeder==="object"&&useSeeder!==null){const opts=useSeeder;if(typeof opts.count==="number")count=opts.count;fixtures=opts.fixtures??[];after=Array.isArray(opts.after)?opts.after.filter((name)=>typeof name==="string"):[]}const modelName=modelDef.name||entry.name.replace(".ts",""),tableName=modelDef.table||snakeCase(modelName)+"s";models.push({name:modelName,table:tableName,count:Math.max(count,fixtures.length),fixtures,attributes:modelDef.attributes||{},model:modelDef,filePath:fullPath,after,seedable})}catch(err){log.error(`Failed to load model ${entry.name}:`,err)}}return models}async function loadAllModels(userModelsDir,verbose=!1,includeDefaults=!1){const defaultDir=defaultModelsPath(),userModels=await loadModelsFromDir(userModelsDir,!1,!0);if(userModels.length>0&&!includeDefaults)return userModels.filter((model)=>model.seedable);const defaultModels=await loadModelsFromDir(defaultDir,!0),modelMap=new Map;for(const model of defaultModels)modelMap.set(model.name,model);for(const model of userModels){if(modelMap.has(model.name)&&verbose)log.info(model.seedable?` User model "${model.name}" overrides default`:` User model "${model.name}" overrides default and opts out of the model pass`);modelMap.set(model.name,model)}return Array.from(modelMap.values()).filter((model)=>model.seedable)}async function loadModels(modelsDir){return loadModelsFromDir(modelsDir,!1)}function isPasswordField(fieldName,attr){const lowerName=fieldName.toLowerCase();if(lowerName==="password"||lowerName.endsWith("_password")||lowerName.endsWith("password"))return!0;if(attr.hidden===!0&&lowerName.includes("pass"))return!0;return!1}export class SeedReferenceError extends Error{name="SeedReferenceError"}async function unavailablePick(model){throw new SeedReferenceError(`pick('${model}') is only available while seeding a model`)}const pendingModels=new Set;function factoryContext(model){const rowsByTable=new Map;return{async pick(parent,column="id"){if(parent!==model.name&&pendingModels.has(parent))throw new SeedReferenceError(`${model.name} picks from ${parent}, which seeds later in this run. Add '${parent}' to ${model.name}'s useSeeder.after.`);const table=parentTable(parent);let rows=rowsByTable.get(table);if(!rows){rows=await existingRows(table);rowsByTable.set(table,rows)}if(rows.length===0)throw new SeedReferenceError(`${model.name} picks from ${parent}, but ${table} has no rows. Seed ${parent} first, or list it in ${model.name}'s useSeeder.after.`);const row=faker.helpers.arrayElement(rows);if(!(column in row))throw new SeedReferenceError(`${model.name} picks ${parent}.${column}, but ${table} has no column "${column}".`);return row[column]}}}function columnIsNotNull(attr){const nullable=attr.nullable;if(nullable!==void 0)return!nullable;const validatorRequired=Boolean(attr.validation?.rule?.isRequired);return attr.required??validatorRequired}async function generateRecord(attributes,modelName,report=!1,context={pick:unavailablePick}){const record={},generated={};for(const[fieldName,attr]of Object.entries(attributes)){const columnName=snakeCase(fieldName);let value;if(attr.factory&&typeof attr.factory==="function")try{value=await attr.factory(faker,generated,context)}catch(err){if(err instanceof SeedReferenceError)throw err;const errorMsg=err instanceof Error?err.message:String(err);if(report)log.warn(` Factory failed for ${modelName}.${fieldName}: ${errorMsg} - seeding the default instead.`);if(attr.default!==void 0)value=attr.default;else value=inferDefaultValue(fieldName)}else if(attr.default!==void 0)value=attr.default;else continue;generated[fieldName]=value;if(isPasswordField(fieldName,attr)&&typeof value==="string")try{value=await hashMake(value,{algorithm:"bcrypt"})}catch(err){const errorMsg=err instanceof Error?err.message:String(err);log.warn(` Failed to hash password for ${modelName}.${fieldName}: ${errorMsg}`)}record[columnName]=value}return record}function inferDefaultValue(fieldName){const lowerName=fieldName.toLowerCase();if(lowerName.startsWith("is")||lowerName.startsWith("has")||lowerName.endsWith("able"))return!1;if(lowerName.includes("count")||lowerName.includes("amount")||lowerName.includes("quantity"))return 0;if(lowerName.includes("url")||lowerName.includes("link"))return"https://example.com";if(lowerName.includes("email"))return faker.internet.email();if(lowerName.includes("name"))return faker.person.fullName();return null}function fixtureToColumns(fixture){const out={};for(const[key,value]of Object.entries(fixture))out[snakeCase(key)]=value;return out}async function existingRows(table){try{return await db.selectFrom(table).selectAll().limit(500).execute()}catch{return[]}}const modelTables=new Map;export function registerModelTables(models){modelTables.clear();for(const model of models)modelTables.set(model.name,model.table)}export function parentTable(parent){return modelTables.get(parent)??`${snakeCase(parent)}s`}async function relationPools(model,options={}){const parents=parentRelations(model);if(parents.length===0)return[];const pools=[];for(const relation of parents){const{model:parent,column}=relation;if(model.attributes[parent])continue;const rows=isAccountModel(parent)&&!options.attachAccounts?seededAccountRows.get(parent)??[]:await existingRows(parentTable(parent));if(rows.length>0){pools.push({column,rows});continue}if(isAccountModel(parent)&&!options.attachAccounts){const existing=(await existingRows(parentTable(parent))).length;log.info(existing>0?` ${model.name}.${column} left empty: no ${parent} rows were seeded in this run, and the ones already in ${parentTable(parent)} may be real accounts. Pass --attach-accounts if this database is a scratch copy.`:` ${model.name}.${column} left empty: there are no ${parent} rows to point it at.`)}}return pools}const seededAccountRows=new Map;async function rememberSeededAccounts(model,highestIdBefore,inserted){if(inserted===0)return;if(highestIdBefore===void 0){log.info(` Could not tell which ${model.name} rows this run seeded (no readable integer id), so no seeded row will point at them.`);return}try{let query=db.selectFrom(model.table).selectAll();if(highestIdBefore!==null)query=query.where("id",">",highestIdBefore);const rows=await query.orderBy("id","asc").limit(inserted).execute();seededAccountRows.set(model.name,[...seededAccountRows.get(model.name)??[],...rows])}catch(error){log.info(` Could not read back the ${model.name} rows just seeded: ${error instanceof Error?error.message:String(error)}`)}}async function highestId(table){const row=await db.selectFrom(table).select(["id"]).orderBy("id","desc").limit(1).executeTakeFirst(),id=Number(row?.id);return row&&Number.isFinite(id)?id:null}export function chooseRelations(pools,count){if(pools.length===0)return[];const wanted=new Set(pools.map((pool)=>pool.column)),specificity=(pool)=>{const sample=pool.rows[0]??{};return[...wanted].filter((column)=>column!==pool.column&&(column in sample)).length},ordered=[...pools].sort((a,b)=>specificity(b)-specificity(a));return Array.from({length:count},()=>{const row={};for(const pool of ordered){if(row[pool.column]!=null)continue;const agrees=(candidate)=>[...wanted].every((column)=>row[column]==null||candidate[column]==null||candidate[column]===row[column]),candidates=pool.rows.filter(agrees),from=candidates.length>0?candidates:pool.rows,chosen=from[Math.floor(Math.random()*from.length)];row[pool.column]=chosen.id;for(const column of wanted)if(column!==pool.column&&row[column]==null&&chosen[column]!=null)row[column]=chosen[column]}return row})}function uniqueColumns(attributes){return Object.entries(attributes).filter(([,attr])=>attr.unique===!0).map(([field])=>snakeCase(field))}function withSuffix(value,suffix){return/\s/.test(value)?`${value} ${suffix}`:`${value}-${suffix}`}export function claimUniqueValues(record,columns,used,suffix){let distinct=!0;for(const column of columns){const value=record[column];if(value==null)continue;const seen=used.get(column)??new Set;used.set(column,seen);if(!seen.has(value)){seen.add(value);continue}distinct=!1;if(suffix>0){const disambiguated=typeof value==="number"?value+suffix:withSuffix(String(value),suffix);record[column]=disambiguated;seen.add(disambiguated)}}return distinct}export function uniqueColumnSets(model){const sets=[],indexes=model.model.indexes??[];for(const index of indexes)if(index?.unique===!0&&Array.isArray(index.columns)&&index.columns.length>0)sets.push(index.columns.map((column)=>snakeCase(column)));const relationColumns=new Set(parentRelations(model).map((relation)=>relation.column));for(const column of uniqueColumns(model.attributes))if(relationColumns.has(column))sets.push([column]);return sets}function uniqueKey(record,columns){const values=columns.map((column)=>record[column]);return values.some((value)=>value==null)?null:JSON.stringify(values)}const RELATION_ATTEMPTS=25;async function generateRecords(model,options={}){const records=[],context=factoryContext(model),requiredFactoryColumns=Object.entries(model.attributes).filter(([,attr])=>typeof attr.factory==="function"&&columnIsNotNull(attr)).map(([name])=>({name,column:snakeCase(name)})),pools=await relationPools(model,options),relations=chooseRelations(pools,model.count),unique=uniqueColumns(model.attributes),used=new Map,sets=uniqueColumnSets(model),claimed=sets.map(()=>new Set);let dropped=0;const collides=(row)=>sets.some((columns,i)=>{const key=uniqueKey(row,columns);return key!==null&&claimed[i].has(key)});for(let i=0;i<model.count;i++){let record=await generateRecord(model.attributes,model.name,i===0,context);if(unique.length>0){for(let attempt=0;attempt<3&&!claimUniqueValues(record,unique,used,0);attempt++)record=await generateRecord(model.attributes,model.name,!1,context);claimUniqueValues(record,unique,used,i+1)}const fixture=model.fixtures[i],attach=(relation)=>{const withRelations={...record};for(const[column,value]of Object.entries(relation))if(withRelations[column]==null)withRelations[column]=value;return fixture?{...withRelations,...fixtureToColumns(fixture)}:withRelations};let row=attach(relations[i]??{});if(!fixture&&pools.length>0){for(let attempt=0;attempt<RELATION_ATTEMPTS&&collides(row);attempt++)row=attach(chooseRelations(pools,1)[0]??{});if(collides(row)){dropped++;continue}}for(const{name,column}of requiredFactoryColumns)if(row[column]==null)throw Error(`${model.name}.${name}: its factory produced ${row[column]===null?"null":"nothing"} for a required column, so no row can be inserted. Return a value, or reference an existing row with pick() in the factory's third argument.`);sets.forEach((columns,s)=>{const key=uniqueKey(row,columns);if(key!==null)claimed[s].add(key)});records.push(row)}if(dropped>0)log.info(` ${model.name}: seeding ${records.length} of ${model.count} rows - the parent rows allow no more distinct ${sets.map((columns)=>`(${columns.join(", ")})`).join(" / ")} combinations.`);return records}async function seedModel(model,options){const startTime=Date.now();try{try{await db.selectFrom(model.table).limit(0).execute()}catch(tableErr){const msg=tableErr?.message||"";if(msg.includes("does not exist")||msg.includes("no such table")||msg.includes("doesn't exist")){log.info(` Skipping ${model.name}: table "${model.table}" does not exist`);return{model:model.name,table:model.table,count:0,success:!0,duration:Date.now()-startTime}}throw tableErr}if(!options.fresh&&!options.append){if(await db.selectFrom(model.table).selectAll().limit(1).executeTakeFirst()){if(options.verbose)log.info(` ${model.name}: table already has rows - skipping (--append to add more, --fresh to replace)`);return{model:model.name,table:model.table,count:0,success:!0,duration:Date.now()-startTime}}}const records=await generateRecords(model,options);if(records.length===0)return{model:model.name,table:model.table,count:0,success:!0,duration:Date.now()-startTime};const account=isAccountModel(model.name),highestIdBefore=account?await highestId(model.table).catch(()=>{return}):void 0,batchSize=100;let inserted=0;for(let i=0;i<records.length;i+=batchSize){const batch=records.slice(i,i+batchSize);await db.insertInto(model.table).values(batch).execute();inserted+=batch.length}if(account)await rememberSeededAccounts(model,highestIdBefore,inserted);if(options.verbose)log.success(` Seeded ${model.name}: ${inserted} records`);return{model:model.name,table:model.table,count:inserted,success:!0,duration:Date.now()-startTime}}catch(err){const errorMessage=err instanceof Error?err.message:String(err);if(options.verbose)log.error(` Failed to seed ${model.name}: ${errorMessage}`);return{model:model.name,table:model.table,count:0,success:!1,error:errorMessage,duration:Date.now()-startTime}}}export function parentRelations(model){const belongsTo=model.model.belongsTo,read=(entry)=>{if(typeof entry==="string")return entry?{model:entry,column:`${snakeCase(entry)}_id`}:null;if(entry&&typeof entry==="object"){const name=String(entry.model??"");if(!name)return null;const key=entry.foreignKey;return{model:name,column:key||`${snakeCase(name)}_id`}}return null};return(Array.isArray(belongsTo)?belongsTo:belongsTo&&typeof belongsTo==="object"?Object.values(belongsTo):[]).map(read).filter((relation)=>relation!==null)}function parentModels(model){return parentRelations(model).map((relation)=>relation.model)}async function clearTables(models,verbose){for(const model of[...models].reverse())try{await db.deleteFrom(model.table).execute();if(verbose)log.info(` Truncated table: ${model.table}`)}catch(error){const message=error instanceof Error?error.message:String(error);if(/no such table|doesn't exist|does not exist/i.test(message))continue;throw Error(`Could not empty ${model.table} before seeding: ${message}`)}}function sortModelsByDependencies(models){const byName=new Map(models.map((model)=>[model.name,model])),ordered=[],state=new Map,visit=(model)=>{const status=state.get(model.name);if(status==="done"||status==="visiting")return;state.set(model.name,"visiting");for(const parentName of[...parentModels(model),...model.after]){const parent=byName.get(parentName);if(parent&&parent!==model)visit(parent)}state.set(model.name,"done");ordered.push(model)};for(const model of models)visit(model);return ordered}export async function seed(config={}){const startTime=Date.now();await ensureDatabaseConfigLoaded();const modelsDir=config.modelsDir||path.userModelsPath(),verbose=config.verbose??!0;if(verbose){log.info("Seeding database using model factories...");log.info(`User models directory: ${modelsDir}`);log.info(`Default models directory: ${defaultModelsPath()}`)}let models=await loadAllModels(modelsDir,verbose,config.includeDefaults??!1);registerModelTables(models);seededAccountRows.clear();if(models.length===0){log.warn("No seedable models found in defaults or user directories");return{total:0,successful:0,failed:0,results:[],duration:Date.now()-startTime}}if(config.only&&config.only.length>0)models=models.filter((m)=>config.only.includes(m.name));if(config.except&&config.except.length>0)models=models.filter((m)=>!config.except.includes(m.name));if(!config.fresh&&!config.allowProtected){const skipped=[];models=models.filter((m)=>{if(isProtectedModel(m.name)){skipped.push(m);return!1}return!0});if(skipped.length>0){log.info(`Skipped ${skipped.length} protected auth model(s) to avoid invalidating live sessions: ${skipped.map((m)=>m.name).join(", ")}`);log.info(" Re-run with --fresh (truncates tables first) or --allow-protected to include them.")}}models=sortModelsByDependencies(models);if(verbose)log.info(`Found ${models.length} seedable model(s)`);if(config.fresh)await clearTables(models,verbose);const results=[];pendingModels.clear();for(const model of models)pendingModels.add(model.name);for(const model of models){if(verbose)log.info(`Seeding ${model.name} (${model.count} records)...`);try{const result=await seedModel(model,config);results.push(result)}catch(err){const errorMessage=err instanceof Error?err.message:String(err);if(verbose)log.error(` Unexpected error seeding ${model.name}: ${errorMessage}`);results.push({model:model.name,table:model.table,count:0,success:!1,error:errorMessage,duration:0})}finally{pendingModels.delete(model.name)}}pendingModels.clear();const successful=results.filter((r)=>r.success).length,failed=results.filter((r)=>!r.success).length,totalRecords=results.reduce((sum,r)=>sum+r.count,0);if(verbose){log.info("");if(failed===0){log.success("Database seeded successfully!");log.info(` Total records: ${totalRecords}`);log.info(` Models seeded: ${successful}`)}else{log.warn(`Seeding completed with ${failed} failure(s)`);log.info(` Successful: ${successful}`);log.info(` Failed: ${failed}`)}}for(const result of results)if(!result.success)log.error(` ${result.model}: ${result.error}`);return{total:results.length,successful,failed,results,duration:Date.now()-startTime}}export async function makeModelRecords(modelName,count=1,overrides={},options={}){if(!Number.isInteger(count)||count<1)throw TypeError(`makeModelRecords(${modelName}) needs a positive integer count; got ${count}`);const models=await loadAllModels(path.userModelsPath(),options.verbose??!1,!0),model=models.find((m)=>m.name===modelName);if(!model)throw Error(`Model not found: ${modelName}. Known models: ${models.map((m)=>m.name).sort().join(", ")||"(none loaded)"}`);return(await generateRecords({...model,count},{verbose:options.verbose??!1})).map((record)=>({...record,...snakeCaseKeys(overrides)}))}function snakeCaseKeys(input){return Object.fromEntries(Object.entries(input).map(([key,value])=>[snakeCase(key),value]))}export async function seedModel$(modelName,options={}){const modelsDir=path.userModelsPath(),model=(await loadAllModels(modelsDir,options.verbose)).find((m)=>m.name===modelName);if(!model)throw Error(`Model not found: ${modelName}`);if(options.count)model.count=options.count;seededAccountRows.clear();return seedModel(model,{fresh:options.fresh,verbose:options.verbose??!0})}export async function freshSeed(config={}){return seed({...config,fresh:!0})}export async function listSeedableModels(){const modelsDir=path.userModelsPath(),defaultDir=defaultModelsPath(),defaultModels=await loadModelsFromDir(defaultDir,!0),userModels=await loadModelsFromDir(modelsDir,!1),result=[],seen=new Set;for(const m of userModels){result.push({name:m.name,table:m.table,count:m.count,source:"user"});seen.add(m.name)}for(const m of defaultModels)if(!seen.has(m.name))result.push({name:m.name,table:m.table,count:m.count,source:"default"});return result}export{seed as runSeeders};export{freshSeed as freshWithSeed};
|
|
1
|
+
import{existsSync,readdirSync}from"node:fs";import{extname,relative}from"node:path";import{pathToFileURL}from"node:url";import{log}from"@stacksjs/logging";import{db,ensureDatabaseConfigLoaded}from"./utils";import{isDateTimeColumnRule,sqlDateTimeInput}from"./sql-helpers";import{faker}from"@stacksjs/faker";import{path}from"@stacksjs/path";import{hashMake}from"@stacksjs/security";import{fs}from"@stacksjs/storage";export class Seeder{static order=0;static tags=[]}const SEEDER_EXTENSIONS=new Set([".js",".mjs",".ts"]);function applicationSeederFiles(directory){if(!existsSync(directory))return[];const files=[],visit=(current)=>{const entries=readdirSync(current,{withFileTypes:!0}).sort((a,b)=>a.name.localeCompare(b.name));for(const entry of entries){if(entry.name.startsWith("."))continue;const file=`${current}/${entry.name}`;if(entry.isDirectory()){visit(file);continue}if(!entry.isFile()||entry.name.endsWith(".d.ts")||!SEEDER_EXTENSIONS.has(extname(entry.name)))continue;files.push(file)}};visit(directory);return files}export async function runApplicationSeeders(config={}){const startTime=Date.now(),directory=config.directory||path.userDatabasePath("seeders"),verbose=config.verbose??!0,results=[],loaded=[];for(const file of applicationSeederFiles(directory)){const displayFile=relative(directory,file),fallbackName=displayFile.replace(/\.(?:m?js|ts)$/,"");try{const SeederClass=(await import(pathToFileURL(file).href)).default,order=typeof SeederClass?.order==="number"?SeederClass.order:0,tags=Array.isArray(SeederClass?.tags)?SeederClass.tags.map(String):[];loaded.push({file,displayFile,name:SeederClass?.name||fallbackName,order,tags,SeederClass})}catch(error){loaded.push({file,displayFile,name:fallbackName,order:0,tags:[],loadError:error})}}const only=config.only?new Set(config.only):void 0,except=config.except?new Set(config.except):void 0,tags=config.tags?.length?new Set(config.tags):void 0,selected=loaded.filter((entry)=>{if(only&&!only.has(entry.name))return!1;if(tags&&!entry.tags.some((tag)=>tags.has(tag)))return!1;return!except?.has(entry.name)});selected.sort((a,b)=>a.order-b.order);for(const entry of selected){const startedAt=Date.now(),{displayFile}=entry;let seeder=entry.name;try{if(entry.loadError)throw entry.loadError;const SeederClass=entry.SeederClass;if(typeof SeederClass!=="function")throw TypeError("The default export must be a Seeder class.");const instance=new SeederClass;if(!(instance instanceof Seeder))throw TypeError("The default export must extend Seeder from @stacksjs/database.");seeder=SeederClass.name||seeder;if(verbose)log.info(`Running application seeder ${seeder}...`);await instance.run();results.push({seeder,file:displayFile,success:!0,duration:Date.now()-startedAt})}catch(error){const message=error instanceof Error?error.message:String(error);if(verbose)log.error(`Application seeder ${seeder} failed: ${message}`);results.push({seeder,file:displayFile,success:!1,error:message,duration:Date.now()-startedAt})}}return{total:results.length,successful:results.filter((result)=>result.success).length,failed:results.filter((result)=>!result.success).length,results,duration:Date.now()-startTime}}export function defaultModelsPath(subpath){return path.frameworkPath(`defaults/app/Models/${subpath||""}`)}export const PROTECTED_MODELS=Object.freeze(["OauthClient","OauthAccessToken","OauthRefreshToken","PersonalAccessToken"]),ACCOUNT_MODELS=Object.freeze(["User","Team","Customer"]);export function isAccountModel(name){return ACCOUNT_MODELS.includes(name)}export function isProtectedModel(name){return PROTECTED_MODELS.includes(name)}function snakeCase(str){return str.replace(/([A-Z]+)([A-Z][a-z])/g,"$1_$2").replace(/([a-z\d])([A-Z])/g,"$1_$2").toLowerCase()}async function loadModelsFromDir(modelsDir,recursive=!1,includeNonSeeding=!1){const models=[];if(!fs.existsSync(modelsDir))return models;const entries=fs.readdirSync(modelsDir,{withFileTypes:!0});for(const entry of entries){const fullPath=path.join(modelsDir,entry.name);if(entry.isDirectory()&&recursive){const subModels=await loadModelsFromDir(fullPath,!0,includeNonSeeding);models.push(...subModels);continue}if(!entry.name.endsWith(".ts")||entry.name.startsWith("index")||entry.name.startsWith("_"))continue;try{const module=await import(fullPath),modelDef=module.default||module;if(!modelDef)continue;const useSeeder=modelDef.traits?.useSeeder??modelDef.traits?.seedable;if(!useSeeder&&!includeNonSeeding)continue;const seedable=Boolean(useSeeder);let count=10,fixtures=[],after=[];if(typeof useSeeder==="object"&&useSeeder!==null){const opts=useSeeder;if(typeof opts.count==="number")count=opts.count;fixtures=opts.fixtures??[];after=Array.isArray(opts.after)?opts.after.filter((name)=>typeof name==="string"):[]}const modelName=modelDef.name||entry.name.replace(".ts",""),tableName=modelDef.table||snakeCase(modelName)+"s";models.push({name:modelName,table:tableName,count:Math.max(count,fixtures.length),fixtures,attributes:modelDef.attributes||{},model:modelDef,filePath:fullPath,after,seedable})}catch(err){log.error(`Failed to load model ${entry.name}:`,err)}}return models}async function loadAllModels(userModelsDir,verbose=!1,includeDefaults=!1){const defaultDir=defaultModelsPath(),userModels=await loadModelsFromDir(userModelsDir,!1,!0);if(userModels.length>0&&!includeDefaults)return userModels.filter((model)=>model.seedable);const defaultModels=await loadModelsFromDir(defaultDir,!0),modelMap=new Map;for(const model of defaultModels)modelMap.set(model.name,model);for(const model of userModels){if(modelMap.has(model.name)&&verbose)log.info(model.seedable?` User model "${model.name}" overrides default`:` User model "${model.name}" overrides default and opts out of the model pass`);modelMap.set(model.name,model)}return Array.from(modelMap.values()).filter((model)=>model.seedable)}async function loadModels(modelsDir){return loadModelsFromDir(modelsDir,!1)}function isPasswordField(fieldName,attr){const lowerName=fieldName.toLowerCase();if(lowerName==="password"||lowerName.endsWith("_password")||lowerName.endsWith("password"))return!0;if(attr.hidden===!0&&lowerName.includes("pass"))return!0;return!1}export class SeedReferenceError extends Error{name="SeedReferenceError"}async function unavailablePick(model){throw new SeedReferenceError(`pick('${model}') is only available while seeding a model`)}const pendingModels=new Set;function factoryContext(model){const rowsByTable=new Map;return{async pick(parent,column="id"){if(parent!==model.name&&pendingModels.has(parent))throw new SeedReferenceError(`${model.name} picks from ${parent}, which seeds later in this run. Add '${parent}' to ${model.name}'s useSeeder.after.`);const table=parentTable(parent);let rows=rowsByTable.get(table);if(!rows){rows=await existingRows(table);rowsByTable.set(table,rows)}if(rows.length===0)throw new SeedReferenceError(`${model.name} picks from ${parent}, but ${table} has no rows. Seed ${parent} first, or list it in ${model.name}'s useSeeder.after.`);const row=faker.helpers.arrayElement(rows);if(!(column in row))throw new SeedReferenceError(`${model.name} picks ${parent}.${column}, but ${table} has no column "${column}".`);return row[column]}}}function columnIsNotNull(attr){const nullable=attr.nullable;if(nullable!==void 0)return!nullable;const validatorRequired=Boolean(attr.validation?.rule?.isRequired);return attr.required??validatorRequired}async function generateRecord(attributes,modelName,report=!1,context={pick:unavailablePick}){const record={},generated={};for(const[fieldName,attr]of Object.entries(attributes)){const columnName=snakeCase(fieldName);let value;if(attr.factory&&typeof attr.factory==="function")try{value=await attr.factory(faker,generated,context)}catch(err){if(err instanceof SeedReferenceError)throw err;const errorMsg=err instanceof Error?err.message:String(err);if(report)log.warn(` Factory failed for ${modelName}.${fieldName}: ${errorMsg} - seeding the default instead.`);if(attr.default!==void 0)value=attr.default;else value=inferDefaultValue(fieldName)}else if(attr.default!==void 0)value=attr.default;else continue;generated[fieldName]=value;if(isPasswordField(fieldName,attr)&&typeof value==="string")try{value=await hashMake(value,{algorithm:"bcrypt"})}catch(err){const errorMsg=err instanceof Error?err.message:String(err);log.warn(` Failed to hash password for ${modelName}.${fieldName}: ${errorMsg}`)}if(isDateTimeColumnRule(attr.validation?.rule))value=sqlDateTimeInput(value);record[columnName]=value}return record}function inferDefaultValue(fieldName){const lowerName=fieldName.toLowerCase();if(lowerName.startsWith("is")||lowerName.startsWith("has")||lowerName.endsWith("able"))return!1;if(lowerName.includes("count")||lowerName.includes("amount")||lowerName.includes("quantity"))return 0;if(lowerName.includes("url")||lowerName.includes("link"))return"https://example.com";if(lowerName.includes("email"))return faker.internet.email();if(lowerName.includes("name"))return faker.person.fullName();return null}function fixtureToColumns(fixture){const out={};for(const[key,value]of Object.entries(fixture))out[snakeCase(key)]=value;return out}async function existingRows(table){try{return await db.selectFrom(table).selectAll().limit(500).execute()}catch{return[]}}const modelTables=new Map;export function registerModelTables(models){modelTables.clear();for(const model of models)modelTables.set(model.name,model.table)}export function parentTable(parent){return modelTables.get(parent)??`${snakeCase(parent)}s`}async function relationPools(model,options={}){const parents=parentRelations(model);if(parents.length===0)return[];const pools=[];for(const relation of parents){const{model:parent,column}=relation;if(model.attributes[parent])continue;const rows=isAccountModel(parent)&&!options.attachAccounts?seededAccountRows.get(parent)??[]:await existingRows(parentTable(parent));if(rows.length>0){pools.push({column,rows});continue}if(isAccountModel(parent)&&!options.attachAccounts){const existing=(await existingRows(parentTable(parent))).length;log.info(existing>0?` ${model.name}.${column} left empty: no ${parent} rows were seeded in this run, and the ones already in ${parentTable(parent)} may be real accounts. Pass --attach-accounts if this database is a scratch copy.`:` ${model.name}.${column} left empty: there are no ${parent} rows to point it at.`)}}return pools}const seededAccountRows=new Map;async function rememberSeededAccounts(model,highestIdBefore,inserted){if(inserted===0)return;if(highestIdBefore===void 0){log.info(` Could not tell which ${model.name} rows this run seeded (no readable integer id), so no seeded row will point at them.`);return}try{let query=db.selectFrom(model.table).selectAll();if(highestIdBefore!==null)query=query.where("id",">",highestIdBefore);const rows=await query.orderBy("id","asc").limit(inserted).execute();seededAccountRows.set(model.name,[...seededAccountRows.get(model.name)??[],...rows])}catch(error){log.info(` Could not read back the ${model.name} rows just seeded: ${error instanceof Error?error.message:String(error)}`)}}async function highestId(table){const row=await db.selectFrom(table).select(["id"]).orderBy("id","desc").limit(1).executeTakeFirst(),id=Number(row?.id);return row&&Number.isFinite(id)?id:null}export function chooseRelations(pools,count){if(pools.length===0)return[];const wanted=new Set(pools.map((pool)=>pool.column)),specificity=(pool)=>{const sample=pool.rows[0]??{};return[...wanted].filter((column)=>column!==pool.column&&(column in sample)).length},ordered=[...pools].sort((a,b)=>specificity(b)-specificity(a));return Array.from({length:count},()=>{const row={};for(const pool of ordered){if(row[pool.column]!=null)continue;const agrees=(candidate)=>[...wanted].every((column)=>row[column]==null||candidate[column]==null||candidate[column]===row[column]),candidates=pool.rows.filter(agrees),from=candidates.length>0?candidates:pool.rows,chosen=from[Math.floor(Math.random()*from.length)];row[pool.column]=chosen.id;for(const column of wanted)if(column!==pool.column&&row[column]==null&&chosen[column]!=null)row[column]=chosen[column]}return row})}function uniqueColumns(attributes){return Object.entries(attributes).filter(([,attr])=>attr.unique===!0).map(([field])=>snakeCase(field))}function withSuffix(value,suffix){const email=/^([^@\s]+)@([^@\s]+\.[^@\s]+)$/.exec(value);if(email)return`${email[1]}-${suffix}@${email[2]}`;return/\s/.test(value)?`${value} ${suffix}`:`${value}-${suffix}`}export function claimUniqueValues(record,columns,used,suffix){const distinct=columns.every((column)=>record[column]==null||!used.get(column)?.has(record[column]));if(!distinct&&suffix===0)return!1;for(const column of columns){const value=record[column];if(value==null)continue;const seen=used.get(column)??new Set;used.set(column,seen);if(!seen.has(value)){seen.add(value);continue}const disambiguated=typeof value==="number"?value+suffix:withSuffix(String(value),suffix);record[column]=disambiguated;seen.add(disambiguated)}return distinct}export function uniqueColumnSets(model){const sets=[],indexes=model.model.indexes??[];for(const index of indexes)if(index?.unique===!0&&Array.isArray(index.columns)&&index.columns.length>0)sets.push(index.columns.map((column)=>snakeCase(column)));const relationColumns=new Set(parentRelations(model).map((relation)=>relation.column));for(const column of uniqueColumns(model.attributes))if(relationColumns.has(column))sets.push([column]);return sets}function uniqueKey(record,columns){const values=columns.map((column)=>record[column]);return values.some((value)=>value==null)?null:JSON.stringify(values)}const RELATION_ATTEMPTS=25;async function generateRecords(model,options={}){const records=[],context=factoryContext(model),requiredFactoryColumns=Object.entries(model.attributes).filter(([,attr])=>typeof attr.factory==="function"&&columnIsNotNull(attr)).map(([name])=>({name,column:snakeCase(name)})),pools=await relationPools(model,options),relations=chooseRelations(pools,model.count),unique=uniqueColumns(model.attributes),used=new Map,sets=uniqueColumnSets(model),claimed=sets.map(()=>new Set);let dropped=0;const collides=(row)=>sets.some((columns,i)=>{const key=uniqueKey(row,columns);return key!==null&&claimed[i].has(key)});for(let i=0;i<model.count;i++){let record=await generateRecord(model.attributes,model.name,i===0,context);if(unique.length>0){let distinct=claimUniqueValues(record,unique,used,0);for(let attempt=0;attempt<3&&!distinct;attempt++){record=await generateRecord(model.attributes,model.name,!1,context);distinct=claimUniqueValues(record,unique,used,0)}if(!distinct)claimUniqueValues(record,unique,used,i+1)}const fixture=model.fixtures[i],attach=(relation)=>{const withRelations={...record};for(const[column,value]of Object.entries(relation))if(withRelations[column]==null)withRelations[column]=value;return fixture?{...withRelations,...fixtureToColumns(fixture)}:withRelations};let row=attach(relations[i]??{});if(!fixture&&pools.length>0){for(let attempt=0;attempt<RELATION_ATTEMPTS&&collides(row);attempt++)row=attach(chooseRelations(pools,1)[0]??{});if(collides(row)){dropped++;continue}}for(const{name,column}of requiredFactoryColumns)if(row[column]==null)throw Error(`${model.name}.${name}: its factory produced ${row[column]===null?"null":"nothing"} for a required column, so no row can be inserted. Return a value, or reference an existing row with pick() in the factory's third argument.`);sets.forEach((columns,s)=>{const key=uniqueKey(row,columns);if(key!==null)claimed[s].add(key)});records.push(row)}if(dropped>0)log.info(` ${model.name}: seeding ${records.length} of ${model.count} rows - the parent rows allow no more distinct ${sets.map((columns)=>`(${columns.join(", ")})`).join(" / ")} combinations.`);return records}async function seedModel(model,options){const startTime=Date.now();try{try{await db.selectFrom(model.table).limit(0).execute()}catch(tableErr){const msg=tableErr?.message||"";if(msg.includes("does not exist")||msg.includes("no such table")||msg.includes("doesn't exist")){log.info(` Skipping ${model.name}: table "${model.table}" does not exist`);return{model:model.name,table:model.table,count:0,success:!0,duration:Date.now()-startTime}}throw tableErr}if(!options.fresh&&!options.append){if(await db.selectFrom(model.table).selectAll().limit(1).executeTakeFirst()){if(options.verbose)log.info(` ${model.name}: table already has rows - skipping (--append to add more, --fresh to replace)`);return{model:model.name,table:model.table,count:0,success:!0,duration:Date.now()-startTime}}}const records=await generateRecords(model,options);if(records.length===0)return{model:model.name,table:model.table,count:0,success:!0,duration:Date.now()-startTime};const account=isAccountModel(model.name),highestIdBefore=account?await highestId(model.table).catch(()=>{return}):void 0,batchSize=100;let inserted=0;for(let i=0;i<records.length;i+=batchSize){const batch=records.slice(i,i+batchSize);await db.insertInto(model.table).values(batch).execute();inserted+=batch.length}if(account)await rememberSeededAccounts(model,highestIdBefore,inserted);if(options.verbose)log.success(` Seeded ${model.name}: ${inserted} records`);return{model:model.name,table:model.table,count:inserted,success:!0,duration:Date.now()-startTime}}catch(err){const errorMessage=err instanceof Error?err.message:String(err);if(options.verbose)log.error(` Failed to seed ${model.name}: ${errorMessage}`);return{model:model.name,table:model.table,count:0,success:!1,error:errorMessage,duration:Date.now()-startTime}}}export function parentRelations(model){const belongsTo=model.model.belongsTo,read=(entry)=>{if(typeof entry==="string")return entry?{model:entry,column:`${snakeCase(entry)}_id`}:null;if(entry&&typeof entry==="object"){const name=String(entry.model??"");if(!name)return null;const key=entry.foreignKey;return{model:name,column:key||`${snakeCase(name)}_id`}}return null};return(Array.isArray(belongsTo)?belongsTo:belongsTo&&typeof belongsTo==="object"?Object.values(belongsTo):[]).map(read).filter((relation)=>relation!==null)}function parentModels(model){return parentRelations(model).map((relation)=>relation.model)}async function clearTables(models,verbose){for(const model of[...models].reverse())try{await db.deleteFrom(model.table).execute();if(verbose)log.info(` Truncated table: ${model.table}`)}catch(error){const message=error instanceof Error?error.message:String(error);if(/no such table|doesn't exist|does not exist/i.test(message))continue;throw Error(`Could not empty ${model.table} before seeding: ${message}`)}}function sortModelsByDependencies(models){const byName=new Map(models.map((model)=>[model.name,model])),ordered=[],state=new Map,visit=(model)=>{const status=state.get(model.name);if(status==="done"||status==="visiting")return;state.set(model.name,"visiting");for(const parentName of[...parentModels(model),...model.after]){const parent=byName.get(parentName);if(parent&&parent!==model)visit(parent)}state.set(model.name,"done");ordered.push(model)};for(const model of models)visit(model);return ordered}export async function seed(config={}){const startTime=Date.now();await ensureDatabaseConfigLoaded();const modelsDir=config.modelsDir||path.userModelsPath(),verbose=config.verbose??!0;if(verbose){log.info("Seeding database using model factories...");log.info(`User models directory: ${modelsDir}`);log.info(`Default models directory: ${defaultModelsPath()}`)}let models=await loadAllModels(modelsDir,verbose,config.includeDefaults??!1);registerModelTables(models);seededAccountRows.clear();if(models.length===0){log.warn("No seedable models found in defaults or user directories");return{total:0,successful:0,failed:0,results:[],duration:Date.now()-startTime}}if(config.only&&config.only.length>0)models=models.filter((m)=>config.only.includes(m.name));if(config.except&&config.except.length>0)models=models.filter((m)=>!config.except.includes(m.name));if(!config.fresh&&!config.allowProtected){const skipped=[];models=models.filter((m)=>{if(isProtectedModel(m.name)){skipped.push(m);return!1}return!0});if(skipped.length>0){log.info(`Skipped ${skipped.length} protected auth model(s) to avoid invalidating live sessions: ${skipped.map((m)=>m.name).join(", ")}`);log.info(" Re-run with --fresh (truncates tables first) or --allow-protected to include them.")}}models=sortModelsByDependencies(models);if(verbose)log.info(`Found ${models.length} seedable model(s)`);if(config.fresh)await clearTables(models,verbose);const results=[];pendingModels.clear();for(const model of models)pendingModels.add(model.name);for(const model of models){if(verbose)log.info(`Seeding ${model.name} (${model.count} records)...`);try{const result=await seedModel(model,config);results.push(result)}catch(err){const errorMessage=err instanceof Error?err.message:String(err);if(verbose)log.error(` Unexpected error seeding ${model.name}: ${errorMessage}`);results.push({model:model.name,table:model.table,count:0,success:!1,error:errorMessage,duration:0})}finally{pendingModels.delete(model.name)}}pendingModels.clear();const successful=results.filter((r)=>r.success).length,failed=results.filter((r)=>!r.success).length,totalRecords=results.reduce((sum,r)=>sum+r.count,0);if(verbose){log.info("");if(failed===0){log.success("Database seeded successfully!");log.info(` Total records: ${totalRecords}`);log.info(` Models seeded: ${successful}`)}else{log.warn(`Seeding completed with ${failed} failure(s)`);log.info(` Successful: ${successful}`);log.info(` Failed: ${failed}`)}}for(const result of results)if(!result.success)log.error(` ${result.model}: ${result.error}`);return{total:results.length,successful,failed,results,duration:Date.now()-startTime}}export async function makeModelRecords(modelName,count=1,overrides={},options={}){if(!Number.isInteger(count)||count<1)throw TypeError(`makeModelRecords(${modelName}) needs a positive integer count; got ${count}`);const models=await loadAllModels(path.userModelsPath(),options.verbose??!1,!0),model=models.find((m)=>m.name===modelName);if(!model)throw Error(`Model not found: ${modelName}. Known models: ${models.map((m)=>m.name).sort().join(", ")||"(none loaded)"}`);return(await generateRecords({...model,count},{verbose:options.verbose??!1})).map((record)=>({...record,...snakeCaseKeys(overrides)}))}function snakeCaseKeys(input){return Object.fromEntries(Object.entries(input).map(([key,value])=>[snakeCase(key),value]))}export async function seedModel$(modelName,options={}){const modelsDir=path.userModelsPath(),model=(await loadAllModels(modelsDir,options.verbose)).find((m)=>m.name===modelName);if(!model)throw Error(`Model not found: ${modelName}`);if(options.count)model.count=options.count;seededAccountRows.clear();return seedModel(model,{fresh:options.fresh,verbose:options.verbose??!0})}export async function freshSeed(config={}){return seed({...config,fresh:!0})}export async function listSeedableModels(){const modelsDir=path.userModelsPath(),defaultDir=defaultModelsPath(),defaultModels=await loadModelsFromDir(defaultDir,!0),userModels=await loadModelsFromDir(modelsDir,!1),result=[],seen=new Set;for(const m of userModels){result.push({name:m.name,table:m.table,count:m.count,source:"user"});seen.add(m.name)}for(const m of defaultModels)if(!seen.has(m.name))result.push({name:m.name,table:m.table,count:m.count,source:"default"});return result}export{seed as runSeeders};export{freshSeed as freshWithSeed};
|
package/dist/shadowed-models.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{readFileSync}from"node:fs";import process from"node:process";export const ALLOW_SHADOW_DROPS_ENV="STACKS_ALLOW_SHADOW_DROPS";export function shadowDropsAllowed(){return process.env[ALLOW_SHADOW_DROPS_ENV]==="1"}export function declaredTable(file){let source;try{source=readFileSync(file,"utf8")}catch{return null}const match=/\btable\s*:\s*['"`]([^'"`]+)['"`]/.exec(source);return match?match[1]:null}const DROP_COLUMN=/^\s*ALTER\s+TABLE\s+["`']?([\w.$]+)["`']?\s+DROP\s+(?:COLUMN\s+)?["`']?([\w$]+)["`']?/i;export function findShadowedColumnDrops(statements,shadowed){if(shadowed.length===0||statements.length===0)return[];const byTable=new Map;for(const model of shadowed){const frameworkTable=declaredTable(model.frameworkFile),userTable=declaredTable(model.userFile);if(!frameworkTable||frameworkTable!==userTable)continue;byTable.set(frameworkTable,{model:model.name,columns:[]})}if(byTable.size===0)return[];for(const statement of statements){const match=DROP_COLUMN.exec(statement);if(!match)continue;const entry=byTable.get(match[1]);if(entry&&!entry.columns.includes(match[2]))entry.columns.push(match[2])}return[...byTable.entries()].filter(([,entry])=>entry.columns.length>0).map(([table,entry])=>({model:entry.model,table,columns:entry.columns}))}export function shadowedDropMessage(drops){const lines=drops.map((drop)=>{const columns=drop.columns.map((column)=>`\`${column}\``).join(", ");return` - \`app/Models/${drop.model}.ts\` overrides the framework's \`${drop.model}\` model on the \`${drop.table}\` table, and would drop ${columns}.`}),[first]=drops;return["Refusing to generate migrations: a model in app/Models/ would drop columns from a framework table.","",...lines,"","A userland model replaces a framework default entirely rather than extending it, so every column","the framework declared and yours does not is dropped - while the framework's own code goes on","reading them. The migration applies cleanly and nothing fails until a page that has always worked","stops finding a column.","","Two ways out:","",` 1. Start from the default and add to it: buddy publish:model ${first?.model??"<Name>"}`," That copies the framework's definition into app/Models/, so nothing is lost by writing yours.",""," 2. Give your model its own table, if it is a different thing that happens to share a name.","",`If you truly mean to drop those columns, re-run with ${ALLOW_SHADOW_DROPS_ENV}=1.`].join(`
|
|
1
|
+
import{readFileSync}from"node:fs";import process from"node:process";export const ALLOW_SHADOW_DROPS_ENV="STACKS_ALLOW_SHADOW_DROPS";export function shadowDropsAllowed(){return process.env[ALLOW_SHADOW_DROPS_ENV]==="1"}export function declaredTable(file){let source;try{source=readFileSync(file,"utf8")}catch{return null}const match=/\btable\s*:\s*['"`]([^'"`]+)['"`]/.exec(source);return match?match[1]:null}const DROP_COLUMN=/^\s*ALTER\s+TABLE\s+["`']?([\w.$]+)["`']?\s+DROP\s+(?:COLUMN\s+)?["`']?([\w$]+)["`']?/i;export function findShadowedColumnDrops(statements,shadowed){if(shadowed.length===0||statements.length===0)return[];const byTable=new Map;for(const model of shadowed){const frameworkTable=declaredTable(model.frameworkFile),userTable=declaredTable(model.userFile);if(!frameworkTable||frameworkTable!==userTable)continue;byTable.set(frameworkTable,{model:model.name,columns:[]})}if(byTable.size===0)return[];for(const statement of statements){if(/^\s*ALTER\s+TABLE\s+["`']?[\w.$]+["`']?\s+DROP\s+(?:FOREIGN\s+KEY|PRIMARY\s+KEY|CONSTRAINT|INDEX|CHECK)\b/i.test(statement))continue;const match=DROP_COLUMN.exec(statement);if(!match)continue;const entry=byTable.get(match[1]);if(entry&&!entry.columns.includes(match[2]))entry.columns.push(match[2])}return[...byTable.entries()].filter(([,entry])=>entry.columns.length>0).map(([table,entry])=>({model:entry.model,table,columns:entry.columns}))}export function shadowedDropMessage(drops){const lines=drops.map((drop)=>{const columns=drop.columns.map((column)=>`\`${column}\``).join(", ");return` - \`app/Models/${drop.model}.ts\` overrides the framework's \`${drop.model}\` model on the \`${drop.table}\` table, and would drop ${columns}.`}),[first]=drops;return["Refusing to generate migrations: a model in app/Models/ would drop columns from a framework table.","",...lines,"","A userland model replaces a framework default entirely rather than extending it, so every column","the framework declared and yours does not is dropped - while the framework's own code goes on","reading them. The migration applies cleanly and nothing fails until a page that has always worked","stops finding a column.","","Two ways out:","",` 1. Start from the default and add to it: buddy publish:model ${first?.model??"<Name>"}`," That copies the framework's definition into app/Models/, so nothing is lost by writing yours.",""," 2. Give your model its own table, if it is a different thing that happens to share a name.","",`If you truly mean to drop those columns, re-run with ${ALLOW_SHADOW_DROPS_ENV}=1.`].join(`
|
|
2
2
|
`)}
|
package/dist/sql-helpers.d.ts
CHANGED
|
@@ -58,6 +58,34 @@ export declare function sqlDateTimeLiteral(value?: Date): string;
|
|
|
58
58
|
* rather than treating a bad value as the epoch.
|
|
59
59
|
*/
|
|
60
60
|
export declare function parseSqlDateTime(value: unknown): Date | null;
|
|
61
|
+
/**
|
|
62
|
+
* A timestamp a caller sent, in the form a datetime column stores.
|
|
63
|
+
*
|
|
64
|
+
* `schema.timestamp()` is how the framework's models declare a date-time, and
|
|
65
|
+
* the column it generates is a `datetime`. The validator, though, accepts Unix
|
|
66
|
+
* seconds as well as date strings, so a client may send either: the gift card
|
|
67
|
+
* dialog sends seconds. Written as is, a number landed in the column as an
|
|
68
|
+
* integer, which PostgreSQL refuses ("column is of type timestamp without time
|
|
69
|
+
* zone but expression is of type integer") and SQLite stores as a meaningless
|
|
70
|
+
* number in a text column (stacksjs/stacks#3006).
|
|
71
|
+
*
|
|
72
|
+
* A number, or a string of digits, is Unix time: seconds, as the validator
|
|
73
|
+
* defines it, or milliseconds when it is too large to be seconds (from 1e11,
|
|
74
|
+
* about the year 5138). A date string or a `Date` is read with
|
|
75
|
+
* {@link parseSqlDateTime}. All become {@link sqlDateTime}'s canonical literal.
|
|
76
|
+
* `null`, `undefined` and `''` pass through as `null`/`undefined` so an
|
|
77
|
+
* optional column can be cleared, and anything unreadable is returned
|
|
78
|
+
* unchanged for validation to report rather than silently replaced.
|
|
79
|
+
*/
|
|
80
|
+
export declare function sqlDateTimeInput(value: unknown): unknown;
|
|
81
|
+
/**
|
|
82
|
+
* Whether an attribute's validation rule gives it a DATETIME column.
|
|
83
|
+
*
|
|
84
|
+
* `timestamp()`, `timestampTz()` and `datetime()` all map to one. A value
|
|
85
|
+
* written there goes through `sqlDateTimeInput` first: MySQL refuses the ISO
|
|
86
|
+
* 8601 form factories and clients produce (stacksjs/stacks#3014).
|
|
87
|
+
*/
|
|
88
|
+
export declare function isDateTimeColumnRule(rule: unknown): boolean;
|
|
61
89
|
/**
|
|
62
90
|
* Read a `DATE` column back out as the `YYYY-MM-DD` it stores.
|
|
63
91
|
*
|
package/dist/sql-helpers.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{dialectCapabilities}from"./dialect";let lastSqlTimestampMs,lastSqlTimestamp="";export function sqlDateTime(value){if(value!==void 0)return value.toISOString().slice(0,-1);const now=Date.now();if(now!==lastSqlTimestampMs){lastSqlTimestamp=new Date(now).toISOString().slice(0,-1);lastSqlTimestampMs=now}return lastSqlTimestamp}export function sqlDateTimeLiteral(value){return`'${sqlDateTime(value)}'`}export function parseSqlDateTime(value){if(value===null||value===void 0)return null;if(value instanceof Date)return Number.isNaN(value.getTime())?null:value;if(typeof value==="number"){const parsed=new Date(value);return Number.isNaN(parsed.getTime())?null:parsed}if(typeof value!=="string")return null;const trimmed=value.trim();if(!trimmed)return null;let normalized=trimmed.replace(" ","T");if(!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized))normalized+="Z";const parsed=new Date(normalized);return Number.isNaN(parsed.getTime())?null:parsed}export function parseSqlDate(value){if(value===null||value===void 0)return null;const pad=(n,width=2)=>String(n).padStart(width,"0");if(value instanceof Date){if(Number.isNaN(value.getTime()))return null;return value.getHours()===0&&value.getMinutes()===0&&value.getSeconds()===0&&value.getMilliseconds()===0?`${pad(value.getFullYear(),4)}-${pad(value.getMonth()+1)}-${pad(value.getDate())}`:value.toISOString().slice(0,10)}if(typeof value!=="string")return null;const match=/^(\d{4}-\d{2}-\d{2})(?:$|[T ])/.exec(value.trim());if(!match)return null;const day=match[1],parsed=new Date(`${day}T00:00:00Z`);return!Number.isNaN(parsed.getTime())&&parsed.toISOString().slice(0,10)===day?day:null}export function sqlHelpers(driver,options={}){const caps=dialectCapabilities(driver,options),isPostgres=caps.wire==="postgres",isMysql=caps.wire==="mysql",isSqlite=caps.wire==="sqlite";return{driver,isPostgres,isMysql,isSqlite,now:isPostgres||isMysql?"NOW()":"datetime('now')",utcNow:isPostgres?"(now() AT TIME ZONE 'utc')":isMysql?"(UTC_TIMESTAMP)":"CURRENT_TIMESTAMP",boolTrue:isPostgres?"true":"1",boolFalse:isPostgres?"false":"0",autoIncrement:isPostgres?"SERIAL":"INTEGER",bigInteger:isSqlite?"INTEGER":"BIGINT",primaryKey:!caps.supportsAutoIncrement?"PRIMARY KEY":isPostgres?"PRIMARY KEY":isMysql?"PRIMARY KEY AUTO_INCREMENT":"PRIMARY KEY AUTOINCREMENT",pkColumn:!caps.supportsAutoIncrement?"id BIGINT NOT NULL PRIMARY KEY":isPostgres?"id SERIAL PRIMARY KEY":isMysql?"id INTEGER PRIMARY KEY AUTO_INCREMENT":"id INTEGER PRIMARY KEY AUTOINCREMENT",bigPkColumn:!caps.supportsAutoIncrement?"id BIGINT NOT NULL PRIMARY KEY":isPostgres?"id BIGSERIAL PRIMARY KEY":isMysql?"id BIGINT PRIMARY KEY AUTO_INCREMENT":"id INTEGER PRIMARY KEY AUTOINCREMENT",datetime:isMysql?"DATETIME":"TIMESTAMP",nullableTimestamp:isMysql?"DATETIME NULL":"TIMESTAMP",param(index){return isPostgres?`$${index}`:"?"},params(...values){if(isPostgres)return{sql:values.map((_,i)=>`$${i+1}`).join(", "),values};return{sql:values.map(()=>"?").join(", "),values}}}}
|
|
1
|
+
import{dialectCapabilities}from"./dialect";import{isDatetimeValidator,isTimestampTzValidator,isTimestampValidator}from"./validators";let lastSqlTimestampMs,lastSqlTimestamp="";export function sqlDateTime(value){if(value!==void 0)return value.toISOString().slice(0,-1);const now=Date.now();if(now!==lastSqlTimestampMs){lastSqlTimestamp=new Date(now).toISOString().slice(0,-1);lastSqlTimestampMs=now}return lastSqlTimestamp}export function sqlDateTimeLiteral(value){return`'${sqlDateTime(value)}'`}export function parseSqlDateTime(value){if(value===null||value===void 0)return null;if(value instanceof Date)return Number.isNaN(value.getTime())?null:value;if(typeof value==="number"){const parsed=new Date(value);return Number.isNaN(parsed.getTime())?null:parsed}if(typeof value!=="string")return null;const trimmed=value.trim();if(!trimmed)return null;let normalized=trimmed.replace(" ","T");if(!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized))normalized+="Z";const parsed=new Date(normalized);return Number.isNaN(parsed.getTime())?null:parsed}export function sqlDateTimeInput(value){if(value===void 0)return;if(value===null||value==="")return null;let date=null;if(value instanceof Date)date=Number.isNaN(value.getTime())?null:value;else if(typeof value==="number"||typeof value==="string"&&/^\s*\d+\s*$/.test(value)){const unix=Number(value);if(Number.isFinite(unix))date=new Date(unix>=100000000000?unix:unix*1000)}else if(typeof value==="string")date=parseSqlDateTime(value);return date&&!Number.isNaN(date.getTime())?sqlDateTime(date):value}export function isDateTimeColumnRule(rule){if(!rule||typeof rule!=="object")return!1;const validator=rule;return isTimestampValidator(validator)||isTimestampTzValidator(validator)||isDatetimeValidator(validator)}export function parseSqlDate(value){if(value===null||value===void 0)return null;const pad=(n,width=2)=>String(n).padStart(width,"0");if(value instanceof Date){if(Number.isNaN(value.getTime()))return null;return value.getHours()===0&&value.getMinutes()===0&&value.getSeconds()===0&&value.getMilliseconds()===0?`${pad(value.getFullYear(),4)}-${pad(value.getMonth()+1)}-${pad(value.getDate())}`:value.toISOString().slice(0,10)}if(typeof value!=="string")return null;const match=/^(\d{4}-\d{2}-\d{2})(?:$|[T ])/.exec(value.trim());if(!match)return null;const day=match[1],parsed=new Date(`${day}T00:00:00Z`);return!Number.isNaN(parsed.getTime())&&parsed.toISOString().slice(0,10)===day?day:null}export function sqlHelpers(driver,options={}){const caps=dialectCapabilities(driver,options),isPostgres=caps.wire==="postgres",isMysql=caps.wire==="mysql",isSqlite=caps.wire==="sqlite";return{driver,isPostgres,isMysql,isSqlite,now:isPostgres||isMysql?"NOW()":"datetime('now')",utcNow:isPostgres?"(now() AT TIME ZONE 'utc')":isMysql?"(UTC_TIMESTAMP)":"CURRENT_TIMESTAMP",boolTrue:isPostgres?"true":"1",boolFalse:isPostgres?"false":"0",autoIncrement:isPostgres?"SERIAL":"INTEGER",bigInteger:isSqlite?"INTEGER":"BIGINT",primaryKey:!caps.supportsAutoIncrement?"PRIMARY KEY":isPostgres?"PRIMARY KEY":isMysql?"PRIMARY KEY AUTO_INCREMENT":"PRIMARY KEY AUTOINCREMENT",pkColumn:!caps.supportsAutoIncrement?"id BIGINT NOT NULL PRIMARY KEY":isPostgres?"id SERIAL PRIMARY KEY":isMysql?"id INTEGER PRIMARY KEY AUTO_INCREMENT":"id INTEGER PRIMARY KEY AUTOINCREMENT",bigPkColumn:!caps.supportsAutoIncrement?"id BIGINT NOT NULL PRIMARY KEY":isPostgres?"id BIGSERIAL PRIMARY KEY":isMysql?"id BIGINT PRIMARY KEY AUTO_INCREMENT":"id INTEGER PRIMARY KEY AUTOINCREMENT",datetime:isMysql?"DATETIME":"TIMESTAMP",nullableTimestamp:isMysql?"DATETIME NULL":"TIMESTAMP",param(index){return isPostgres?`$${index}`:"?"},params(...values){if(isPostgres)return{sql:values.map((_,i)=>`$${i+1}`).join(", "),values};return{sql:values.map(()=>"?").join(", "),values}}}}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/database",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.320",
|
|
6
6
|
"description": "The Stacks database integration.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -66,26 +66,26 @@
|
|
|
66
66
|
"prepublishOnly": "bun run build"
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
|
-
"@stacksjs/config": "0.75.
|
|
70
|
-
"@stacksjs/env": "0.75.
|
|
71
|
-
"@stacksjs/error-handling": "0.75.
|
|
72
|
-
"@stacksjs/faker": "0.75.
|
|
73
|
-
"@stacksjs/features": "0.75.
|
|
74
|
-
"@stacksjs/logging": "0.75.
|
|
75
|
-
"@stacksjs/model-meta": "0.75.
|
|
76
|
-
"@stacksjs/path": "0.75.
|
|
77
|
-
"@stacksjs/query-builder": "0.75.
|
|
78
|
-
"@stacksjs/security": "0.75.
|
|
79
|
-
"@stacksjs/storage": "0.75.
|
|
80
|
-
"@stacksjs/strings": "0.75.
|
|
69
|
+
"@stacksjs/config": "0.75.320",
|
|
70
|
+
"@stacksjs/env": "0.75.320",
|
|
71
|
+
"@stacksjs/error-handling": "0.75.320",
|
|
72
|
+
"@stacksjs/faker": "0.75.320",
|
|
73
|
+
"@stacksjs/features": "0.75.320",
|
|
74
|
+
"@stacksjs/logging": "0.75.320",
|
|
75
|
+
"@stacksjs/model-meta": "0.75.320",
|
|
76
|
+
"@stacksjs/path": "0.75.320",
|
|
77
|
+
"@stacksjs/query-builder": "0.75.320",
|
|
78
|
+
"@stacksjs/security": "0.75.320",
|
|
79
|
+
"@stacksjs/storage": "0.75.320",
|
|
80
|
+
"@stacksjs/strings": "0.75.320",
|
|
81
81
|
"@stacksjs/ts-validation": "^0.5.6",
|
|
82
|
-
"bun-query-builder": "^0.3.
|
|
82
|
+
"bun-query-builder": "^0.3.22",
|
|
83
83
|
"dynamodb-tooling": "^0.3.6"
|
|
84
84
|
},
|
|
85
85
|
"devDependencies": {
|
|
86
|
-
"@stacksjs/cli": "0.75.
|
|
87
|
-
"@stacksjs/router": "0.75.
|
|
88
|
-
"@stacksjs/utils": "0.75.
|
|
86
|
+
"@stacksjs/cli": "0.75.320",
|
|
87
|
+
"@stacksjs/router": "0.75.320",
|
|
88
|
+
"@stacksjs/utils": "0.75.320",
|
|
89
89
|
"better-dx": "^0.2.24"
|
|
90
90
|
}
|
|
91
91
|
}
|