@stacksjs/database 0.72.86 → 0.72.91
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/migrations.d.ts +44 -3
- package/dist/migrations.js +3 -3
- package/dist/seeder.d.ts +1 -0
- package/dist/seeder.js +1 -1
- package/package.json +12 -12
package/dist/migrations.d.ts
CHANGED
|
@@ -149,12 +149,24 @@ export declare function ensureDatabaseReady(): Promise<void>;
|
|
|
149
149
|
/** Test seam: forget that the bootstrap already ran. */
|
|
150
150
|
export declare function resetDatabaseBootstrapCache(): void;
|
|
151
151
|
/**
|
|
152
|
-
* The table a
|
|
152
|
+
* The table a statement acts on, or null when it acts on none we can name.
|
|
153
153
|
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
154
|
+
* DDL was the whole list here, which meant a `UPDATE tags` read as "no table"
|
|
155
|
+
* and was kept by every caller that filters on this. Data statements are the
|
|
156
|
+
* ones a feature gate most needs to see: a disabled feature's CREATE is
|
|
157
|
+
* hidden, so anything that later writes to that table has nothing to write to.
|
|
156
158
|
*/
|
|
157
159
|
export declare function statementTable(statement: string): string | null;
|
|
160
|
+
/**
|
|
161
|
+
* Whether a statement names a table anywhere, not just as its target.
|
|
162
|
+
*
|
|
163
|
+
* The target alone is not enough. `0000000133` reads `FROM tags` inside a CTE
|
|
164
|
+
* before updating it, and a statement that reads a table which was never
|
|
165
|
+
* created fails exactly as loudly as one that writes to it. There is no case
|
|
166
|
+
* where naming a missing table succeeds, so matching a reference anywhere is
|
|
167
|
+
* the conservative direction.
|
|
168
|
+
*/
|
|
169
|
+
export declare function statementReferencesTable(statement: string, table: string): boolean;
|
|
158
170
|
/**
|
|
159
171
|
* Drop the statements that act on a table a disabled feature owns.
|
|
160
172
|
*
|
|
@@ -414,6 +426,31 @@ export declare function regenerateMigrationCorpus(options?: {
|
|
|
414
426
|
*/
|
|
415
427
|
onlyExistingTables?: boolean
|
|
416
428
|
}): Promise<Result<RegeneratedCorpus, Error>>;
|
|
429
|
+
/**
|
|
430
|
+
* The table a `CREATE TABLE` statement creates, lowercased, or undefined.
|
|
431
|
+
*
|
|
432
|
+
* Quoting varies by dialect and by whoever wrote the committed file, so all
|
|
433
|
+
* four spellings have to reach the same name.
|
|
434
|
+
*/
|
|
435
|
+
export declare function createdTableName(statement: string): string | undefined;
|
|
436
|
+
export declare function indexCommittedMigrations(fileContents: readonly string[]): CommittedMigrationIndex;
|
|
437
|
+
/**
|
|
438
|
+
* Whether a generated statement is already represented in the committed corpus.
|
|
439
|
+
*
|
|
440
|
+
* Text matching alone is too weak for `CREATE TABLE`. The generator's
|
|
441
|
+
* formatting does not have to agree with whatever wrote the committed file - a
|
|
442
|
+
* hand-authored migration, an older generator, one of the guarantee helpers -
|
|
443
|
+
* and a single differing space means the statement reads as new. The result is
|
|
444
|
+
* a SECOND `CREATE TABLE notification_deliveries` written next to the one the
|
|
445
|
+
* corpus already had, which is what `migrate:fresh` was leaving behind on a
|
|
446
|
+
* freshly scaffolded app: a failed migration, a half-built database, and
|
|
447
|
+
* fifteen untracked files to notice and delete (stacksjs/stacks#2323).
|
|
448
|
+
*
|
|
449
|
+
* So a `CREATE TABLE` is matched on the table it creates rather than on how it
|
|
450
|
+
* is written. Everything else still compares text, because an `ALTER` or an
|
|
451
|
+
* `UPDATE` is only redundant if it is genuinely the same statement.
|
|
452
|
+
*/
|
|
453
|
+
export declare function generatedStatementIsRedundant(statement: string, index: CommittedMigrationIndex): boolean;
|
|
417
454
|
/**
|
|
418
455
|
* Group generated SQL by the migration filename style the runner already
|
|
419
456
|
* uses for hand-written files: `create-<table>-table`,
|
|
@@ -481,6 +518,10 @@ export declare interface RegeneratedCorpus {
|
|
|
481
518
|
preservedOutOfScope: string[]
|
|
482
519
|
dir: string
|
|
483
520
|
}
|
|
521
|
+
export declare interface CommittedMigrationIndex {
|
|
522
|
+
sql: string
|
|
523
|
+
createdTables: Set<string>
|
|
524
|
+
}
|
|
484
525
|
declare interface GeneratedGroup {
|
|
485
526
|
label: string
|
|
486
527
|
statements: string[]
|
package/dist/migrations.js
CHANGED
|
@@ -10,7 +10,7 @@ ${missing.join(`
|
|
|
10
10
|
migration TEXT NOT NULL UNIQUE,
|
|
11
11
|
executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
12
12
|
)`);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}`)}}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)}
|
|
13
|
-
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(){if(databaseBootstrapped)return;await ensureDatabaseExists();databaseBootstrapped=!0}export function resetDatabaseBootstrapCache(){databaseBootstrapped=!1}async function hideDisabledFeatureMigrations(){const hidden=[];try{const{appModelClaimsTable,FEATURE_NAMES,migrationFeature,migrationTable}=await import("@stacksjs/buddy"),{feature:isFeatureEnabled}=await import("@stacksjs/config"),fs=await import("node:fs/promises"),migrationsDir=migrationDirectory();if(!existsSync(migrationsDir))return hidden;const disabledFeatures=new Set(FEATURE_NAMES.filter((f)=>!isFeatureEnabled(f)));if(disabledFeatures.size===0)return hidden;const files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql")),gatedTables=new Set;for(const file of files){const owner=migrationFeature(file);if(!owner||!disabledFeatures.has(owner))continue;const table=migrationTable(file);if(table&&appModelClaimsTable(table))continue;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:owner})}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}export function statementTable(statement){const 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];for(const pattern of patterns){const match=pattern.exec(
|
|
13
|
+
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(){if(databaseBootstrapped)return;await ensureDatabaseExists();databaseBootstrapped=!0}export function resetDatabaseBootstrapCache(){databaseBootstrapped=!1}async function hideDisabledFeatureMigrations(){const hidden=[];try{const{appModelClaimsTable,FEATURE_NAMES,migrationFeature,migrationTable}=await import("@stacksjs/buddy"),{feature:isFeatureEnabled}=await import("@stacksjs/config"),fs=await import("node:fs/promises"),migrationsDir=migrationDirectory();if(!existsSync(migrationsDir))return hidden;const disabledFeatures=new Set(FEATURE_NAMES.filter((f)=>!isFeatureEnabled(f)));if(disabledFeatures.size===0)return hidden;const files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql")),gatedTables=new Set;for(const file of files){const owner=migrationFeature(file);if(!owner||!disabledFeatures.has(owner))continue;const table=migrationTable(file);if(table&&appModelClaimsTable(table))continue;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:owner})}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)}export function withoutGatedStatements(sql,gated){if(gated.size===0)return sql;const statements=sql.split(";").map((s)=>s.trim()).filter(Boolean),kept=statements.filter((statement)=>{const table=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(`;
|
|
14
14
|
`)};
|
|
15
15
|
`}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(){try{const row=await db.selectFrom("migrations").select((eb)=>eb.fn.count("id").as("n")).executeTakeFirst();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]??"",stmts=sql.slice(header.length).split(";").map((s)=>s.trim()).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 "),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(`;
|
|
16
16
|
`)};
|
|
@@ -30,8 +30,8 @@ Create it yourself with: ${manualCreateHint(target)}`);if(result.created)log.su
|
|
|
30
30
|
`);export function isGeneratedMigration(dir,file){try{return readFileSync(join(dir,file),"utf8").slice(0,200).includes("@generated by `buddy migrate:regenerate`")}catch{return!1}}export function tablesOperatedOn(sql){const tables=new Set;for(const statement of sqlStatementsOf(sql)){const stmt=statement.trim(),direct=stmt.match(/^(?:CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?|ALTER\s+TABLE|DROP\s+TABLE(?:\s+IF\s+EXISTS)?|TRUNCATE\s+TABLE)\s+["'`]?(\w+)["'`]?/i);if(direct?.[1]){tables.add(direct[1].toLowerCase());continue}const index=stmt.match(/^CREATE\s+(?:UNIQUE\s+)?INDEX(?:\s+IF\s+NOT\s+EXISTS)?\s+\S+\s+ON\s+["'`]?(\w+)["'`]?/i);if(index?.[1])tables.add(index[1].toLowerCase())}return[...tables]}export function columnsDefinedByCreate(statement){const body=statement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?\w+["'`]?\s*\(([\s\S]*)\)\s*;?\s*$/i)?.[1];if(!body)return[];const parts=[];let depth=0,current="";for(const char of body){if(char==="(")depth++;if(char===")")depth--;if(char===","&&depth===0){parts.push(current);current="";continue}current+=char}parts.push(current);const constraint=/^\s*(?:PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK|CONSTRAINT|KEY|INDEX)\b/i;return parts.map((part)=>part.trim()).filter((part)=>part&&!constraint.test(part)).flatMap((part)=>part.match(/^["'`]?(\w+)["'`]?/)?.[1]??[])}export function columnsProducedByMigrations(dir,files,table){const columns=new Set,target=table.toLowerCase();for(const file of[...files].sort()){let content;try{content=readFileSync(join(dir,file),"utf8")}catch{continue}for(const statement of sqlStatementsOf(content)){const stmt=statement.trim();if(stmt.match(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i)?.[1]?.toLowerCase()===target){for(const column of columnsDefinedByCreate(stmt))columns.add(column.toLowerCase());continue}const added=stmt.match(/^ALTER\s+TABLE\s+["'`]?(\w+)["'`]?\s+ADD\s+(?:COLUMN\s+)?["'`]?(\w+)["'`]?/i);if(added?.[1]?.toLowerCase()===target&&added[2])columns.add(added[2].toLowerCase());const dropped=stmt.match(/^ALTER\s+TABLE\s+["'`]?(\w+)["'`]?\s+DROP\s+(?:COLUMN\s+)?["'`]?(\w+)["'`]?/i);if(dropped?.[1]?.toLowerCase()===target&&dropped[2])columns.delete(dropped[2].toLowerCase());const rebuilt=stmt.match(/^ALTER\s+TABLE\s+["'`]?_qb_tmp_(\w+)["'`]?\s+RENAME\s+TO\s+["'`]?(\w+)["'`]?/i);if(rebuilt?.[2]?.toLowerCase()===target){const temp=sqlStatementsOf(content).find((s)=>new RegExp(`^CREATE\\s+TABLE\\s+["'\`]?_qb_tmp_${rebuilt[1]}["'\`]?`,"i").test(s.trim()));if(temp){columns.clear();for(const column of columnsDefinedByCreate(temp))columns.add(column.toLowerCase())}}}}return columns}export function rootedTableCatchUpStatements(createStatement,existingColumns){const table=createStatement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i)?.[1];if(!table)return[];const body=createStatement.match(/\(([\s\S]*)\)\s*;?\s*$/)?.[1];if(!body)return[];const definitions=new Map;let depth=0,current="";const parts=[];for(const char of body){if(char==="(")depth++;if(char===")")depth--;if(char===","&&depth===0){parts.push(current);current="";continue}current+=char}parts.push(current);const constraint=/^\s*(?:PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK|CONSTRAINT|KEY|INDEX)\b/i;for(const raw of parts){const part=raw.trim();if(!part||constraint.test(part))continue;const name=part.match(/^["'`]?(\w+)["'`]?/)?.[1];if(name)definitions.set(name.toLowerCase(),part)}const statements=[];for(const[name,definition]of definitions){if(existingColumns.has(name))continue;if(/\b(?:PRIMARY\s+KEY|UNIQUE|AUTOINCREMENT)\b/i.test(definition))continue;const nullable=/\bNOT\s+NULL\b/i.test(definition)&&!/\bDEFAULT\b/i.test(definition)?definition.replace(/\s*\bNOT\s+NULL\b/i,""):definition;statements.push(`ALTER TABLE "${table}" ADD COLUMN ${nullable.trim()}`)}return statements}export function createdTablesOf(statements){const tables=new Set;for(const statement of statements){const match=statement.trim().match(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i);if(match?.[1])tables.add(match[1].toLowerCase())}return[...tables]}export function historicallyRootedTables(dir,files){const tables=new Set;for(const file of files){if(isGeneratedMigration(dir,file))continue;try{for(const statement of sqlStatementsOf(readFileSync(join(dir,file),"utf8"))){const match=statement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i);if(match?.[1])tables.add(match[1].toLowerCase())}}catch{}}return[...tables]}export function tablesDefinedByCorpus(dir,files){const tables=new Set;for(const file of files)try{for(const statement of sqlStatementsOf(readFileSync(join(dir,file),"utf8"))){const match=statement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i);if(match?.[1])tables.add(match[1].toLowerCase())}}catch{}return[...tables]}export function migrationTouchesRootedTable(dir,file,rootedTables){try{return tablesOperatedOn(readFileSync(join(dir,file),"utf8")).some((table)=>rootedTables.has(table.toLowerCase()))}catch{return!1}}export function allocateMigrationOrdinals(count,startAt,reserved){const ordinals=[];let cursor=startAt;while(ordinals.length<count){if(!reserved.has(cursor))ordinals.push(cursor);cursor+=1}return ordinals}export function migrationsOutsideCorpus(dir,files,corpusTables){const rebuilt=new Set(corpusTables.map((table)=>table.toLowerCase()));return files.filter((file)=>{let contents;try{contents=readFileSync(join(dir,file),"utf8")}catch{return!0}const touched=tablesOperatedOn(contents);if(touched.length===0)return!0;return touched.some((table)=>!rebuilt.has(table))})}function migrationOrdinal(file){const match=file.match(/^(\d+)/);return match?Number(match[1]):0}export async function regenerateMigrationCorpus(options={}){try{const dialect=options.dialect??getQbDialect();let requestedVitessSharded;if(dialect==="vitess")try{const{config,overridesReady}=await import("@stacksjs/config");await overridesReady;requestedVitessSharded=isVitessSharded(config?.database?.connections?.vitess?.sharded)}catch{requestedVitessSharded=isVitessSharded(dbConfig.connections.vitess.sharded)}configureQueryBuilder(dialect,requestedVitessSharded);const dir=options.dir??migrationDirectory(dialect),sources=resolveModelSources({forceStage:!0,...options.onlyExistingTables?{includeFrameworkDefaults:!0}:{}});if(!sources)return err(Error("No models found. Define models in app/Models, or ensure the framework defaults at storage/framework/defaults/app/Models are present."));try{writeFileSync(join(sources.dir,`.qb-migrations.${dialect}.json`),JSON.stringify({plan:{dialect,tables:[]}}))}catch{}const snapshotPath=join(resolveSnapshotDir(),`model-snapshot.${dialect}.json`),parkedSnapshot=`${snapshotPath}.regenerating`;let snapshotParked=!1;if(existsSync(snapshotPath))try{renameSync(snapshotPath,parkedSnapshot);snapshotParked=!0}catch{}let result;try{result=await qbGenerateMigration(sources.dir,{dialect,vitessSharded:requestedVitessSharded,dryRun:!0,full:!0})}finally{if(snapshotParked)try{renameSync(parkedSnapshot,snapshotPath)}catch{}}const statements=result.sqlStatements??[];if(statements.length===0)return err(Error(`The generator produced no SQL for dialect "${dialect}".`));const dangling=findDanglingTypeReferences(statements);if(dangling.length>0)return err(Error(`The generator emitted ${dangling.length} reference(s) to enum type(s) it never creates: ${dangling.slice(0,5).join(", ")}${dangling.length>5?`, +${dangling.length-5} more`:""}. Writing this corpus would fail partway through a migration. This is a bug in the migration generator, not in your models.`));let groups=groupGeneratedStatements(statements),existing=[];try{existing=readdirSync(dir).filter((f)=>f.endsWith(".sql")).sort()}catch{}let unrebuildable=[];if(options.onlyExistingTables){const corpusTables=new Set(tablesDefinedByCorpus(dir,existing));if(corpusTables.size===0)return err(Error(`No CREATE TABLE statements found in ${dir}, so there is nothing to regenerate in place. Run \`buddy migrate:regenerate <dialect>\` without --only-existing-tables to write a corpus from your models.`));const emitted=new Set(createdTablesOf(statements).map((table)=>table.toLowerCase()));unrebuildable=[...corpusTables].filter((table)=>!emitted.has(table)).sort();groups=groups.map((group)=>({...group,statements:group.statements.filter((statement)=>{const table=statementTable(statement);return table?corpusTables.has(table.toLowerCase()):!1})})).filter((group)=>group.statements.length>0);if(groups.length===0)return err(Error(`None of the ${corpusTables.size} table(s) in ${dir} have a model behind them, so none can be regenerated. Declare the models, or publish the framework ones with \`buddy publish model <Name>\`.`))}const rootedTables=new Set(options.replaceUnmarked||options.onlyExistingTables?[]:historicallyRootedTables(dir,existing)),outOfScope=new Set(migrationsOutsideCorpus(dir,existing,createdTablesOf(statements))),removed=(options.replaceUnmarked||options.onlyExistingTables?existing:existing.filter((file)=>isGeneratedMigration(dir,file))).filter((file)=>{return!outOfScope.has(file)&&!migrationTouchesRootedTable(dir,file,rootedTables)}),preserved=existing.filter((file)=>!removed.includes(file)),preservedOutOfScope=preserved.filter((file)=>outOfScope.has(file)),catchUp=[];for(const table of rootedTables){const create=groups.flatMap((group)=>group.statements).find((statement)=>statementTable(statement)===table&&/^\s*CREATE\s+TABLE\b/i.test(statement));if(!create)continue;const produced=columnsProducedByMigrations(dir,preserved,table);if(produced.size===0)continue;catchUp.push(...rootedTableCatchUpStatements(create,produced))}const writableGroups=groups.map((group)=>({...group,statements:group.statements.filter((statement)=>{const table=statementTable(statement);return!table||!rootedTables.has(table)})})).filter((group)=>group.statements.length>0).concat(catchUp.length>0?[{label:"alter-rooted-tables-columns",statements:catchUp}]:[]),historicalBoundary=existing.filter((file)=>!isGeneratedMigration(dir,file)).reduce((max,file)=>Math.max(max,migrationOrdinal(file)),0),startAt=rootedTables.size>0?historicalBoundary+1:preserved.reduce((max,file)=>Math.max(max,migrationOrdinal(file)),0)+1,reservedOrdinals=new Set(preserved.map(migrationOrdinal)),ordinals=allocateMigrationOrdinals(writableGroups.length,startAt,reservedOrdinals),files=writableGroups.map((group,index)=>({name:`${String(ordinals[index]).padStart(10,"0")}-${group.label}.sql`,statements:group.statements.length}));if(options.dryRun)return ok({dialect,models:sources.models.length,modelRoots:sources.roots,files,removed,preserved,preservedOutOfScope,unrebuildable,dir});mkdirSync(dir,{recursive:!0});for(const file of removed)unlinkSync(join(dir,file));writableGroups.forEach((group,index)=>{const body=`${group.statements.map((s)=>s.trim().replace(/;\s*$/,"")).join(`;
|
|
31
31
|
`)};
|
|
32
32
|
`;writeFileSync(join(dir,files[index].name),`${GENERATED_MIGRATION_MARKER}
|
|
33
|
-
${body}`)});saveMigrationSnapshot(result.plan,{dialect});return ok({dialect,models:sources.models.length,modelRoots:sources.roots,files,removed,preserved,preservedOutOfScope,unrebuildable,dir})}catch(error){return err(handleError("Migration regeneration failed",error))}}function
|
|
34
|
-
|
|
33
|
+
${body}`)});saveMigrationSnapshot(result.plan,{dialect});return ok({dialect,models:sources.models.length,modelRoots:sources.roots,files,removed,preserved,preservedOutOfScope,unrebuildable,dir})}catch(error){return err(handleError("Migration regeneration failed",error))}}export function createdTableName(statement){return/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`[]?([\w$]+)["'`\]]?/i.exec(statement)?.[1]?.toLowerCase()}export function indexCommittedMigrations(fileContents){const createdTables=new Set;for(const contents of fileContents)for(const statement of contents.split(";")){const table=createdTableName(statement);if(table)createdTables.add(table)}return{sql:normalizeSqlForComparison(fileContents.join(`
|
|
34
|
+
`)),createdTables}}function normalizeSqlForComparison(sql){return sql.replace(/\s+/g," ").trim()}export function generatedStatementIsRedundant(statement,index){const table=createdTableName(statement);if(table)return index.createdTables.has(table);return index.sql.includes(normalizeSqlForComparison(statement))}function persistGeneratedMigrations(sqlStatements){if(!sqlStatements?.length)return 0;const migrationsDir=migrationDirectory();try{require("node:fs").mkdirSync(migrationsDir,{recursive:!0})}catch{}const committed=[];try{for(const f of readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql")))committed.push(readFileSync(join(migrationsDir,f),"utf8"))}catch{}const index=indexCommittedMigrations(committed),groups=groupGeneratedStatements(sqlStatements);let written=0,cursor=nextMigrationNumber(migrationsDir);for(const group of groups){const fresh=group.statements.filter((stmt)=>{if(!generatedStatementIsRedundant(stmt,index))return!0;const table=createdTableName(stmt);if(table)log.debug(`[migration] Skipping generated CREATE for "${table}" - the committed corpus already creates it.`);return!1});if(fresh.length===0)continue;const filename=`${String(cursor).padStart(10,"0")}-${group.label}.sql`,filePath=join(migrationsDir,filename),body=`${fresh.map((s)=>s.trim().replace(/;\s*$/,"")).join(`;
|
|
35
35
|
`)};
|
|
36
36
|
`;writeFileSync(filePath,`${GENERATED_MIGRATION_MARKER}
|
|
37
37
|
${body}`);log.debug(`[migration] Wrote ${filename} (${fresh.length} stmt${fresh.length===1?"":"s"})`);written+=1;cursor+=1}return written}function normalizeCreateStatements(sqlStatements){const creates=[],constraints=[],passthrough=[];for(const raw of sqlStatements){const statement=raw.trim();if(!statement)continue;const create=statement.match(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/i);if(create?.[1]){creates.push({statement,table:create[1]});continue}const constraint=statement.match(/^ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+CONSTRAINT\s+([\s\S]+?);?$/i);if(constraint?.[1]&&constraint[2]){const references=[...constraint[2].matchAll(/REFERENCES\s+["`]?(\w+)["`]?/gi)].flatMap((match)=>match[1]?[match[1]]:[]);constraints.push({body:`CONSTRAINT ${constraint[2].replace(/;\s*$/,"")}`,references,statement,table:constraint[1]});continue}passthrough.push(statement)}if(creates.length===0)return sqlStatements.map((statement)=>statement.trim()).filter(Boolean);const createdTables=new Set(creates.map((create)=>create.table)),createOrder=new Map(creates.map((create,index)=>[create.table,index])),relevantConstraints=constraints.filter((constraint)=>createdTables.has(constraint.table)),unrelatedConstraints=constraints.filter((constraint)=>!createdTables.has(constraint.table)),dependencies=new Map(creates.map((create)=>[create.table,new Set(relevantConstraints.filter((constraint)=>constraint.table===create.table).flatMap((constraint)=>constraint.references).filter((reference)=>reference!==create.table&&createdTables.has(reference)))])),sortTables=(ignoredEdges=new Set)=>{const remaining=new Set(createdTables),sorted=[];while(remaining.size>0){const ready=[...remaining].filter((table)=>[...dependencies.get(table)??[]].every((dependency)=>{return!remaining.has(dependency)||ignoredEdges.has(`${table}->${dependency}`)})).sort((a,b)=>(createOrder.get(a)??0)-(createOrder.get(b)??0));if(ready.length===0)break;for(const table of ready){remaining.delete(table);sorted.push(table)}}return sorted},initiallySorted=sortTables(),cyclicTables=new Set([...createdTables].filter((table)=>!initiallySorted.includes(table))),deferred=relevantConstraints.filter((constraint)=>constraint.references.some((reference)=>{return reference!==constraint.table&&cyclicTables.has(constraint.table)&&cyclicTables.has(reference)})),deferredStatements=new Set(deferred.map((constraint)=>constraint.statement)),ignoredEdges=new Set(deferred.flatMap((constraint)=>constraint.references.map((reference)=>`${constraint.table}->${reference}`))),orderedTables=sortTables(ignoredEdges),byTable=new Map(creates.map((create)=>[create.table,create]));return[...orderedTables.map((table)=>{const create=byTable.get(table),inline=relevantConstraints.filter((constraint)=>constraint.table===table&&!deferredStatements.has(constraint.statement));if(inline.length===0)return create.statement;const closing=create.statement.lastIndexOf(")");if(closing<0)return create.statement;const before=create.statement.slice(0,closing).trimEnd(),after=create.statement.slice(closing);return`${before},
|
package/dist/seeder.d.ts
CHANGED
|
@@ -177,6 +177,7 @@ export declare interface SeederModel {
|
|
|
177
177
|
attributes: Record<string, Attribute>
|
|
178
178
|
model: Model
|
|
179
179
|
filePath: string
|
|
180
|
+
seedable: boolean
|
|
180
181
|
}
|
|
181
182
|
/** A parent a model belongs to, and the column that points at it. */
|
|
182
183
|
export declare interface ParentRelation {
|
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}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;loaded.push({file,displayFile,name:SeederClass?.name||fallbackName,order,SeederClass})}catch(error){loaded.push({file,displayFile,name:fallbackName,order:0,loadError:error})}}loaded.sort((a,b)=>a.order-b.order);for(const entry of loaded){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){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);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)continue;let count=10,fixtures=[];if(typeof useSeeder==="object"&&"count"in useSeeder){const opts=useSeeder;count=opts.count;fixtures=opts.fixtures??[]}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})}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);if(userModels.length>0&&!includeDefaults)return userModels;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(` User model "${model.name}" overrides default`);modelMap.set(model.name,model)}return Array.from(modelMap.values())}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}async function generateRecord(attributes,modelName,report=!1){const record={};for(const[fieldName,attr]of Object.entries(attributes)){const columnName=snakeCase(fieldName);let value;if(attr.factory&&typeof attr.factory==="function")try{value=attr.factory(faker)}catch(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;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 relationColumns(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;if(isAccountModel(parent)&&!options.allowProtected)continue;const rows=await existingRows(parentTable(parent));if(rows.length>0)pools.push({column,rows})}return chooseRelations(pools,model.count)}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})}async function generateRecords(model,options={}){const records=[],relations=await relationColumns(model,options);for(let i=0;i<model.count;i++){const record=await generateRecord(model.attributes,model.name,i===0),fixture=model.fixtures[i],relation=relations[i]??{},withRelations={...record};for(const[column,value]of Object.entries(relation))if(withRelations[column]==null)withRelations[column]=value;records.push(fixture?{...withRelations,...fixtureToColumns(fixture)}:withRelations)}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 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(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)){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);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=[];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})}}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}`)}}return{total:results.length,successful,failed,results,duration:Date.now()-startTime}}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;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{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}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;loaded.push({file,displayFile,name:SeederClass?.name||fallbackName,order,SeederClass})}catch(error){loaded.push({file,displayFile,name:fallbackName,order:0,loadError:error})}}loaded.sort((a,b)=>a.order-b.order);for(const entry of loaded){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=[];if(typeof useSeeder==="object"&&"count"in useSeeder){const opts=useSeeder;count=opts.count;fixtures=opts.fixtures??[]}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,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}async function generateRecord(attributes,modelName,report=!1){const record={};for(const[fieldName,attr]of Object.entries(attributes)){const columnName=snakeCase(fieldName);let value;if(attr.factory&&typeof attr.factory==="function")try{value=attr.factory(faker)}catch(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;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 relationColumns(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;if(isAccountModel(parent)&&!options.allowProtected)continue;const rows=await existingRows(parentTable(parent));if(rows.length>0)pools.push({column,rows})}return chooseRelations(pools,model.count)}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})}async function generateRecords(model,options={}){const records=[],relations=await relationColumns(model,options);for(let i=0;i<model.count;i++){const record=await generateRecord(model.attributes,model.name,i===0),fixture=model.fixtures[i],relation=relations[i]??{},withRelations={...record};for(const[column,value]of Object.entries(relation))if(withRelations[column]==null)withRelations[column]=value;records.push(fixture?{...withRelations,...fixtureToColumns(fixture)}:withRelations)}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 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(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)){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);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=[];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})}}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}`)}}return{total:results.length,successful,failed,results,duration:Date.now()-startTime}}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;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/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/database",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.72.
|
|
5
|
+
"version": "0.72.91",
|
|
6
6
|
"description": "The Stacks database integration.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -60,22 +60,22 @@
|
|
|
60
60
|
"prepublishOnly": "bun run build"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@stacksjs/faker": "^0.72.
|
|
64
|
-
"@stacksjs/query-builder": "^0.72.
|
|
63
|
+
"@stacksjs/faker": "^0.72.91",
|
|
64
|
+
"@stacksjs/query-builder": "^0.72.91",
|
|
65
65
|
"@stacksjs/ts-validation": "^0.5.6",
|
|
66
66
|
"bun-query-builder": "^0.2.53",
|
|
67
67
|
"dynamodb-tooling": "^0.3.2"
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|
|
70
|
-
"@stacksjs/cli": "0.72.
|
|
71
|
-
"@stacksjs/config": "0.72.
|
|
72
|
-
"@stacksjs/logging": "0.72.
|
|
73
|
-
"@stacksjs/router": "0.72.
|
|
70
|
+
"@stacksjs/cli": "0.72.91",
|
|
71
|
+
"@stacksjs/config": "0.72.91",
|
|
72
|
+
"@stacksjs/logging": "0.72.91",
|
|
73
|
+
"@stacksjs/router": "0.72.91",
|
|
74
74
|
"better-dx": "^0.2.24",
|
|
75
|
-
"@stacksjs/path": "0.72.
|
|
76
|
-
"@stacksjs/query-builder": "0.72.
|
|
77
|
-
"@stacksjs/storage": "0.72.
|
|
78
|
-
"@stacksjs/strings": "0.72.
|
|
79
|
-
"@stacksjs/utils": "0.72.
|
|
75
|
+
"@stacksjs/path": "0.72.91",
|
|
76
|
+
"@stacksjs/query-builder": "0.72.91",
|
|
77
|
+
"@stacksjs/storage": "0.72.91",
|
|
78
|
+
"@stacksjs/strings": "0.72.91",
|
|
79
|
+
"@stacksjs/utils": "0.72.91"
|
|
80
80
|
}
|
|
81
81
|
}
|