@stacksjs/database 0.75.317 → 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.
@@ -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: string
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
@@ -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&&gated.has(table))return!1;for(const candidate of gated)if(statementReferencesTable(statement,candidate))return!1;return!0});if(kept.length===statements.length)return sql;return kept.length===0?"":`${kept.join(`;
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};
@@ -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
  `)}
@@ -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
  *
@@ -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.317",
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.317",
70
- "@stacksjs/env": "0.75.317",
71
- "@stacksjs/error-handling": "0.75.317",
72
- "@stacksjs/faker": "0.75.317",
73
- "@stacksjs/features": "0.75.317",
74
- "@stacksjs/logging": "0.75.317",
75
- "@stacksjs/model-meta": "0.75.317",
76
- "@stacksjs/path": "0.75.317",
77
- "@stacksjs/query-builder": "0.75.317",
78
- "@stacksjs/security": "0.75.317",
79
- "@stacksjs/storage": "0.75.317",
80
- "@stacksjs/strings": "0.75.317",
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.9",
82
+ "bun-query-builder": "^0.3.22",
83
83
  "dynamodb-tooling": "^0.3.6"
84
84
  },
85
85
  "devDependencies": {
86
- "@stacksjs/cli": "0.75.317",
87
- "@stacksjs/router": "0.75.317",
88
- "@stacksjs/utils": "0.75.317",
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
  }