@stacksjs/database 0.75.261 → 0.75.263

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.
@@ -0,0 +1,27 @@
1
+ import type { ModelSource } from './model-sources';
2
+ /**
3
+ * Decide, per framework default route, whether its action can work in this app.
4
+ *
5
+ * `database.models.includeFrameworkDefaults: false` takes the framework's
6
+ * models out of an app's schema, and the default route bundles went on
7
+ * registering actions that query them. `POST /api/email/subscribe` reads
8
+ * `Subscriber`, so in an app without a `subscribers` table it answered 500 with
9
+ * nothing at boot, build or migrate saying why (stacksjs/stacks#2943).
10
+ *
11
+ * A route is withheld only when both are true: the model is out of the schema,
12
+ * AND the live database has no table for it. Schema alone is not enough, because
13
+ * an app that created those tables before #2220 still has them, and its routes
14
+ * work. When the table list cannot be read, nothing is withheld and the routes
15
+ * register exactly as they did before.
16
+ *
17
+ * The dependency is read from the framework action's own source: a model it
18
+ * imports from `@stacksjs/orm` or names as a global, or a missing table it names
19
+ * in quotes. An action the app overrides in `app/Actions` is the app's, and is
20
+ * always admitted.
21
+ */
22
+ export declare function frameworkRouteAdmission(options?: {
23
+ excluded?: ModelSource[]
24
+ liveTables?: () => Promise<Set<string>>
25
+ appActionsRoot?: string
26
+ frameworkActionsRoot?: string
27
+ }): Promise<((handler: string) => string | undefined) | undefined>;
@@ -0,0 +1 @@
1
+ import{existsSync,readFileSync}from"node:fs";import{path}from"@stacksjs/path";import{getLiveTables}from"./fk-audit";import{declaredTableName,excludedFrameworkModels}from"./model-sources";const BUILTIN_NAMES=new Set(["Error","Request","Response","URL","Map","Set","Object","Array","Number","String","Date","Promise","Symbol"]),LIVE_TABLES_TIMEOUT_MS=3000;export async function frameworkRouteAdmission(options={}){const excluded=options.excluded??excludedFrameworkModels();if(excluded.length===0)return;const live=await withTimeout((options.liveTables??getLiveTables)(),LIVE_TABLES_TIMEOUT_MS).catch(()=>{return});if(!live||live.size===0)return;const missing=excluded.map((model)=>({name:model.name,table:declaredTableName(model).toLowerCase()})).filter((model)=>!live.has(model.table));if(missing.length===0)return;const appActionsRoot=options.appActionsRoot??path.appPath("Actions"),frameworkActionsRoot=options.frameworkActionsRoot??path.frameworkPath("defaults/app/Actions"),transpiler=new Bun.Transpiler({loader:"ts",trimUnusedImports:!1}),verdicts=new Map;return(handler)=>{if(verdicts.has(handler))return verdicts.get(handler);const verdict=judge(handler);verdicts.set(handler,verdict);return verdict};function judge(handler){if(!handler.startsWith("Actions/"))return;const relative=handler.slice(8).replace(/\.ts$/,"");if(existsSync(`${appActionsRoot}/${relative}.ts`))return;const file=`${frameworkActionsRoot}/${relative}.ts`;let code;try{code=transpiler.transformSync(readFileSync(file,"utf8"))}catch{return}const fromOrm=ormImports(code),needs=missing.filter((model)=>fromOrm.has(model.name)||!BUILTIN_NAMES.has(model.name)&&new RegExp(`\\b${model.name}\\b`).test(code)||new RegExp(`['"\`]${escapeRegExp(model.table)}['"\`]`).test(code));if(needs.length===0)return;return needs.map((model)=>`${model.name} (table "${model.table}" does not exist)`).join(", ")}}function ormImports(code){const names=new Set;for(const match of code.matchAll(/import\s*\{([^}]*)\}\s*from\s*["']@stacksjs\/orm(?:\/[^"']*)?["']/g))for(const specifier of(match[1]??"").split(",")){const name=specifier.trim().replace(/^type\s+/,"").split(/\s+as\s+/)[0]?.trim();if(name)names.add(name)}return names}function escapeRegExp(value){return value.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function withTimeout(promise,ms){let cancel=()=>{};const timeout=new Promise((resolve)=>{const timer=setTimeout(resolve,ms);cancel=()=>clearTimeout(timer)});return Promise.race([promise,timeout]).finally(()=>cancel())}
package/dist/index.d.ts CHANGED
@@ -168,6 +168,7 @@ export * from './ensure-database';
168
168
  // Foreign-key audit (stacksjs/stacks#1916) — compare declared
169
169
  // `belongsTo` relationships against live FKs.
170
170
  export { auditForeignKeys, classifyDeclaredFKs, findFkOrphans, fkKey, getDeclaredFKs, getLiveFKs, getLiveTables } from './fk-audit';
171
+ export { frameworkRouteAdmission } from './framework-route-admission';
171
172
  // Schema drift audit — compare live column types against what the models
172
173
  // declare. `migrate` only tracks which files have run, so a database built from
173
174
  // a wrong migration set reports "up to date" forever while its columns differ.
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export{Database,createDatabase,createMysqlDatabase,createPostgresDatabase,createSqliteDatabase,createTursoDatabase}from"./database";export{detectDriver,driverDefaults,getConfigFromEnv,getConnectionString,mergeWithDefaults,validateDriverConfig}from"./driver-config";export*from"./utils";export*from"./row-lock";export{BUN_BROKEN_MYSQL_POOL_MESSAGE,BUN_BROKEN_POSTGRES_POOL_MESSAGE,getBrokenDatabasePools,isBrokenBunPoolError}from"./broken-pool";export*from"./types";export*from"./migrations";export*from"./framework-renames";export{setQueryTracker,logQuery}from"./query-logger";export{addColumnSafely,backfillInBatches,renameColumnSafely}from"./safe-migrations";export{matchedRows,mutationCount}from"./affected-rows";export*from"./seeder";export*from"./drivers";export*from"./custom";export*from"./auth-tables";export*from"./utc-defaults";export*from"./uuid-columns";export*from"./managed-columns";export*from"./relation-columns";export{ensureNotificationForeignKeys,migrateNotificationTables}from"./notification-tables";export{migrateRbacTables}from"./rbac-tables";export*from"./trait-tables";export*from"./datetime-columns";export*from"./dialect";export*from"./replicas";export*from"./ddl-constraints";export*from"./erd";export*from"./vschema";export*from"./sql-helpers";export*from"./defaults";export*from"./migration-dialect";export*from"./migration-path";export*from"./migration-ledger";export*from"./schema-align";export*from"./hand-written-migrations";export*from"./migration-names";export{comparableIndexKey,postgresIndexStatement}from"./index-shape";export*from"./model-sources";export*from"./package-migrations";export*from"./package-models";export*from"./shadowed-models";export*from"./ensure-database";export{auditForeignKeys,classifyDeclaredFKs,findFkOrphans,fkKey,getDeclaredFKs,getLiveFKs,getLiveTables}from"./fk-audit";export{auditSchemaDrift,formatSchemaDrift}from"./schema-drift";export{describeIgnoreRule,findIgnoredMigrations,formatIgnoredMigrations,trackIgnoredGeneratedMigrations}from"./ignored-migrations";export{auditUniqueIndexes,getDeclaredUniques,getLiveUniqueIndexes}from"./unique-audit";export{__flushAfterCommitNow,__pendingAfterCommitCount,enqueueAfterCommit,isInTransaction,runInTransactionScope}from"./transaction-context";export{createQueryBuilder,setConfig}from"@stacksjs/query-builder";export{createDynamo,dynamo,EntityQueryBuilder,generateKeyPattern,parseKeyPattern,buildKey,marshall,unmarshall}from"./drivers/dynamodb";
1
+ export{Database,createDatabase,createMysqlDatabase,createPostgresDatabase,createSqliteDatabase,createTursoDatabase}from"./database";export{detectDriver,driverDefaults,getConfigFromEnv,getConnectionString,mergeWithDefaults,validateDriverConfig}from"./driver-config";export*from"./utils";export*from"./row-lock";export{BUN_BROKEN_MYSQL_POOL_MESSAGE,BUN_BROKEN_POSTGRES_POOL_MESSAGE,getBrokenDatabasePools,isBrokenBunPoolError}from"./broken-pool";export*from"./types";export*from"./migrations";export*from"./framework-renames";export{setQueryTracker,logQuery}from"./query-logger";export{addColumnSafely,backfillInBatches,renameColumnSafely}from"./safe-migrations";export{matchedRows,mutationCount}from"./affected-rows";export*from"./seeder";export*from"./drivers";export*from"./custom";export*from"./auth-tables";export*from"./utc-defaults";export*from"./uuid-columns";export*from"./managed-columns";export*from"./relation-columns";export{ensureNotificationForeignKeys,migrateNotificationTables}from"./notification-tables";export{migrateRbacTables}from"./rbac-tables";export*from"./trait-tables";export*from"./datetime-columns";export*from"./dialect";export*from"./replicas";export*from"./ddl-constraints";export*from"./erd";export*from"./vschema";export*from"./sql-helpers";export*from"./defaults";export*from"./migration-dialect";export*from"./migration-path";export*from"./migration-ledger";export*from"./schema-align";export*from"./hand-written-migrations";export*from"./migration-names";export{comparableIndexKey,postgresIndexStatement}from"./index-shape";export*from"./model-sources";export*from"./package-migrations";export*from"./package-models";export*from"./shadowed-models";export*from"./ensure-database";export{auditForeignKeys,classifyDeclaredFKs,findFkOrphans,fkKey,getDeclaredFKs,getLiveFKs,getLiveTables}from"./fk-audit";export{frameworkRouteAdmission}from"./framework-route-admission";export{auditSchemaDrift,formatSchemaDrift}from"./schema-drift";export{describeIgnoreRule,findIgnoredMigrations,formatIgnoredMigrations,trackIgnoredGeneratedMigrations}from"./ignored-migrations";export{auditUniqueIndexes,getDeclaredUniques,getLiveUniqueIndexes}from"./unique-audit";export{__flushAfterCommitNow,__pendingAfterCommitCount,enqueueAfterCommit,isInTransaction,runInTransactionScope}from"./transaction-context";export{createQueryBuilder,setConfig}from"@stacksjs/query-builder";export{createDynamo,dynamo,EntityQueryBuilder,generateKeyPattern,parseKeyPattern,buildKey,marshall,unmarshall}from"./drivers/dynamodb";
@@ -344,6 +344,20 @@ export declare function previewPendingMigrations(options?: GenerateMigrationsOpt
344
344
  export declare function pendingMigrationOperations(options?: GenerateMigrationsOptions): Promise<MigrationOperation[]>;
345
345
  export declare function preserveMigrationPlanTableOrder(next: MigrationPlan, previous?: MigrationPlan): MigrationPlan;
346
346
  export declare function generateMigrations(options?: GenerateMigrationsOptions): Promise<Result<string, Error>>;
347
+ /**
348
+ * The dialects that keep a migration corpus besides the attached one, by the
349
+ * snapshot each keeps. The candidates for `--dialect all`.
350
+ */
351
+ export declare function siblingMigrationDialects(): Promise<GenerationDialect[]>;
352
+ /**
353
+ * The sibling dialects whose snapshot no longer matches the models: a
354
+ * `generate:migrations --dialect <name>` for each would write something.
355
+ *
356
+ * Reported after an ordinary generate, which only moves the attached dialect.
357
+ * Without it the other corpus fell behind silently, and every local gate stayed
358
+ * green while production was missing the tables (stacksjs/stacks#2949).
359
+ */
360
+ export declare function dialectsBehindModels(): Promise<GenerationDialect[]>;
347
361
  /**
348
362
  * Write generated SQL to the active dialect's corpus so the runner picks it up.
349
363
  * Returns the number of files written.
@@ -645,6 +659,8 @@ export declare function withTableDropsLast(groups: GeneratedGroup[], previousPla
645
659
  * Generate fresh migrations (full regeneration, ignoring previous state)
646
660
  */
647
661
  export declare function generateMigrations2(): Promise<Result<string, Error>>;
662
+ /** The dialects whose migrations can be generated, as `--dialect` spells them. */
663
+ export declare const MIGRATION_DIALECTS: readonly ['sqlite', 'mysql', 'singlestore', 'vitess', 'postgres'];
648
664
  export declare interface PreprocessSqliteOptions {
649
665
  schema?: import('bun:sqlite').Database | null
650
666
  recordMigrations?: (record: string[], replay: string[]) => void
@@ -664,6 +680,7 @@ export declare interface PreprocessSqliteOptions {
664
680
  export declare interface GenerateMigrationsOptions {
665
681
  applyRenames?: boolean
666
682
  fromDb?: boolean
683
+ dialect?: GenerationDialect
667
684
  }
668
685
  declare interface MigrationPlanColumn {
669
686
  name?: string
@@ -748,6 +765,7 @@ export declare interface MigrationResult {
748
765
  direction: 'Up' | 'Down'
749
766
  status: 'Success' | 'Error' | 'NotExecuted'
750
767
  }
768
+ export type GenerationDialect = typeof MIGRATION_DIALECTS[number];
751
769
  export { enumTypesCreatedBy, sqlStatementsOf } from './sql-statements';
752
770
  /**
753
771
  * Header stamped onto every file `regenerateMigrationCorpus` writes.
@@ -1,4 +1,4 @@
1
- var {require}=import.meta;import{existsSync,mkdirSync,readdirSync,readFileSync,renameSync,unlinkSync,writeFileSync}from"node:fs";import{isPackageMigration,stagePackageMigrations}from"./package-migrations";import{nextFreeMigrationOrdinal,nextMigrationOrdinal,reservedMigrationOrdinals,retiredMigrationNames}from"./migration-names";import{basename,dirname,isAbsolute,join,resolve}from"node:path";import{log as _log}from"@stacksjs/logging";const log={info:(...args)=>typeof _log?.info==="function"?_log.info(...args):console.log(...args),success:(msg)=>typeof _log?.success==="function"?_log.success(msg):console.log(msg),warn:(msg)=>typeof _log?.warn==="function"?_log.warn(msg):console.warn(msg),error:(...args)=>typeof _log?.error==="function"?_log.error(...args):console.error(...args),debug:(...args)=>typeof _log?.debug==="function"?_log.debug(...args):console.debug(...args)};import{err,handleError,ok}from"@stacksjs/error-handling";import{path}from"@stacksjs/path";import{defaultModelsPath}from"./seeder";import{createQueryBuilder,executeMigrationWithReport as qbExecuteMigrationWithReport,generateDiffOperations,generateMigration as qbGenerateMigration,resetDatabase as qbResetDatabase,config as qbConfig,saveMigrationSnapshot,setConfig}from"@stacksjs/query-builder";import{db,ensureDatabaseConfigLoaded,qbSnapshotDir,resetDatabaseConnection,resolvedDatabaseConfig}from"./utils";import{classifyConnectionError,createDatabase,describeTarget,manualCreateHint,probeTargetDatabase,resolveConnectionTarget}from"./ensure-database";import{declaredModelTables,resolveModelSources}from"./model-sources";import{applicableFrameworkRenames,applyFrameworkRenamesToDatabase,applyFrameworkRenamesToPlan}from"./framework-renames";import{findShadowedColumnDrops,shadowDropsAllowed,shadowedDropMessage}from"./shadowed-models";import{frameworkManagedColumns,withoutManagedColumnDrops,withoutManagedColumnDropSql}from"./managed-columns";import{acquireLibsqlMigrationLock,acquireMigrationLock}from"./migration-lock";import{auditMissingTables}from"./migration-ledger";import{relativeMigrationDirectory,resolveMigrationDirectory}from"./migration-path";import{ensureNotificationForeignKeys,migrateNotificationTables,notificationTablePreflight}from"./notification-tables";import{traitTableNames,traitTablePrerequisiteSql}from"./trait-tables";import{AUTH_TABLES}from"./auth-tables";import{enumTypesCreatedBy,guardedBlockStatements,SQL_IDENTIFIER,sqlStatementsOf}from"./sql-statements";import{indexShape}from"./index-shape";import{corpusUniqueIndexes,hasIndex,restorableMissingUniqueIndexes,restoreDroppedUniqueIndexes,uniqueIndexIsRestorable}from"./declared-unique-indexes";import{ensureUuidColumns}from"./uuid-columns";import{sqlHelpers}from"./sql-helpers";import{GENERATED_MIGRATION_MARKER,isGeneratedMigration}from"./generated-migration-marker";import{trackIgnoredGeneratedMigrations}from"./ignored-migrations";export{enumTypesCreatedBy,sqlStatementsOf}from"./sql-statements";import{isLibsqlDriver,isVitessSharded,toSqlIntrospectionDialect}from"./dialect";const dbConfig={get default(){return resolvedDatabaseConfig().driver},get connections(){const{connections}=resolvedDatabaseConfig();return{...connections,turso:connections.turso??{url:"",authToken:""}}}};function usesLibsql(){return isLibsqlDriver(getDriver())}function sqliteDatabasePath(){const configured=dbConfig.connections.sqlite.database||"stacks.db";return isAbsolute(configured)?configured:join(process.cwd(),configured)}function getDriver(){return dbConfig.default||"sqlite"}function getDialect(){const driver=getDriver();if(driver==="sqlite"||driver==="mysql"||driver==="vitess"||driver==="postgres")return driver;if(driver==="turso")return"sqlite";if(driver==="singlestore")return"mysql";if(driver==="dynamodb")throw Error("[database] DB_CONNECTION=dynamodb is not compatible with the SQL migration runner. DynamoDB has no schema-migration concept - use the entity-style `dynamo.entity(...)` API from @stacksjs/database directly. To run SQL migrations, set DB_CONNECTION to one of: sqlite, turso, mysql, singlestore, vitess, postgres.");throw Error(`[database] Unknown DB_CONNECTION "${driver}". Allowed values: sqlite, turso, mysql, singlestore, vitess, postgres, dynamodb.`)}function getQbDialect(){return getDriver()==="singlestore"?"singlestore":getDialect()}function migrationDirectory(dialect=getQbDialect()){return resolveMigrationDirectory(dialect,{configured:qbConfig.migrationDir,snapshotDir:qbSnapshotDir()})}export async function activeMigrationDirectory(){await ensureDatabaseConfigLoaded();return migrationDirectory(getDialect())}function configureQueryBuilder(targetDialect=getQbDialect(),vitessSharded){const connectionConfig=dbConfig.connections[targetDialect],libsql=targetDialect==="sqlite"&&usesLibsql()?{url:dbConfig.connections.turso.url||void 0,authToken:dbConfig.connections.turso.authToken||void 0}:{url:void 0,authToken:void 0};if(targetDialect==="sqlite"&&usesLibsql()&&!libsql.url)throw Error("[database] DB_CONNECTION=turso needs TURSO_DATABASE_URL (libsql://<db>-<org>.turso.io, or http://127.0.0.1:8080 for `turso dev`).");setConfig({dialect:targetDialect,vitess:{sharded:isVitessSharded(vitessSharded??connectionConfig?.sharded)},verbose:!1,snapshotDir:qbSnapshotDir(),migrationDir:relativeMigrationDirectory(migrationDirectory(targetDialect)),database:{database:connectionConfig?.name||connectionConfig?.database||"stacks",host:connectionConfig?.host||"localhost",port:connectionConfig?.port||(targetDialect==="postgres"?5432:targetDialect==="vitess"?15306:targetDialect==="mysql"||targetDialect==="singlestore"?3306:0),username:connectionConfig?.username||"",password:connectionConfig?.password||"",...libsql}});resetDatabaseConnection()}export function prepareMigrationModelsDir(){const sources=resolveModelSources(),excludedTables=sources?.excludedTables??[];return{modelsDir:sources?.dir??path.userModelsPath(),skip:!sources,excludedTables,protectedTables:[...new Set([...excludedTables,...traitTableNames()])]}}function generatedStatementTables(statement){return statement.split(";").map((part)=>part.trim()).filter(Boolean).flatMap((part)=>{const table=rebuiltTable(part)??statementTable(part),index=/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?\w+["`]?\s+ON\s+["`]?(\w+)["`]?/i.exec(part)?.[1],dropped=DROP_TABLE_RE.exec(part)?.[1];return[table,index,dropped].filter((name)=>Boolean(name)).map((name)=>name.toLowerCase())})}export function withoutRuntimeOwnedTableSql(statements,owned=AUTH_TABLES){const ownedSet=new Set(owned.map((table)=>table.toLowerCase())),removed=[];return{statements:statements.filter((statement)=>{if(!generatedStatementTables(statement).some((table)=>ownedSet.has(table)))return!0;removed.push(statement);return!1}),removed}}const DROP_TABLE_RE=/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i;export function withoutProtectedTableDropSql(statements,protectedTables,operations){if(protectedTables.length===0)return{statements,removed:[]};const excluded=new Set(protectedTables.map((table)=>table.toLowerCase())),normalize=(sql)=>sql.replace(/\s+/g," ").trim().replace(/;$/,""),dropped=new Set(operations.filter((op)=>op.kind==="drop_table"&&excluded.has(op.table.toLowerCase())).filter((op)=>Boolean(op.sql)).map((op)=>normalize(op.sql))),removed=[];return{statements:statements.filter((statement)=>{if(dropped.has(normalize(statement))){removed.push(statement);return!1}const match=statement.match(DROP_TABLE_RE);if(match?.[1]&&excluded.has(match[1].toLowerCase())){removed.push(statement);return!1}return!0}),removed}}export function orderPostgresColumnTypeChanges(sql){const lines=sql.split(`
1
+ var {require}=import.meta;import{existsSync,mkdirSync,readdirSync,readFileSync,renameSync,rmSync,unlinkSync,writeFileSync}from"node:fs";import{isPackageMigration,stagePackageMigrations}from"./package-migrations";import{nextFreeMigrationOrdinal,nextMigrationOrdinal,reservedMigrationOrdinals,retiredMigrationNames}from"./migration-names";import{basename,dirname,isAbsolute,join,resolve}from"node:path";import{log as _log}from"@stacksjs/logging";const log={info:(...args)=>typeof _log?.info==="function"?_log.info(...args):console.log(...args),success:(msg)=>typeof _log?.success==="function"?_log.success(msg):console.log(msg),warn:(msg)=>typeof _log?.warn==="function"?_log.warn(msg):console.warn(msg),error:(...args)=>typeof _log?.error==="function"?_log.error(...args):console.error(...args),debug:(...args)=>typeof _log?.debug==="function"?_log.debug(...args):console.debug(...args)};import{err,handleError,ok}from"@stacksjs/error-handling";import{path}from"@stacksjs/path";import{defaultModelsPath}from"./seeder";import{createQueryBuilder,executeMigrationWithReport as qbExecuteMigrationWithReport,generateDiffOperations,generateMigration as qbGenerateMigration,resetDatabase as qbResetDatabase,config as qbConfig,saveMigrationSnapshot,setConfig}from"@stacksjs/query-builder";import{db,ensureDatabaseConfigLoaded,qbSnapshotDir,resetDatabaseConnection,resolvedDatabaseConfig}from"./utils";import{classifyConnectionError,createDatabase,describeTarget,manualCreateHint,probeTargetDatabase,resolveConnectionTarget}from"./ensure-database";import{declaredModelTables,resolveModelSources}from"./model-sources";import{applicableFrameworkRenames,applyFrameworkRenamesToDatabase,applyFrameworkRenamesToPlan}from"./framework-renames";import{findShadowedColumnDrops,shadowDropsAllowed,shadowedDropMessage}from"./shadowed-models";import{frameworkManagedColumns,withoutManagedColumnDrops,withoutManagedColumnDropSql}from"./managed-columns";import{acquireLibsqlMigrationLock,acquireMigrationLock}from"./migration-lock";import{auditMissingTables}from"./migration-ledger";import{relativeMigrationDirectory,resolveMigrationDirectory}from"./migration-path";import{ensureNotificationForeignKeys,migrateNotificationTables,notificationTablePreflight}from"./notification-tables";import{traitTableNames,traitTablePrerequisiteSql}from"./trait-tables";import{AUTH_TABLES}from"./auth-tables";import{enumTypesCreatedBy,guardedBlockStatements,SQL_IDENTIFIER,sqlStatementsOf}from"./sql-statements";import{indexShape}from"./index-shape";import{corpusUniqueIndexes,hasIndex,restorableMissingUniqueIndexes,restoreDroppedUniqueIndexes,uniqueIndexIsRestorable}from"./declared-unique-indexes";import{ensureUuidColumns}from"./uuid-columns";import{sqlHelpers}from"./sql-helpers";import{GENERATED_MIGRATION_MARKER,isGeneratedMigration}from"./generated-migration-marker";import{trackIgnoredGeneratedMigrations}from"./ignored-migrations";export{enumTypesCreatedBy,sqlStatementsOf}from"./sql-statements";import{isLibsqlDriver,isVitessSharded,toSqlIntrospectionDialect}from"./dialect";const dbConfig={get default(){return resolvedDatabaseConfig().driver},get connections(){const{connections}=resolvedDatabaseConfig();return{...connections,turso:connections.turso??{url:"",authToken:""}}}};function usesLibsql(){return isLibsqlDriver(getDriver())}function sqliteDatabasePath(){const configured=dbConfig.connections.sqlite.database||"stacks.db";return isAbsolute(configured)?configured:join(process.cwd(),configured)}function getDriver(){return generationDialect??attachedDriver()}function attachedDriver(){return dbConfig.default||"sqlite"}export const MIGRATION_DIALECTS=["sqlite","mysql","singlestore","vitess","postgres"];let generationDialect;function getDialect(){const driver=getDriver();if(driver==="sqlite"||driver==="mysql"||driver==="vitess"||driver==="postgres")return driver;if(driver==="turso")return"sqlite";if(driver==="singlestore")return"mysql";if(driver==="dynamodb")throw Error("[database] DB_CONNECTION=dynamodb is not compatible with the SQL migration runner. DynamoDB has no schema-migration concept - use the entity-style `dynamo.entity(...)` API from @stacksjs/database directly. To run SQL migrations, set DB_CONNECTION to one of: sqlite, turso, mysql, singlestore, vitess, postgres.");throw Error(`[database] Unknown DB_CONNECTION "${driver}". Allowed values: sqlite, turso, mysql, singlestore, vitess, postgres, dynamodb.`)}function getQbDialect(){return getDriver()==="singlestore"?"singlestore":getDialect()}function migrationDirectory(dialect=getQbDialect()){const committed=resolveMigrationDirectory(dialect,{configured:userMigrationDir(),snapshotDir:qbSnapshotDir()});return runCorpus&&committed===runCorpus.committed?runCorpus.staged:committed}let runCorpus;function stageRunCorpus(dialect){const committed=migrationDirectory(dialect),staged=join(path.frameworkRuntimePath(),"migrations",`run-${process.pid}-${Date.now()}`);mkdirSync(staged,{recursive:!0});for(const file of listCorpusEntries(committed))writeFileSync(join(staged,file),readFileSync(join(committed,file)));runCorpus={committed,staged};return runCorpus}function releaseRunCorpus(run){if(!run)return;if(runCorpus?.staged===run.staged)runCorpus=void 0;try{rmSync(run.staged,{recursive:!0,force:!0})}catch{}}function listCorpusEntries(dir){try{return readdirSync(dir).filter((file)=>file.endsWith(".sql")||file.endsWith(".sql.unsupported"))}catch{return[]}}let capturedMigrationDir;function userMigrationDir(){capturedMigrationDir??={value:qbConfig.migrationDir};return capturedMigrationDir.value}export async function activeMigrationDirectory(){await ensureDatabaseConfigLoaded();return migrationDirectory(getDialect())}function configureQueryBuilder(targetDialect=getQbDialect(),vitessSharded){const connectionConfig=dbConfig.connections[targetDialect],libsql=targetDialect==="sqlite"&&usesLibsql()?{url:dbConfig.connections.turso.url||void 0,authToken:dbConfig.connections.turso.authToken||void 0}:{url:void 0,authToken:void 0};if(targetDialect==="sqlite"&&usesLibsql()&&!libsql.url)throw Error("[database] DB_CONNECTION=turso needs TURSO_DATABASE_URL (libsql://<db>-<org>.turso.io, or http://127.0.0.1:8080 for `turso dev`).");setConfig({dialect:targetDialect,vitess:{sharded:isVitessSharded(vitessSharded??connectionConfig?.sharded)},verbose:!1,snapshotDir:qbSnapshotDir(),migrationDir:relativeMigrationDirectory(migrationDirectory(targetDialect)),database:{database:connectionConfig?.name||connectionConfig?.database||"stacks",host:connectionConfig?.host||"localhost",port:connectionConfig?.port||(targetDialect==="postgres"?5432:targetDialect==="vitess"?15306:targetDialect==="mysql"||targetDialect==="singlestore"?3306:0),username:connectionConfig?.username||"",password:connectionConfig?.password||"",...libsql}});resetDatabaseConnection()}export function prepareMigrationModelsDir(){const sources=resolveModelSources(),excludedTables=sources?.excludedTables??[];return{modelsDir:sources?.dir??path.userModelsPath(),skip:!sources,excludedTables,protectedTables:[...new Set([...excludedTables,...traitTableNames()])]}}function generatedStatementTables(statement){return statement.split(";").map((part)=>part.trim()).filter(Boolean).flatMap((part)=>{const table=rebuiltTable(part)??statementTable(part),index=/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?\w+["`]?\s+ON\s+["`]?(\w+)["`]?/i.exec(part)?.[1],dropped=DROP_TABLE_RE.exec(part)?.[1];return[table,index,dropped].filter((name)=>Boolean(name)).map((name)=>name.toLowerCase())})}export function withoutRuntimeOwnedTableSql(statements,owned=AUTH_TABLES){const ownedSet=new Set(owned.map((table)=>table.toLowerCase())),removed=[];return{statements:statements.filter((statement)=>{if(!generatedStatementTables(statement).some((table)=>ownedSet.has(table)))return!0;removed.push(statement);return!1}),removed}}const DROP_TABLE_RE=/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i;export function withoutProtectedTableDropSql(statements,protectedTables,operations){if(protectedTables.length===0)return{statements,removed:[]};const excluded=new Set(protectedTables.map((table)=>table.toLowerCase())),normalize=(sql)=>sql.replace(/\s+/g," ").trim().replace(/;$/,""),dropped=new Set(operations.filter((op)=>op.kind==="drop_table"&&excluded.has(op.table.toLowerCase())).filter((op)=>Boolean(op.sql)).map((op)=>normalize(op.sql))),removed=[];return{statements:statements.filter((statement)=>{if(dropped.has(normalize(statement))){removed.push(statement);return!1}const match=statement.match(DROP_TABLE_RE);if(match?.[1]&&excluded.has(match[1].toLowerCase())){removed.push(statement);return!1}return!0}),removed}}export function orderPostgresColumnTypeChanges(sql){const lines=sql.split(`
2
2
  `),output=[];for(const line of lines){const match=/^(\s*)ALTER\s+TABLE\s+("?[\w.]+"?)\s+ALTER\s+COLUMN\s+("?[\w]+"?)\s+TYPE\s/i.exec(line);if(match){const[,indent,table,column]=match,drop=`${indent}ALTER TABLE ${table} ALTER COLUMN ${column} DROP DEFAULT;`;if((output.length>0?output[output.length-1].trim():"")!==drop.trim())output.push(drop)}output.push(line)}return output.join(`
3
3
  `)}export function guardPostgresEnumTypes(sql){return assertPostgresEnumMembers(wrapPostgresEnumTypes(sql))}function wrapPostgresEnumTypes(sql){return sql.replace(/CREATE\s+TYPE\s+("?[\w.]+"?)\s+AS\s+ENUM\s*\(([^)]*)\)/gi,(match,name,members,offset,whole)=>{if(/\bBEGIN\s*$/i.test(whole.slice(Math.max(0,offset-40),offset)))return match;return`DO $stacks$ BEGIN CREATE TYPE ${name} AS ENUM (${members}); EXCEPTION WHEN duplicate_object THEN null; END $stacks$`})}function assertPostgresEnumMembers(sql){return sql.replace(/DO \$stacks\$[\s\S]*?END \$stacks\$;?/g,(block)=>{const created=/CREATE\s+TYPE\s+("?[\w.]+"?)\s+AS\s+ENUM\s*\(([^)]*)\)/i.exec(block);if(!created)return block;const[,name,members]=created,missing=enumMembers(members).map((member)=>`ALTER TYPE ${name} ADD VALUE IF NOT EXISTS ${member};`).filter((statement)=>!sql.includes(statement));if(missing.length===0)return block;return`${block.endsWith(";")?block:`${block};`}
4
4
  ${missing.join(`
@@ -13,12 +13,12 @@ Create it yourself with: ${manualCreateHint(target)}`);if(result.created)log.su
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
  `)};
16
- `}export function normalizePostgresMigrationSql(sql){if(!(/\bADD\s+(?:COLUMN|CONSTRAINT)\b/i.test(sql)||/\bDROP\s+COLUMN\b/i.test(sql)||/\bCREATE\s+TYPE\b/i.test(sql)||/\bALTER\s+COLUMN\b[^\n]*\bTYPE\b/i.test(sql)))return sql;return orderPostgresColumnTypeChanges(guardPostgresEnumTypes(idempotentSql(sql)))}function makeMigrationsIdempotent(){const rewritten=[],migrationsDir=migrationDirectory("postgres");let files;try{files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql"))}catch{return}for(const f of files){const p=join(migrationsDir,f);let sql;try{sql=readFileSync(p,"utf8")}catch{continue}const next=normalizePostgresMigrationSql(sql);if(next!==sql)try{writeFileSync(p,next);rewritten.push(f)}catch{}}if(rewritten.length>0)log.warn(`[migration] Rewrote ${rewritten.length} migration file(s) on disk to be idempotent: ${rewritten.slice(0,3).join(", ")}${rewritten.length>3?`, +${rewritten.length-3} more`:""}. These files are tracked in git, so this shows up as a working-tree change.`)}async function loadLibsqlSchemaMirror(){const{Database}=require("bun:sqlite"),mirror=new Database(":memory:");mirror.exec("PRAGMA foreign_keys = OFF");const objects=await db.unsafe("SELECT type, name, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 WHEN 'view' THEN 2 ELSE 3 END, rowid").execute();for(const object of objects)try{mirror.exec(object.sql)}catch(error){log.debug(`[migration] libSQL schema mirror skipped ${object.type} ${object.name}: ${error instanceof Error?error.message:String(error)}`)}if(objects.some((object)=>object.type==="table"&&object.name==="migrations")){const recorded=await db.unsafe("SELECT migration FROM migrations").execute(),insert=mirror.prepare("INSERT OR IGNORE INTO migrations (migration) VALUES (?)");for(const row of recorded)try{insert.run(row.migration)}catch{}}return mirror}async function recordLibsqlMigrations(record,replay){if(record.length===0&&replay.length===0)return;try{await db.unsafe(`CREATE TABLE IF NOT EXISTS migrations (
16
+ `}export function normalizePostgresMigrationSql(sql){if(!(/\bADD\s+(?:COLUMN|CONSTRAINT)\b/i.test(sql)||/\bDROP\s+COLUMN\b/i.test(sql)||/\bCREATE\s+TYPE\b/i.test(sql)||/\bALTER\s+COLUMN\b[^\n]*\bTYPE\b/i.test(sql)))return sql;return orderPostgresColumnTypeChanges(guardPostgresEnumTypes(idempotentSql(sql)))}function makeMigrationsIdempotent(){const rewritten=[],migrationsDir=migrationDirectory("postgres");let files;try{files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql"))}catch{return}for(const f of files){const p=join(migrationsDir,f);let sql;try{sql=readFileSync(p,"utf8")}catch{continue}const next=normalizePostgresMigrationSql(sql);if(next!==sql)try{writeFileSync(p,next);rewritten.push(f)}catch{}}if(rewritten.length>0)log.debug(`[migration] Made ${rewritten.length} migration(s) idempotent for this run: ${rewritten.slice(0,3).join(", ")}${rewritten.length>3?`, +${rewritten.length-3} more`:""}`)}async function loadLibsqlSchemaMirror(){const{Database}=require("bun:sqlite"),mirror=new Database(":memory:");mirror.exec("PRAGMA foreign_keys = OFF");const objects=await db.unsafe("SELECT type, name, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 WHEN 'view' THEN 2 ELSE 3 END, rowid").execute();for(const object of objects)try{mirror.exec(object.sql)}catch(error){log.debug(`[migration] libSQL schema mirror skipped ${object.type} ${object.name}: ${error instanceof Error?error.message:String(error)}`)}if(objects.some((object)=>object.type==="table"&&object.name==="migrations")){const recorded=await db.unsafe("SELECT migration FROM migrations").execute(),insert=mirror.prepare("INSERT OR IGNORE INTO migrations (migration) VALUES (?)");for(const row of recorded)try{insert.run(row.migration)}catch{}}return mirror}async function recordLibsqlMigrations(record,replay){if(record.length===0&&replay.length===0)return;try{await db.unsafe(`CREATE TABLE IF NOT EXISTS migrations (
17
17
  id INTEGER PRIMARY KEY AUTOINCREMENT,
18
18
  migration TEXT NOT NULL UNIQUE,
19
19
  executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
20
- )`).execute();for(const migration of record)await db.unsafe("INSERT OR IGNORE INTO migrations (migration) VALUES (?)",[migration]).execute();for(const migration of replay)await db.unsafe("DELETE FROM migrations WHERE migration = ?",[migration]).execute()}catch(e){log.debug(`[migration] Could not record dropped migrations as executed: ${e}`)}}async function preprocessLibsqlMigrations(){const mirror=await loadLibsqlSchemaMirror();let ledger={record:[],replay:[]};try{const quarantined=preprocessSqliteMigrations({schema:mirror,recordMigrations:(record,replay)=>{ledger={record,replay}}});await recordLibsqlMigrations(ledger.record,ledger.replay);return quarantined}finally{mirror.close()}}async function restoreUniqueIndexesAfterBatch(corpusDir){if(usesLibsql()){const mirror=await loadLibsqlSchemaMirror();let missing;try{missing=restorableMissingUniqueIndexes(mirror,corpusDir)}finally{mirror.close()}for(const index of missing){try{await db.unsafe(index.statement).execute()}catch(error){const detail=error instanceof Error?error.message:String(error);throw Error(`Could not restore unique index "${index.name}" on "${index.table}" (declared in ${index.file}), which a later table rebuild dropped: ${detail}`)}log.info(`Restored unique index ${index.name} on ${index.table} (declared in ${index.file}, dropped by a later table rebuild)`)}return}const dbPath=sqliteDatabasePath();if(!existsSync(dbPath))return;const{Database}=require("bun:sqlite"),liveDb=new Database(dbPath);try{liveDb.exec("PRAGMA busy_timeout = 5000");for(const index of restoreDroppedUniqueIndexes(liveDb,corpusDir))log.info(`Restored unique index ${index.name} on ${index.table} (declared in ${index.file}, dropped by a later table rebuild)`)}finally{liveDb.close()}}export async function runDatabaseMigration(){await ensureDatabaseConfigLoaded();const startedAt=Date.now(),hidden=await hideDisabledFeatureMigrations();let quarantinedMigrations=[],lockHandle=null;try{log.debug("Migrating database...");await ensureDatabaseReady();configureQueryBuilder();const dialect=getDialect();if(usesLibsql())lockHandle=await acquireLibsqlMigrationLock(db);else{const lockDb=dialect==="sqlite"?null:createQueryBuilder();lockHandle=await acquireMigrationLock(dialect,lockDb)}try{const{packageMigrationRoots}=await import("@stacksjs/config"),staged=stagePackageMigrations({roots:packageMigrationRoots(),corpusDir:migrationDirectory(dialect)});if(staged.length>0){const packages=[...new Set(staged.map((s)=>s.package))].join(", ");log.info(`Staged ${staged.length} migration(s) from ${packages}`)}}catch(error){log.warn(`Could not stage package migrations: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite"){quarantinedMigrations=usesLibsql()?await preprocessLibsqlMigrations():preprocessSqliteMigrations();hidden.push(...quarantinedMigrations)}else if(dialect==="postgres")makeMigrationsIdempotent();const corpusDir=migrationDirectory(dialect),migrationsDir=migrationDirectory();let migrationSql="";try{migrationSql=readdirSync(migrationsDir).filter((file)=>file.endsWith(".sql")).sort().map((file)=>readFileSync(join(migrationsDir,file),"utf8")).join(`
21
- `)}catch{}if(migrationSql){for(const statement of traitTablePrerequisiteSql(migrationSql,sqlHelpers(dialect)))await db.unsafe(statement).execute();const preflight=notificationTablePreflight(migrationSql);if(preflight.length>0){const prepared=await migrateNotificationTables({tables:preflight});if(!prepared.success)throw Error(prepared.error||"Failed to prepare notification tables before migrations")}}const appliedBefore=await countAppliedMigrations();if(dialect==="sqlite"&&appliedBefore>0)await ensureUuidColumns(sqlHelpers(dialect));await catchUpFrameworkRenames();log.debug(`[migration] Running migrations from: ${corpusDir}`);const strict=process.env.STACKS_MIGRATE_STRICT==="1";await qbExecuteMigrationWithReport(corpusDir,strict?{strict:!0}:{auditDrift:!1});const appliedAfter=await countAppliedMigrations();if(dialect==="sqlite"&&appliedAfter>appliedBefore)await ensureUuidColumns(sqlHelpers(dialect));await catchUpFrameworkRenames();if(dialect==="sqlite")await restoreUniqueIndexesAfterBatch(corpusDir);const notificationTables=await migrateNotificationTables();if(!notificationTables.success)throw Error(notificationTables.error||"Failed to prepare notification tables");await ensureNotificationForeignKeys();const appliedCount=Math.max(0,appliedAfter-appliedBefore);await writeMigrateMarker(appliedCount);let ledgerRows=null;try{const rows=await db.unsafe("SELECT migration FROM migrations").execute();ledgerRows=(Array.isArray(rows)?rows:[]).map((row)=>String(row.migration??""))}catch(error){const message=error instanceof Error?error.message:String(error);if(/no such table|does not exist|doesn't exist/i.test(message))ledgerRows=[];else log.warn(`[migration] Could not read the migrations table to confirm every file was applied: ${message}`)}const stillPending=ledgerRows?unrecordedMigrationFiles(listCorpusFiles(corpusDir),ledgerRows):[];if(stillPending.length>0&&process.env.STACKS_ALLOW_PENDING_MIGRATIONS!=="1")return err(Error(`Applied ${appliedCount} migration${appliedCount===1?"":"s"}, but ${stillPending.length} migration file${stillPending.length===1?" is":"s are"} still not recorded in the migrations table after the run: ${stillPending.slice(0,5).join(", ")}${stillPending.length>5?`, +${stillPending.length-5} more`:""}. The runner did not apply ${stillPending.length===1?"it":"them"}, so the schema is behind the corpus. Run \`./buddy migrate:status\` to see what the database actually has. Set STACKS_ALLOW_PENDING_MIGRATIONS=1 to proceed anyway.`));log.debug(`Database migration completed in ${Date.now()-startedAt}ms (applied ${appliedCount}).`);if(quarantinedMigrations.length>0&&process.env.STACKS_ALLOW_PENDING_MIGRATIONS!=="1"){const names=quarantinedMigrations.map((q)=>basename(q.original));return err(Error(`Applied ${appliedCount} migration${appliedCount===1?"":"s"}, but ${names.length} could not run on SQLite and ${names.length===1?"is":"are"} still pending: ${names.join(", ")}. ${names.length===1?"It mixes":"They mix"} statements SQLite cannot execute with statements it can, so splitting them into separate migrations lets the runnable half apply. Set STACKS_ALLOW_PENDING_MIGRATIONS=1 to proceed anyway.`))}return ok(appliedCount===0?"Nothing to migrate.":`Applied ${appliedCount} migration${appliedCount===1?"":"s"}.`)}catch(error){const detail=error instanceof Error?error.message:String(error);log.error(`[migration] Failed after ${Date.now()-startedAt}ms: ${detail}`);if(/UNIQUE constraint failed/i.test(detail)){const viaIndex=/index\s+'/i.test(detail);log.info("[migration] This is a data conflict, not a schema one - `migrate:fresh` replays the same statements against the same rows and fails the same way. Clear or de-duplicate the offending rows first.");if(viaIndex)log.info("[migration] The error names an index rather than a column, which SQLite only does for an expression index or a table rebuild - so the conflict is arising while rows are being copied, not from a bare CREATE UNIQUE INDEX.")}else log.info("[migration] Run `./buddy migrate:fresh` to drop and recreate the schema if state is partial.");return err(handleError("Migration failed",error))}finally{if(lockHandle)try{await lockHandle.release()}catch{}await restoreHiddenMigrations(hidden)}}function listCorpusFiles(dir){try{return readdirSync(dir).filter((file)=>file.endsWith(".sql"))}catch{return[]}}export async function pendingMigrationFiles(){await ensureDatabaseConfigLoaded();configureQueryBuilder();const dir=migrationDirectory(getDialect()),gated=new Set((await featureGatedMigrationFiles(dir)).map((entry)=>entry.file));let ledger=[];try{const rows=await db.unsafe("SELECT migration FROM migrations").execute();ledger=(Array.isArray(rows)?rows:[]).map((row)=>String(row.migration??""))}catch(error){const message=error instanceof Error?error.message:String(error);if(!/no such table|does not exist|doesn't exist/i.test(message))throw error}return unrecordedMigrationFiles(listCorpusFiles(dir).filter((file)=>!gated.has(file)),ledger)}export async function missingRecordedTables(){await ensureDatabaseConfigLoaded();configureQueryBuilder();const dialect=getDialect(),dir=migrationDirectory(dialect),ledgerDialect=dialect==="vitess"?"mysql":dialect,{supported,missing,live}=await auditMissingTables({dir,dialect:ledgerDialect});if(!supported||missing.length===0)return[];const renamed=new Map;for(const rename of frameworkRenamesForThisApp())for(const table of rename.tables)renamed.set(table.from.toLowerCase(),table.to.toLowerCase());return missing.filter((table)=>{const seen=new Set([table]);for(let current=renamed.get(table);current&&!seen.has(current);current=renamed.get(current)){if(live.has(current))return!1;seen.add(current)}return!0})}export function unrecordedMigrationFiles(diskFiles,ledger){const recorded=new Set(ledger);return diskFiles.filter((file)=>!recorded.has(file)).sort()}const FRAMEWORK_TABLES=["oauth_refresh_tokens","oauth_access_tokens","oauth_clients","passkeys","passkey_ceremonies","failed_jobs","jobs","notifications","password_reset_tokens"];export async function resetDatabase(){await ensureDatabaseConfigLoaded();try{await ensureDatabaseReady();configureQueryBuilder();const modelsDir=path.userModelsPath(),dialect=getDialect();await dropFrameworkTables(dialect);if(existsSync(modelsDir))await qbResetDatabase(modelsDir,{dialect,preserveMigrationState:!0});else log.debug(`No models directory at ${modelsDir}; skipping model table drops.`);const defaultsDir=defaultModelsPath();if(existsSync(defaultsDir))await qbResetDatabase(defaultsDir,{dialect,preserveMigrationState:!0});else log.debug(`No framework default models directory at ${defaultsDir}; skipping.`);if(dialect==="postgres")await dropOrphanedEnumTypes();return ok("All tables dropped successfully!")}catch(error){return err(handleError("Database reset failed",error))}}async function dropOrphanedEnumTypes(){try{const raw=await db.unsafe(`
20
+ )`).execute();for(const migration of record)await db.unsafe("INSERT OR IGNORE INTO migrations (migration) VALUES (?)",[migration]).execute();for(const migration of replay)await db.unsafe("DELETE FROM migrations WHERE migration = ?",[migration]).execute()}catch(e){log.debug(`[migration] Could not record dropped migrations as executed: ${e}`)}}async function preprocessLibsqlMigrations(){const mirror=await loadLibsqlSchemaMirror();let ledger={record:[],replay:[]};try{const quarantined=preprocessSqliteMigrations({schema:mirror,recordMigrations:(record,replay)=>{ledger={record,replay}}});await recordLibsqlMigrations(ledger.record,ledger.replay);return quarantined}finally{mirror.close()}}async function restoreUniqueIndexesAfterBatch(corpusDir){if(usesLibsql()){const mirror=await loadLibsqlSchemaMirror();let missing;try{missing=restorableMissingUniqueIndexes(mirror,corpusDir)}finally{mirror.close()}for(const index of missing){try{await db.unsafe(index.statement).execute()}catch(error){const detail=error instanceof Error?error.message:String(error);throw Error(`Could not restore unique index "${index.name}" on "${index.table}" (declared in ${index.file}), which a later table rebuild dropped: ${detail}`)}log.info(`Restored unique index ${index.name} on ${index.table} (declared in ${index.file}, dropped by a later table rebuild)`)}return}const dbPath=sqliteDatabasePath();if(!existsSync(dbPath))return;const{Database}=require("bun:sqlite"),liveDb=new Database(dbPath);try{liveDb.exec("PRAGMA busy_timeout = 5000");for(const index of restoreDroppedUniqueIndexes(liveDb,corpusDir))log.info(`Restored unique index ${index.name} on ${index.table} (declared in ${index.file}, dropped by a later table rebuild)`)}finally{liveDb.close()}}export async function runDatabaseMigration(){await ensureDatabaseConfigLoaded();const startedAt=Date.now();configureQueryBuilder();const run=stageRunCorpus(getDialect()),hidden=await hideDisabledFeatureMigrations();let quarantinedMigrations=[],lockHandle=null;try{log.debug("Migrating database...");await ensureDatabaseReady();configureQueryBuilder();const dialect=getDialect();if(usesLibsql())lockHandle=await acquireLibsqlMigrationLock(db);else{const lockDb=dialect==="sqlite"?null:createQueryBuilder();lockHandle=await acquireMigrationLock(dialect,lockDb)}try{const{packageMigrationRoots}=await import("@stacksjs/config"),staged=stagePackageMigrations({roots:packageMigrationRoots(),corpusDir:migrationDirectory(dialect)});if(staged.length>0){const packages=[...new Set(staged.map((s)=>s.package))].join(", ");log.info(`Staged ${staged.length} migration(s) from ${packages}`)}}catch(error){log.warn(`Could not stage package migrations: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite"){quarantinedMigrations=usesLibsql()?await preprocessLibsqlMigrations():preprocessSqliteMigrations();hidden.push(...quarantinedMigrations)}else if(dialect==="postgres")makeMigrationsIdempotent();const corpusDir=migrationDirectory(dialect),migrationsDir=migrationDirectory();let migrationSql="";try{migrationSql=readdirSync(migrationsDir).filter((file)=>file.endsWith(".sql")).sort().map((file)=>readFileSync(join(migrationsDir,file),"utf8")).join(`
21
+ `)}catch{}if(migrationSql){for(const statement of traitTablePrerequisiteSql(migrationSql,sqlHelpers(dialect)))await db.unsafe(statement).execute();const preflight=notificationTablePreflight(migrationSql);if(preflight.length>0){const prepared=await migrateNotificationTables({tables:preflight});if(!prepared.success)throw Error(prepared.error||"Failed to prepare notification tables before migrations")}}const appliedBefore=await countAppliedMigrations();if(dialect==="sqlite"&&appliedBefore>0)await ensureUuidColumns(sqlHelpers(dialect));await catchUpFrameworkRenames();log.debug(`[migration] Running migrations from: ${corpusDir}`);const strict=process.env.STACKS_MIGRATE_STRICT==="1";await qbExecuteMigrationWithReport(corpusDir,strict?{strict:!0}:{auditDrift:!1});const appliedAfter=await countAppliedMigrations();if(dialect==="sqlite"&&appliedAfter>appliedBefore)await ensureUuidColumns(sqlHelpers(dialect));await catchUpFrameworkRenames();if(dialect==="sqlite")await restoreUniqueIndexesAfterBatch(corpusDir);const notificationTables=await migrateNotificationTables();if(!notificationTables.success)throw Error(notificationTables.error||"Failed to prepare notification tables");await ensureNotificationForeignKeys();const appliedCount=Math.max(0,appliedAfter-appliedBefore);await writeMigrateMarker(appliedCount);let ledgerRows=null;try{const rows=await db.unsafe("SELECT migration FROM migrations").execute();ledgerRows=(Array.isArray(rows)?rows:[]).map((row)=>String(row.migration??""))}catch(error){const message=error instanceof Error?error.message:String(error);if(/no such table|does not exist|doesn't exist/i.test(message))ledgerRows=[];else log.warn(`[migration] Could not read the migrations table to confirm every file was applied: ${message}`)}const stillPending=ledgerRows?unrecordedMigrationFiles(listCorpusFiles(corpusDir),ledgerRows):[];if(stillPending.length>0&&process.env.STACKS_ALLOW_PENDING_MIGRATIONS!=="1")return err(Error(`Applied ${appliedCount} migration${appliedCount===1?"":"s"}, but ${stillPending.length} migration file${stillPending.length===1?" is":"s are"} still not recorded in the migrations table after the run: ${stillPending.slice(0,5).join(", ")}${stillPending.length>5?`, +${stillPending.length-5} more`:""}. The runner did not apply ${stillPending.length===1?"it":"them"}, so the schema is behind the corpus. Run \`./buddy migrate:status\` to see what the database actually has. Set STACKS_ALLOW_PENDING_MIGRATIONS=1 to proceed anyway.`));log.debug(`Database migration completed in ${Date.now()-startedAt}ms (applied ${appliedCount}).`);if(quarantinedMigrations.length>0&&process.env.STACKS_ALLOW_PENDING_MIGRATIONS!=="1"){const names=quarantinedMigrations.map((q)=>basename(q.original));return err(Error(`Applied ${appliedCount} migration${appliedCount===1?"":"s"}, but ${names.length} could not run on SQLite and ${names.length===1?"is":"are"} still pending: ${names.join(", ")}. ${names.length===1?"It mixes":"They mix"} statements SQLite cannot execute with statements it can, so splitting them into separate migrations lets the runnable half apply. Set STACKS_ALLOW_PENDING_MIGRATIONS=1 to proceed anyway.`))}return ok(appliedCount===0?"Nothing to migrate.":`Applied ${appliedCount} migration${appliedCount===1?"":"s"}.`)}catch(error){const detail=error instanceof Error?error.message:String(error);log.error(`[migration] Failed after ${Date.now()-startedAt}ms: ${detail}`);if(/UNIQUE constraint failed/i.test(detail)){const viaIndex=/index\s+'/i.test(detail);log.info("[migration] This is a data conflict, not a schema one - `migrate:fresh` replays the same statements against the same rows and fails the same way. Clear or de-duplicate the offending rows first.");if(viaIndex)log.info("[migration] The error names an index rather than a column, which SQLite only does for an expression index or a table rebuild - so the conflict is arising while rows are being copied, not from a bare CREATE UNIQUE INDEX.")}else log.info("[migration] Run `./buddy migrate:fresh` to drop and recreate the schema if state is partial.");return err(handleError("Migration failed",error))}finally{if(lockHandle)try{await lockHandle.release()}catch{}await restoreHiddenMigrations(hidden);releaseRunCorpus(run)}}function listCorpusFiles(dir){try{return readdirSync(dir).filter((file)=>file.endsWith(".sql"))}catch{return[]}}export async function pendingMigrationFiles(){await ensureDatabaseConfigLoaded();configureQueryBuilder();const dir=migrationDirectory(getDialect()),gated=new Set((await featureGatedMigrationFiles(dir)).map((entry)=>entry.file));let ledger=[];try{const rows=await db.unsafe("SELECT migration FROM migrations").execute();ledger=(Array.isArray(rows)?rows:[]).map((row)=>String(row.migration??""))}catch(error){const message=error instanceof Error?error.message:String(error);if(!/no such table|does not exist|doesn't exist/i.test(message))throw error}return unrecordedMigrationFiles(listCorpusFiles(dir).filter((file)=>!gated.has(file)),ledger)}export async function missingRecordedTables(){await ensureDatabaseConfigLoaded();configureQueryBuilder();const dialect=getDialect(),dir=migrationDirectory(dialect),ledgerDialect=dialect==="vitess"?"mysql":dialect,{supported,missing,live}=await auditMissingTables({dir,dialect:ledgerDialect});if(!supported||missing.length===0)return[];const renamed=new Map;for(const rename of frameworkRenamesForThisApp())for(const table of rename.tables)renamed.set(table.from.toLowerCase(),table.to.toLowerCase());return missing.filter((table)=>{const seen=new Set([table]);for(let current=renamed.get(table);current&&!seen.has(current);current=renamed.get(current)){if(live.has(current))return!1;seen.add(current)}return!0})}export function unrecordedMigrationFiles(diskFiles,ledger){const recorded=new Set(ledger);return diskFiles.filter((file)=>!recorded.has(file)).sort()}const FRAMEWORK_TABLES=["oauth_refresh_tokens","oauth_access_tokens","oauth_clients","passkeys","passkey_ceremonies","failed_jobs","jobs","notifications","password_reset_tokens"];export async function resetDatabase(){await ensureDatabaseConfigLoaded();try{await ensureDatabaseReady();configureQueryBuilder();const modelsDir=path.userModelsPath(),dialect=getDialect();await dropFrameworkTables(dialect);if(existsSync(modelsDir))await qbResetDatabase(modelsDir,{dialect,preserveMigrationState:!0});else log.debug(`No models directory at ${modelsDir}; skipping model table drops.`);const defaultsDir=defaultModelsPath();if(existsSync(defaultsDir))await qbResetDatabase(defaultsDir,{dialect,preserveMigrationState:!0});else log.debug(`No framework default models directory at ${defaultsDir}; skipping.`);if(dialect==="postgres")await dropOrphanedEnumTypes();return ok("All tables dropped successfully!")}catch(error){return err(handleError("Database reset failed",error))}}async function dropOrphanedEnumTypes(){try{const raw=await db.unsafe(`
22
22
  SELECT t.typname AS name
23
23
  FROM pg_type t
24
24
  JOIN pg_namespace n ON n.oid = t.typnamespace
@@ -29,11 +29,11 @@ Create it yourself with: ${manualCreateHint(target)}`);if(result.created)log.su
29
29
  JOIN pg_class c ON c.oid = a.attrelid
30
30
  WHERE a.atttypid = t.oid AND c.relkind = 'r' AND NOT a.attisdropped
31
31
  )
32
- `).execute(),names=(Array.isArray(raw)?raw:raw?.rows??[]).map((row)=>String(row.name??row.typname??"")).filter(Boolean);for(const name of names)try{await db.unsafe(`DROP TYPE IF EXISTS "${name}" CASCADE`).execute();log.debug(`Dropped orphaned enum type: ${name}`)}catch(error){log.warn(`Could not drop enum type ${name}: ${error instanceof Error?error.message:String(error)}`)}}catch(error){log.warn(`Could not list enum types to drop: ${error instanceof Error?error.message:String(error)}`)}}async function dropFrameworkTables(dialect){if(dialect==="mysql"||dialect==="vitess")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 0").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = OFF").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}for(const tableName of FRAMEWORK_TABLES)try{let dropSql;if(dialect==="postgres")dropSql=`DROP TABLE IF EXISTS "${tableName}" CASCADE`;else if(dialect==="mysql"||dialect==="vitess")dropSql=`DROP TABLE IF EXISTS \`${tableName}\``;else dropSql=`DROP TABLE IF EXISTS "${tableName}"`;log.info(`Dropping framework table: ${tableName}`);await db.unsafe(dropSql).execute();log.info(`Dropped framework table: ${tableName}`)}catch(error){const kind=classifyConnectionError(error);if(kind==="missing-database"||kind==="missing-role"||kind==="auth-failed"||kind==="server-unreachable"||kind==="timeout")throw error;log.warn(`Could not drop table ${tableName}: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="mysql"||dialect==="vitess")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 1").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = ON").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}}function frameworkRenamesForThisApp(){return applicableFrameworkRenames(declaredModelTables())}function describeRenames(renames){return renames.map((rename)=>`${rename.id}, ${rename.reference}`).join("; ")}export function catchUpSnapshotFrameworkRenames(options){configureQueryBuilder();const dialect=getQbDialect(),stored=readStoredMigrationPlan(dialect);if(!stored)return{changes:[]};const renames=frameworkRenamesForThisApp();if(renames.length===0)return{changes:[]};const{plan,changes}=applyFrameworkRenamesToPlan(stored,renames);if(changes.length===0)return{changes};if(options.persist){saveMigrationSnapshot(plan,{dialect});log.info(`[migration] Updated the model snapshot for framework renames (${describeRenames(renames)}): ${changes.join(", ")}`)}else log.debug(`[migration] Previewing against the snapshot with framework renames applied: ${changes.join(", ")}`);return{changes,plan}}export async function catchUpFrameworkRenames(options={}){await ensureDatabaseConfigLoaded();const dialect=toSqlIntrospectionDialect(getDialect());if(dialect==="other")return{applied:[],skipped:[]};const renames=frameworkRenamesForThisApp();if(renames.length===0)return{applied:[],skipped:[]};const runner=options.runner??(async(sql)=>{const result=await db.unsafe(sql).execute();return Array.isArray(result)?result:[]}),result=await applyFrameworkRenamesToDatabase(runner,dialect,renames);if(result.applied.length>0)log.info(`[migration] Applied framework renames to the database (${describeRenames(renames)}): ${result.applied.join(", ")}`);for(const skipped of result.skipped)log.warn(`[migration] Framework rename not applied: ${skipped}`);return result}function resolveGenerateOptions(options){const applyRenames=options.applyRenames??(process.env.STACKS_MIGRATE_NO_RENAME==="1"?!1:void 0),fromDb=options.fromDb??(process.env.STACKS_MIGRATE_FROM_DB==="1"?!0:void 0);return{applyRenames,fromDb}}export async function previewPendingMigrations(options={}){try{return await pendingMigrationOperations(options)}catch(error){log.debug(`[migration] preview failed: ${error instanceof Error?error.message:String(error)}`);return[]}}export async function pendingMigrationOperations(options={}){await ensureDatabaseConfigLoaded();configureQueryBuilder();const dialect=getDialect(),{modelsDir,skip,protectedTables}=prepareMigrationModelsDir();if(skip)return[];const{applyRenames,fromDb}=resolveGenerateOptions(options),qbDialect=getQbDialect(),result=await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,dryRun:!0,applyRenames,fromDb}),corrected=fromDb?void 0:catchUpSnapshotFrameworkRenames({persist:!1}).plan;if(corrected&&result.plan)result.operations=generateDiffOperations(corrected,result.plan,{applyRenames,dryRun:!0}).operations;const authOwned=new Set(AUTH_TABLES);let operations=(result.operations??[]).filter((op)=>!authOwned.has(op.table.toLowerCase()));if(protectedTables.length>0){const excluded=new Set(protectedTables.map((table)=>table.toLowerCase()));operations=operations.filter((op)=>!(op.kind==="drop_table"&&excluded.has(op.table.toLowerCase())))}if(!operations.some((op)=>op.kind==="drop_column"))return operations;return withoutManagedColumnDrops(operations,await frameworkManagedColumns())}function snapshotDirLabel(){return qbConfig?.snapshotDir||qbSnapshotDir()}function resolveSnapshotDir(){const label=snapshotDirLabel();return isAbsolute(label)?label:resolve(process.cwd(),label)}function snapshotPathFor(dialect){return join(resolveSnapshotDir(),`model-snapshot.${dialect}.json`)}function readStoredMigrationPlan(dialect){const snapshotPath=snapshotPathFor(dialect);if(!existsSync(snapshotPath))return;let parsed;try{parsed=JSON.parse(readFileSync(snapshotPath,"utf8"))}catch(error){throw Error(`The migration snapshot is not valid JSON: ${snapshotPath}. Repair or regenerate it before creating migrations.`,{cause:error})}const candidate=parsed&&typeof parsed==="object"&&"plan"in parsed?parsed.plan:parsed;if(!candidate||typeof candidate!=="object"||!Array.isArray(candidate.tables))throw TypeError(`The migration snapshot has an invalid structure: ${snapshotPath}. Repair or regenerate it before creating migrations.`);return candidate}export function preserveMigrationPlanTableOrder(next,previous){const orderedByPrevious=(values,previousValues,keyFor)=>{if(!previousValues)return[...values];const previousOrder=new Map(previousValues.map((value,index)=>[keyFor(value),index])),nextOrder=new Map(values.map((value,index)=>[keyFor(value),index])),previousCount=previousValues.length;return[...values].sort((left,right)=>{const leftKey=keyFor(left),rightKey=keyFor(right),leftOrder=previousOrder.get(leftKey)??previousCount+nextOrder.get(leftKey),rightOrder=previousOrder.get(rightKey)??previousCount+nextOrder.get(rightKey);return leftOrder-rightOrder})},previousTables=new Map(previous?.tables.map((table)=>[table.table,table])??[]),tables=next.tables.map((table)=>{const previousTable=previousTables.get(table.table),previousColumns=new Map(previousTable?.columns.map((column)=>[column.name,column])??[]);return{...table,columns:orderedByPrevious(table.columns.map((column)=>{if(next.dialect!=="sqlite"||!column.enumTypeName||previousColumns.get(column.name)?.enumTypeName)return column;const portableColumn={...column};delete portableColumn.enumTypeName;return portableColumn}),previousTable?.columns,(column)=>column.name),indexes:orderedByPrevious(table.indexes,previousTable?.indexes,(index)=>index.name)}});return{...next,tables:orderedByPrevious(tables,previous?.tables,(table)=>table.table)}}function detectSnapshotDialectMismatch(dialect){const qbDir=resolveSnapshotDir();let files;try{files=readdirSync(qbDir)}catch{return null}const snapshotFor=(d)=>`model-snapshot.${d}.json`;if(files.includes(snapshotFor(dialect)))return null;for(const f of files){const m=/^model-snapshot\.(\w+)\.json$/.exec(f);if(m?.[1]&&m[1]!==dialect)return m[1]}return null}export async function generateMigrations(options={}){await ensureDatabaseConfigLoaded();try{log.debug("Generating migrations...");configureQueryBuilder();const dialect=getDialect(),mismatch=detectSnapshotDialectMismatch(dialect),flatMigrationDir=join(process.cwd(),"database","migrations");if(mismatch&&migrationDirectory(dialect)===flatMigrationDir){const snapshotDir=snapshotDirLabel();return err(Error(`Refusing to generate migrations: resolved dialect "${dialect}" has no snapshot in ${snapshotDir}/, but "${mismatch}" does. DB_CONNECTION is likely unset or wrong (missing .env?) - generating now would write a full duplicate migration set in the wrong dialect. Set DB_CONNECTION=${mismatch} (or your intended dialect) and retry. To intentionally start a new dialect from scratch, remove ${snapshotDir}/model-snapshot.${mismatch}.json first.`))}catchUpSnapshotFrameworkRenames({persist:!0});const storedPlan=readStoredMigrationPlan(getQbDialect()),{modelsDir,skip,excludedTables,protectedTables}=prepareMigrationModelsDir();if(skip){trackIgnoredGeneratedMigrations(migrationDirectory());log.debug("No app/Models directory found; using committed framework migrations");return ok("Migrations generated")}const{applyRenames,fromDb}=resolveGenerateOptions(options);log.debug(`[migration] Generating migrations for dialect: ${dialect}, models: ${modelsDir}`);if(excludedTables.length>0)log.debug(`[migration] ${excludedTables.length} framework default model(s) out of scope because app/Models defines this app's schema. Enable database.models.includeFrameworkDefaults (or STACKS_INCLUDE_FRAMEWORK_MODELS=1) to generate them too.`);const qbDialect=getQbDialect(),result=await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,dryRun:!0,applyRenames,fromDb});let sqlStatements=result.sqlStatements??[];if(dialect==="sqlite")sqlStatements=inlineSqliteAddedColumnReferences(sqlStatements,result.plan);{const runtimeOwned=withoutRuntimeOwnedTableSql(sqlStatements);if(runtimeOwned.removed.length>0)log.debug(`[migration] Left out ${runtimeOwned.removed.length} generated statement(s) for tables the auth layer creates itself`);sqlStatements=runtimeOwned.statements}if(result.hasChanges&&sqlStatements.length>0){const filtered=withoutManagedColumnDropSql(sqlStatements,await frameworkManagedColumns(),result.operations??[]);if(filtered.removed.length>0)log.debug(`[migration] Skipped ${filtered.removed.length} generated drop(s) of framework-managed column(s) (stacksjs/stacks#2075)`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0&&protectedTables.length>0){const filtered=withoutProtectedTableDropSql(sqlStatements,protectedTables,result.operations??[]);if(filtered.removed.length>0)log.info(`[migration] Left ${filtered.removed.length} framework-owned table(s) in place rather than dropping them. They are no longer generated because app/Models defines this app's schema; the tables and their data are untouched. Set database.models.includeFrameworkDefaults to keep generating them (stacksjs/stacks#2220).`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0){const dangling=findDanglingTypeReferences(sqlStatements);if(dangling.length>0){const created=createMissingEnumTypes(dangling,result.plan);if(created.statements.length>0){sqlStatements=[...created.statements,...sqlStatements];log.debug(`[migration] Created ${created.statements.length} enum type(s) an ALTER needed: ${created.defined.join(", ")}`)}const unresolved=dangling.filter((name)=>!created.defined.includes(name));if(unresolved.length>0){const before=sqlStatements.length;sqlStatements=sqlStatements.filter((statement)=>!referencesUndefinedType(statement,unresolved));log.warn(`[migration] Skipped ${before-sqlStatements.length} generated statement(s) referencing enum type(s) nothing creates and no model defines values for (${unresolved.slice(0,3).join(", ")}${unresolved.length>3?", \u2026":""}).`)}}}if(result.hasChanges&&sqlStatements.length>0&&!shadowDropsAllowed()){const shadowed=resolveModelSources()?.shadowed??[],drops=findShadowedColumnDrops(sqlStatements,shadowed);if(drops.length>0)return err(Error(shadowedDropMessage(drops)))}if(result.hasChanges){const written=persistGeneratedMigrations(sqlStatements,storedPlan,await claimedMigrationNames(migrationDirectory()));if(written>0)log.success(`Generated ${written} migration file${written===1?"":"s"}`);else log.debug("Migration generation produced no new files (already up to date)")}else log.debug("No changes detected");trackIgnoredGeneratedMigrations(migrationDirectory());const stablePlan=preserveMigrationPlanTableOrder(result.plan,storedPlan);if(!storedPlan||JSON.stringify(stablePlan)!==JSON.stringify(storedPlan))saveMigrationSnapshot(stablePlan,{dialect:getQbDialect()});else log.debug("Model snapshot unchanged");return ok("Migrations generated")}catch(error){const because=error instanceof Error?error.message:String(error);return err(handleError(`Migration generation failed: ${because}`,error))}}export function referencesUndefinedType(statement,dangling){return dangling.some((name)=>statement.includes(`"${name}"`))}export function findDanglingTypeReferences(statements){const defined=new Set,referenced=new Set;for(const statement of statements){for(const match of statement.matchAll(/CREATE\s+TYPE\s+"([^"]+)"/gi))defined.add(match[1]);for(const match of statement.matchAll(/\bTYPE\s+"([^"]+)"/gi))referenced.add(match[1])}return[...referenced].filter((name)=>!defined.has(name)).sort()}export function createMissingEnumTypes(dangling,plan){if(dangling.length===0)return{statements:[],defined:[]};const values=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){if(!column.name||!column.enumValues?.length)continue;values.set(`${table.table}_${column.name}_type`,column.enumValues)}}const statements=[],defined=[];for(const name of dangling){const members=values.get(name);if(!members?.length)continue;const literals=members.map((member)=>`'${String(member).replaceAll("'","''")}'`).join(", "),guarded=guardPostgresEnumTypes(`CREATE TYPE "${name}" AS ENUM (${literals})`);statements.push(guarded.endsWith(";")?guarded:`${guarded};`);defined.push(name)}return{statements,defined}}export function inlineSqliteAddedColumnReferences(statements,plan){const references=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){const reference=column.references;if(!column.name||!reference?.table||!reference.column)continue;references.set(`${table.table}.${column.name}`,{table:reference.table,column:reference.column})}}if(references.size===0)return statements;return statements.map((statement)=>{if(/\bREFERENCES\b/i.test(statement))return statement;const match=statement.match(/^(\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+COLUMN\s+["`]?(\w+)["`]?\s+)([\s\S]*?)(;?\s*)$/i),reference=match?.[2]&&match[3]?references.get(`${match[2]}.${match[3]}`):void 0;if(!match||!reference)return statement;return`${match[1]}${match[4].trimEnd()} REFERENCES "${reference.table}"("${reference.column}")${match[5]}`})}export{GENERATED_MIGRATION_MARKER,isGeneratedMigration}from"./generated-migration-marker";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]}const IDENTIFIER=SQL_IDENTIFIER,NEUTRAL_STATEMENT=/^(?:BEGIN|COMMIT|END|PRAGMA|SET|SELECT\s+1\s*$|COMMENT\s+ON)\b/i;export function describeMigrationContent(sql){const content={types:[],droppedTypes:[],droppedIndexes:[],createdIndexes:[],dataTables:[],seedTables:[],createdTables:[],other:!1},add=(list,name)=>{const lower=name?.toLowerCase();if(lower&&!list.includes(lower))list.push(lower)},visit=(statement)=>{const stmt=statement.trim().replace(/;\s*$/,"");if(!stmt||NEUTRAL_STATEMENT.test(stmt))return;const guarded=guardedBlockStatements(stmt);if(guarded){for(const inner of guarded)visit(inner);return}let m=new RegExp(String.raw`^CREATE\s+TYPE\s+${IDENTIFIER}\s+AS\s+ENUM\b`,"i").exec(stmt)??new RegExp(String.raw`^ALTER\s+TYPE\s+${IDENTIFIER}\s+ADD\s+VALUE\b`,"i").exec(stmt);if(m){add(content.types,m[1]);return}m=new RegExp(String.raw`^DROP\s+TYPE\s+(?:IF\s+EXISTS\s+)?${IDENTIFIER}`,"i").exec(stmt);if(m){add(content.droppedTypes,m[1]);return}m=new RegExp(String.raw`^DROP\s+INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+EXISTS\s+)?${IDENTIFIER}`,"i").exec(stmt);if(m){add(content.droppedIndexes,m[1]);return}m=new RegExp(String.raw`^(?:DELETE\s+FROM|UPDATE|TRUNCATE(?:\s+TABLE)?)\s+(?:ONLY\s+)?${IDENTIFIER}`,"i").exec(stmt);if(m){add(content.dataTables,m[1]);return}m=new RegExp(String.raw`^COPY\s+${IDENTIFIER}[\s\S]*\bFROM\b`,"i").exec(stmt);if(m){add(content.seedTables,m[1]);return}m=new RegExp(String.raw`^INSERT\s+(?:OR\s+\w+\s+)?INTO\s+${IDENTIFIER}`,"i").exec(stmt);if(m){if(/\bSELECT\b[\s\S]*\bFROM\b/i.test(stmt)&&!/\bVALUES\b/i.test(stmt))content.other=!0;else add(content.seedTables,m[1]);return}const shape=indexShape(stmt);if(shape){add(content.createdIndexes,shape.name);content.other=!0;return}const created=createdTableName(stmt);if(created)add(content.createdTables,created);content.other=!0};for(const statement of sqlStatementsOf(sql))visit(statement);return content}export function obsoleteMigrationReason(content,context){if(content.other||content.seedTables.length>0)return;const hasTypes=content.types.length>0||content.droppedTypes.length>0,hasDrops=content.droppedIndexes.length>0,hasData=content.dataTables.length>0;if([hasTypes,hasDrops,hasData].filter(Boolean).length!==1)return;if(hasTypes)return content.types.every((type)=>context.emittedTypes.has(type))?"types":void 0;if(hasDrops)return content.droppedIndexes.every((index)=>!context.keptIndexes.has(index))?"drop-index":void 0;return content.dataTables.every((table)=>!context.keptSeedTables.has(table))?"data":void 0}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 generateFullModelMigration(options={}){await ensureDatabaseConfigLoaded();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 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.`));return ok({dialect,statements,plan:result.plan,sources})}catch(error){return err(handleError("Migration generation failed",error))}}export async function regenerateMigrationCorpus(options={}){try{const generated=await generateFullModelMigration({dialect:options.dialect,onlyExistingTables:options.onlyExistingTables});if(!generated.isOk)return err(generated.error);const{dialect,statements,plan,sources}=generated.value,dir=options.dir??migrationDirectory(dialect);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 contents=new Map,contentOf=(file)=>{let content=contents.get(file);if(!content){try{content=describeMigrationContent(readFileSync(join(dir,file),"utf8"))}catch{content={types:[],droppedTypes:[],droppedIndexes:[],createdIndexes:[],dataTables:[],seedTables:[],createdTables:[],other:!0}}contents.set(file,content)}return content},namesNoTable=new Set(existing.filter((file)=>{try{return tablesOperatedOn(readFileSync(join(dir,file),"utf8")).length===0}catch{return!1}})),seedFiles=new Set(existing.filter((file)=>contentOf(file).seedTables.length>0)),seedRootedTables=options.replaceUnmarked&&!options.onlyExistingTables?[...seedFiles].flatMap((file)=>contentOf(file).createdTables):[],rootedTables=new Set(options.replaceUnmarked||options.onlyExistingTables?seedRootedTables:historicallyRootedTables(dir,existing)),outOfScopeByTable=new Set(migrationsOutsideCorpus(dir,existing,createdTablesOf(statements))),markerDeletable=(options.replaceUnmarked||options.onlyExistingTables?existing:existing.filter((file)=>isGeneratedMigration(dir,file))).filter((file)=>!isPackageMigration(file)),certainlyKept=existing.filter((file)=>!markerDeletable.includes(file)||seedFiles.has(file)||outOfScopeByTable.has(file)&&!namesNoTable.has(file)),emittedTypes=enumTypesCreatedBy(statements),keptIndexes=new Set(certainlyKept.flatMap((file)=>contentOf(file).createdIndexes)),obsolete=new Map;for(const file of namesNoTable){const keptSeedTables=new Set(certainlyKept.filter((kept)=>migrationOrdinal(kept)<migrationOrdinal(file)).flatMap((kept)=>contentOf(kept).seedTables)),reason=obsoleteMigrationReason(contentOf(file),{emittedTypes,keptIndexes,keptSeedTables});if(reason)obsolete.set(file,reason)}const outOfScope=new Set([...outOfScopeByTable].filter((file)=>!obsolete.has(file)&&!seedFiles.has(file))),removed=markerDeletable.filter((file)=>!seedFiles.has(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)),preservedData=preserved.filter((file)=>seedFiles.has(file)),removedObsolete=removed.filter((file)=>obsolete.get(file)==="types"||obsolete.get(file)==="drop-index"),removedDataOnly=removed.filter((file)=>obsolete.get(file)==="data"),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}]:[]),writtenTables=new Set(createdTablesOf(writableGroups.flatMap((group)=>group.statements))),relocating=preservedData.filter((file)=>{const content=contentOf(file);return content.seedTables.some((table)=>writtenTables.has(table)&&!content.createdTables.includes(table))}),staying=preserved.filter((file)=>!relocating.includes(file)),historicalBoundary=existing.filter((file)=>!isGeneratedMigration(dir,file)&&!isPackageMigration(file)&&!relocating.includes(file)).reduce((max,file)=>Math.max(max,migrationOrdinal(file)),0),startAt=rootedTables.size>0?historicalBoundary+1:staying.filter((file)=>!isPackageMigration(file)).reduce((max,file)=>Math.max(max,migrationOrdinal(file)),0)+1,reservedOrdinals=new Set(staying.map(migrationOrdinal)),ordinals=allocateMigrationOrdinals(writableGroups.length+relocating.length,startAt,reservedOrdinals),files=writableGroups.map((group,index)=>({name:`${String(ordinals[index]).padStart(10,"0")}-${group.label}.sql`,statements:group.statements.length})),relocated=relocating.map((file,index)=>({from:file,to:`${String(ordinals[writableGroups.length+index]).padStart(10,"0")}-${file.replace(/^\d+[-_]/,"")}`})),corpus={dialect,models:sources.models.length,modelRoots:sources.roots,files,removed,removedObsolete,removedDataOnly,preserved,preservedOutOfScope,preservedData,relocated,unrebuildable,dir};if(options.dryRun)return ok(corpus);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(`;
32
+ `).execute(),names=(Array.isArray(raw)?raw:raw?.rows??[]).map((row)=>String(row.name??row.typname??"")).filter(Boolean);for(const name of names)try{await db.unsafe(`DROP TYPE IF EXISTS "${name}" CASCADE`).execute();log.debug(`Dropped orphaned enum type: ${name}`)}catch(error){log.warn(`Could not drop enum type ${name}: ${error instanceof Error?error.message:String(error)}`)}}catch(error){log.warn(`Could not list enum types to drop: ${error instanceof Error?error.message:String(error)}`)}}async function dropFrameworkTables(dialect){if(dialect==="mysql"||dialect==="vitess")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 0").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = OFF").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}for(const tableName of FRAMEWORK_TABLES)try{let dropSql;if(dialect==="postgres")dropSql=`DROP TABLE IF EXISTS "${tableName}" CASCADE`;else if(dialect==="mysql"||dialect==="vitess")dropSql=`DROP TABLE IF EXISTS \`${tableName}\``;else dropSql=`DROP TABLE IF EXISTS "${tableName}"`;log.info(`Dropping framework table: ${tableName}`);await db.unsafe(dropSql).execute();log.info(`Dropped framework table: ${tableName}`)}catch(error){const kind=classifyConnectionError(error);if(kind==="missing-database"||kind==="missing-role"||kind==="auth-failed"||kind==="server-unreachable"||kind==="timeout")throw error;log.warn(`Could not drop table ${tableName}: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="mysql"||dialect==="vitess")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 1").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = ON").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}}function frameworkRenamesForThisApp(){return applicableFrameworkRenames(declaredModelTables())}function describeRenames(renames){return renames.map((rename)=>`${rename.id}, ${rename.reference}`).join("; ")}export function catchUpSnapshotFrameworkRenames(options){configureQueryBuilder();const dialect=getQbDialect(),stored=readStoredMigrationPlan(dialect);if(!stored)return{changes:[]};const renames=frameworkRenamesForThisApp();if(renames.length===0)return{changes:[]};const{plan,changes}=applyFrameworkRenamesToPlan(stored,renames);if(changes.length===0)return{changes};if(options.persist){saveMigrationSnapshot(plan,{dialect});log.info(`[migration] Updated the model snapshot for framework renames (${describeRenames(renames)}): ${changes.join(", ")}`)}else log.debug(`[migration] Previewing against the snapshot with framework renames applied: ${changes.join(", ")}`);return{changes,plan}}export async function catchUpFrameworkRenames(options={}){await ensureDatabaseConfigLoaded();const dialect=toSqlIntrospectionDialect(getDialect());if(dialect==="other")return{applied:[],skipped:[]};const renames=frameworkRenamesForThisApp();if(renames.length===0)return{applied:[],skipped:[]};const runner=options.runner??(async(sql)=>{const result=await db.unsafe(sql).execute();return Array.isArray(result)?result:[]}),result=await applyFrameworkRenamesToDatabase(runner,dialect,renames);if(result.applied.length>0)log.info(`[migration] Applied framework renames to the database (${describeRenames(renames)}): ${result.applied.join(", ")}`);for(const skipped of result.skipped)log.warn(`[migration] Framework rename not applied: ${skipped}`);return result}function resolveGenerateOptions(options){const applyRenames=options.applyRenames??(process.env.STACKS_MIGRATE_NO_RENAME==="1"?!1:void 0),fromDb=options.fromDb??(process.env.STACKS_MIGRATE_FROM_DB==="1"?!0:void 0);return{applyRenames,fromDb}}export async function previewPendingMigrations(options={}){try{return await pendingMigrationOperations(options)}catch(error){log.debug(`[migration] preview failed: ${error instanceof Error?error.message:String(error)}`);return[]}}export async function pendingMigrationOperations(options={}){await ensureDatabaseConfigLoaded();configureQueryBuilder();const dialect=getDialect(),{modelsDir,skip,protectedTables}=prepareMigrationModelsDir();if(skip)return[];const{applyRenames,fromDb}=resolveGenerateOptions(options),qbDialect=getQbDialect(),result=await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,dryRun:!0,applyRenames,fromDb}),corrected=fromDb?void 0:catchUpSnapshotFrameworkRenames({persist:!1}).plan;if(corrected&&result.plan)result.operations=generateDiffOperations(corrected,result.plan,{applyRenames,dryRun:!0}).operations;const authOwned=new Set(AUTH_TABLES);let operations=(result.operations??[]).filter((op)=>!authOwned.has(op.table.toLowerCase()));if(protectedTables.length>0){const excluded=new Set(protectedTables.map((table)=>table.toLowerCase()));operations=operations.filter((op)=>!(op.kind==="drop_table"&&excluded.has(op.table.toLowerCase())))}if(!operations.some((op)=>op.kind==="drop_column"))return operations;return withoutManagedColumnDrops(operations,await frameworkManagedColumns())}function snapshotDirLabel(){return qbConfig?.snapshotDir||qbSnapshotDir()}function resolveSnapshotDir(){const label=snapshotDirLabel();return isAbsolute(label)?label:resolve(process.cwd(),label)}function snapshotPathFor(dialect){return join(resolveSnapshotDir(),`model-snapshot.${dialect}.json`)}function readStoredMigrationPlan(dialect){const snapshotPath=snapshotPathFor(dialect);if(!existsSync(snapshotPath))return;let parsed;try{parsed=JSON.parse(readFileSync(snapshotPath,"utf8"))}catch(error){throw Error(`The migration snapshot is not valid JSON: ${snapshotPath}. Repair or regenerate it before creating migrations.`,{cause:error})}const candidate=parsed&&typeof parsed==="object"&&"plan"in parsed?parsed.plan:parsed;if(!candidate||typeof candidate!=="object"||!Array.isArray(candidate.tables))throw TypeError(`The migration snapshot has an invalid structure: ${snapshotPath}. Repair or regenerate it before creating migrations.`);return candidate}export function preserveMigrationPlanTableOrder(next,previous){const orderedByPrevious=(values,previousValues,keyFor)=>{if(!previousValues)return[...values];const previousOrder=new Map(previousValues.map((value,index)=>[keyFor(value),index])),nextOrder=new Map(values.map((value,index)=>[keyFor(value),index])),previousCount=previousValues.length;return[...values].sort((left,right)=>{const leftKey=keyFor(left),rightKey=keyFor(right),leftOrder=previousOrder.get(leftKey)??previousCount+nextOrder.get(leftKey),rightOrder=previousOrder.get(rightKey)??previousCount+nextOrder.get(rightKey);return leftOrder-rightOrder})},previousTables=new Map(previous?.tables.map((table)=>[table.table,table])??[]),tables=next.tables.map((table)=>{const previousTable=previousTables.get(table.table),previousColumns=new Map(previousTable?.columns.map((column)=>[column.name,column])??[]);return{...table,columns:orderedByPrevious(table.columns.map((column)=>{if(next.dialect!=="sqlite"||!column.enumTypeName||previousColumns.get(column.name)?.enumTypeName)return column;const portableColumn={...column};delete portableColumn.enumTypeName;return portableColumn}),previousTable?.columns,(column)=>column.name),indexes:orderedByPrevious(table.indexes,previousTable?.indexes,(index)=>index.name)}});return{...next,tables:orderedByPrevious(tables,previous?.tables,(table)=>table.table)}}function detectSnapshotDialectMismatch(dialect){const qbDir=resolveSnapshotDir();let files;try{files=readdirSync(qbDir)}catch{return null}const snapshotFor=(d)=>`model-snapshot.${d}.json`;if(files.includes(snapshotFor(dialect)))return null;for(const f of files){const m=/^model-snapshot\.(\w+)\.json$/.exec(f);if(m?.[1]&&m[1]!==dialect)return m[1]}return null}export async function generateMigrations(options={}){await ensureDatabaseConfigLoaded();if(!options.dialect||options.dialect===attachedDriver())return generateMigrationsForDialect(options);if(!MIGRATION_DIALECTS.includes(options.dialect))return err(Error(`Unknown migration dialect "${options.dialect}". Use one of: ${MIGRATION_DIALECTS.join(", ")}.`));if(resolveGenerateOptions(options).fromDb)return err(Error(`--from-db diffs against the attached ${attachedDriver()} database, so it cannot generate ${options.dialect} migrations.`));return withGenerationDialect(options.dialect,()=>generateMigrationsForDialect(options))}function withAttachedDialect(work){const previous=generationDialect;generationDialect=void 0;try{return work()}finally{generationDialect=previous}}async function withGenerationDialect(dialect,work){const previous=generationDialect;generationDialect=dialect;try{return await work()}finally{generationDialect=previous;configureQueryBuilder()}}export async function siblingMigrationDialects(){await ensureDatabaseConfigLoaded();configureQueryBuilder();let files;try{files=readdirSync(resolveSnapshotDir())}catch{return[]}return MIGRATION_DIALECTS.filter((dialect)=>dialect!==attachedDriver()&&files.includes(`model-snapshot.${dialect}.json`))}export async function dialectsBehindModels(){const behind=[];for(const dialect of await siblingMigrationDialects())if(await withGenerationDialect(dialect,async()=>{configureQueryBuilder();const{modelsDir,skip}=prepareMigrationModelsDir();if(skip)return!1;const result=await qbGenerateMigration(modelsDir,{dialect:getQbDialect(),dryRun:!0});return Boolean(result.hasChanges)}).catch(()=>!1))behind.push(dialect);return behind}async function generateMigrationsForDialect(options){try{log.debug("Generating migrations...");configureQueryBuilder();const dialect=getDialect(),otherDialect=generationDialect!==void 0&&generationDialect!==attachedDriver();if(otherDialect){const attachedDir=withAttachedDialect(()=>migrationDirectory());if(migrationDirectory(dialect)===attachedDir)return err(Error(`Refusing to generate ${dialect} migrations into ${relativeMigrationDirectory(attachedDir)}, which is the ${attachedDriver()} corpus. Create ${relativeMigrationDirectory(join(attachedDir,dialect))}/ to give ${dialect} a corpus of its own, then retry.`))}const mismatch=detectSnapshotDialectMismatch(dialect),flatMigrationDir=join(process.cwd(),"database","migrations");if(mismatch&&migrationDirectory(dialect)===flatMigrationDir){const snapshotDir=snapshotDirLabel();return err(Error(`Refusing to generate migrations: resolved dialect "${dialect}" has no snapshot in ${snapshotDir}/, but "${mismatch}" does. DB_CONNECTION is likely unset or wrong (missing .env?) - generating now would write a full duplicate migration set in the wrong dialect. Set DB_CONNECTION=${mismatch} (or your intended dialect) and retry. To intentionally start a new dialect from scratch, remove ${snapshotDir}/model-snapshot.${mismatch}.json first.`))}catchUpSnapshotFrameworkRenames({persist:!0});const storedPlan=readStoredMigrationPlan(getQbDialect()),{modelsDir,skip,excludedTables,protectedTables}=prepareMigrationModelsDir();if(skip){trackIgnoredGeneratedMigrations(migrationDirectory());log.debug("No app/Models directory found; using committed framework migrations");return ok("Migrations generated")}const{applyRenames,fromDb}=resolveGenerateOptions(options);log.debug(`[migration] Generating migrations for dialect: ${dialect}, models: ${modelsDir}`);if(excludedTables.length>0)log.debug(`[migration] ${excludedTables.length} framework default model(s) out of scope because app/Models defines this app's schema. Enable database.models.includeFrameworkDefaults (or STACKS_INCLUDE_FRAMEWORK_MODELS=1) to generate them too.`);const qbDialect=getQbDialect(),full=otherDialect&&!existsSync(snapshotPathFor(qbDialect)),result=await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,dryRun:!0,applyRenames,fromDb,...full?{full:!0}:{}});let sqlStatements=result.sqlStatements??[];if(dialect==="sqlite")sqlStatements=inlineSqliteAddedColumnReferences(sqlStatements,result.plan);{const runtimeOwned=withoutRuntimeOwnedTableSql(sqlStatements);if(runtimeOwned.removed.length>0)log.debug(`[migration] Left out ${runtimeOwned.removed.length} generated statement(s) for tables the auth layer creates itself`);sqlStatements=runtimeOwned.statements}if(result.hasChanges&&sqlStatements.length>0){const filtered=withoutManagedColumnDropSql(sqlStatements,await frameworkManagedColumns(),result.operations??[]);if(filtered.removed.length>0)log.debug(`[migration] Skipped ${filtered.removed.length} generated drop(s) of framework-managed column(s) (stacksjs/stacks#2075)`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0&&protectedTables.length>0){const filtered=withoutProtectedTableDropSql(sqlStatements,protectedTables,result.operations??[]);if(filtered.removed.length>0)log.info(`[migration] Left ${filtered.removed.length} framework-owned table(s) in place rather than dropping them. They are no longer generated because app/Models defines this app's schema; the tables and their data are untouched. Set database.models.includeFrameworkDefaults to keep generating them (stacksjs/stacks#2220).`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0){const dangling=findDanglingTypeReferences(sqlStatements);if(dangling.length>0){const created=createMissingEnumTypes(dangling,result.plan);if(created.statements.length>0){sqlStatements=[...created.statements,...sqlStatements];log.debug(`[migration] Created ${created.statements.length} enum type(s) an ALTER needed: ${created.defined.join(", ")}`)}const unresolved=dangling.filter((name)=>!created.defined.includes(name));if(unresolved.length>0){const before=sqlStatements.length;sqlStatements=sqlStatements.filter((statement)=>!referencesUndefinedType(statement,unresolved));log.warn(`[migration] Skipped ${before-sqlStatements.length} generated statement(s) referencing enum type(s) nothing creates and no model defines values for (${unresolved.slice(0,3).join(", ")}${unresolved.length>3?", \u2026":""}).`)}}}if(result.hasChanges&&sqlStatements.length>0&&!shadowDropsAllowed()){const shadowed=resolveModelSources()?.shadowed??[],drops=findShadowedColumnDrops(sqlStatements,shadowed);if(drops.length>0)return err(Error(shadowedDropMessage(drops)))}if(result.hasChanges){const written=persistGeneratedMigrations(sqlStatements,storedPlan,await claimedMigrationNames(migrationDirectory()));if(written>0)log.success(`Generated ${written} migration file${written===1?"":"s"}`);else log.debug("Migration generation produced no new files (already up to date)")}else log.debug("No changes detected");trackIgnoredGeneratedMigrations(migrationDirectory());const stablePlan=preserveMigrationPlanTableOrder(result.plan,storedPlan);if(!storedPlan||JSON.stringify(stablePlan)!==JSON.stringify(storedPlan))saveMigrationSnapshot(stablePlan,{dialect:getQbDialect()});else log.debug("Model snapshot unchanged");return ok("Migrations generated")}catch(error){const because=error instanceof Error?error.message:String(error);return err(handleError(`Migration generation failed: ${because}`,error))}}export function referencesUndefinedType(statement,dangling){return dangling.some((name)=>statement.includes(`"${name}"`))}export function findDanglingTypeReferences(statements){const defined=new Set,referenced=new Set;for(const statement of statements){for(const match of statement.matchAll(/CREATE\s+TYPE\s+"([^"]+)"/gi))defined.add(match[1]);for(const match of statement.matchAll(/\bTYPE\s+"([^"]+)"/gi))referenced.add(match[1])}return[...referenced].filter((name)=>!defined.has(name)).sort()}export function createMissingEnumTypes(dangling,plan){if(dangling.length===0)return{statements:[],defined:[]};const values=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){if(!column.name||!column.enumValues?.length)continue;values.set(`${table.table}_${column.name}_type`,column.enumValues)}}const statements=[],defined=[];for(const name of dangling){const members=values.get(name);if(!members?.length)continue;const literals=members.map((member)=>`'${String(member).replaceAll("'","''")}'`).join(", "),guarded=guardPostgresEnumTypes(`CREATE TYPE "${name}" AS ENUM (${literals})`);statements.push(guarded.endsWith(";")?guarded:`${guarded};`);defined.push(name)}return{statements,defined}}export function inlineSqliteAddedColumnReferences(statements,plan){const references=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){const reference=column.references;if(!column.name||!reference?.table||!reference.column)continue;references.set(`${table.table}.${column.name}`,{table:reference.table,column:reference.column})}}if(references.size===0)return statements;return statements.map((statement)=>{if(/\bREFERENCES\b/i.test(statement))return statement;const match=statement.match(/^(\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+COLUMN\s+["`]?(\w+)["`]?\s+)([\s\S]*?)(;?\s*)$/i),reference=match?.[2]&&match[3]?references.get(`${match[2]}.${match[3]}`):void 0;if(!match||!reference)return statement;return`${match[1]}${match[4].trimEnd()} REFERENCES "${reference.table}"("${reference.column}")${match[5]}`})}export{GENERATED_MIGRATION_MARKER,isGeneratedMigration}from"./generated-migration-marker";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]}const IDENTIFIER=SQL_IDENTIFIER,NEUTRAL_STATEMENT=/^(?:BEGIN|COMMIT|END|PRAGMA|SET|SELECT\s+1\s*$|COMMENT\s+ON)\b/i;export function describeMigrationContent(sql){const content={types:[],droppedTypes:[],droppedIndexes:[],createdIndexes:[],dataTables:[],seedTables:[],createdTables:[],other:!1},add=(list,name)=>{const lower=name?.toLowerCase();if(lower&&!list.includes(lower))list.push(lower)},visit=(statement)=>{const stmt=statement.trim().replace(/;\s*$/,"");if(!stmt||NEUTRAL_STATEMENT.test(stmt))return;const guarded=guardedBlockStatements(stmt);if(guarded){for(const inner of guarded)visit(inner);return}let m=new RegExp(String.raw`^CREATE\s+TYPE\s+${IDENTIFIER}\s+AS\s+ENUM\b`,"i").exec(stmt)??new RegExp(String.raw`^ALTER\s+TYPE\s+${IDENTIFIER}\s+ADD\s+VALUE\b`,"i").exec(stmt);if(m){add(content.types,m[1]);return}m=new RegExp(String.raw`^DROP\s+TYPE\s+(?:IF\s+EXISTS\s+)?${IDENTIFIER}`,"i").exec(stmt);if(m){add(content.droppedTypes,m[1]);return}m=new RegExp(String.raw`^DROP\s+INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+EXISTS\s+)?${IDENTIFIER}`,"i").exec(stmt);if(m){add(content.droppedIndexes,m[1]);return}m=new RegExp(String.raw`^(?:DELETE\s+FROM|UPDATE|TRUNCATE(?:\s+TABLE)?)\s+(?:ONLY\s+)?${IDENTIFIER}`,"i").exec(stmt);if(m){add(content.dataTables,m[1]);return}m=new RegExp(String.raw`^COPY\s+${IDENTIFIER}[\s\S]*\bFROM\b`,"i").exec(stmt);if(m){add(content.seedTables,m[1]);return}m=new RegExp(String.raw`^INSERT\s+(?:OR\s+\w+\s+)?INTO\s+${IDENTIFIER}`,"i").exec(stmt);if(m){if(/\bSELECT\b[\s\S]*\bFROM\b/i.test(stmt)&&!/\bVALUES\b/i.test(stmt))content.other=!0;else add(content.seedTables,m[1]);return}const shape=indexShape(stmt);if(shape){add(content.createdIndexes,shape.name);content.other=!0;return}const created=createdTableName(stmt);if(created)add(content.createdTables,created);content.other=!0};for(const statement of sqlStatementsOf(sql))visit(statement);return content}export function obsoleteMigrationReason(content,context){if(content.other||content.seedTables.length>0)return;const hasTypes=content.types.length>0||content.droppedTypes.length>0,hasDrops=content.droppedIndexes.length>0,hasData=content.dataTables.length>0;if([hasTypes,hasDrops,hasData].filter(Boolean).length!==1)return;if(hasTypes)return content.types.every((type)=>context.emittedTypes.has(type))?"types":void 0;if(hasDrops)return content.droppedIndexes.every((index)=>!context.keptIndexes.has(index))?"drop-index":void 0;return content.dataTables.every((table)=>!context.keptSeedTables.has(table))?"data":void 0}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 generateFullModelMigration(options={}){await ensureDatabaseConfigLoaded();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 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.`));return ok({dialect,statements,plan:result.plan,sources})}catch(error){return err(handleError("Migration generation failed",error))}}export async function regenerateMigrationCorpus(options={}){try{const generated=await generateFullModelMigration({dialect:options.dialect,onlyExistingTables:options.onlyExistingTables});if(!generated.isOk)return err(generated.error);const{dialect,statements,plan,sources}=generated.value,dir=options.dir??migrationDirectory(dialect);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 contents=new Map,contentOf=(file)=>{let content=contents.get(file);if(!content){try{content=describeMigrationContent(readFileSync(join(dir,file),"utf8"))}catch{content={types:[],droppedTypes:[],droppedIndexes:[],createdIndexes:[],dataTables:[],seedTables:[],createdTables:[],other:!0}}contents.set(file,content)}return content},namesNoTable=new Set(existing.filter((file)=>{try{return tablesOperatedOn(readFileSync(join(dir,file),"utf8")).length===0}catch{return!1}})),seedFiles=new Set(existing.filter((file)=>contentOf(file).seedTables.length>0)),seedRootedTables=options.replaceUnmarked&&!options.onlyExistingTables?[...seedFiles].flatMap((file)=>contentOf(file).createdTables):[],rootedTables=new Set(options.replaceUnmarked||options.onlyExistingTables?seedRootedTables:historicallyRootedTables(dir,existing)),outOfScopeByTable=new Set(migrationsOutsideCorpus(dir,existing,createdTablesOf(statements))),markerDeletable=(options.replaceUnmarked||options.onlyExistingTables?existing:existing.filter((file)=>isGeneratedMigration(dir,file))).filter((file)=>!isPackageMigration(file)),certainlyKept=existing.filter((file)=>!markerDeletable.includes(file)||seedFiles.has(file)||outOfScopeByTable.has(file)&&!namesNoTable.has(file)),emittedTypes=enumTypesCreatedBy(statements),keptIndexes=new Set(certainlyKept.flatMap((file)=>contentOf(file).createdIndexes)),obsolete=new Map;for(const file of namesNoTable){const keptSeedTables=new Set(certainlyKept.filter((kept)=>migrationOrdinal(kept)<migrationOrdinal(file)).flatMap((kept)=>contentOf(kept).seedTables)),reason=obsoleteMigrationReason(contentOf(file),{emittedTypes,keptIndexes,keptSeedTables});if(reason)obsolete.set(file,reason)}const outOfScope=new Set([...outOfScopeByTable].filter((file)=>!obsolete.has(file)&&!seedFiles.has(file))),removed=markerDeletable.filter((file)=>!seedFiles.has(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)),preservedData=preserved.filter((file)=>seedFiles.has(file)),removedObsolete=removed.filter((file)=>obsolete.get(file)==="types"||obsolete.get(file)==="drop-index"),removedDataOnly=removed.filter((file)=>obsolete.get(file)==="data"),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}]:[]),writtenTables=new Set(createdTablesOf(writableGroups.flatMap((group)=>group.statements))),relocating=preservedData.filter((file)=>{const content=contentOf(file);return content.seedTables.some((table)=>writtenTables.has(table)&&!content.createdTables.includes(table))}),staying=preserved.filter((file)=>!relocating.includes(file)),historicalBoundary=existing.filter((file)=>!isGeneratedMigration(dir,file)&&!isPackageMigration(file)&&!relocating.includes(file)).reduce((max,file)=>Math.max(max,migrationOrdinal(file)),0),startAt=rootedTables.size>0?historicalBoundary+1:staying.filter((file)=>!isPackageMigration(file)).reduce((max,file)=>Math.max(max,migrationOrdinal(file)),0)+1,reservedOrdinals=new Set(staying.map(migrationOrdinal)),ordinals=allocateMigrationOrdinals(writableGroups.length+relocating.length,startAt,reservedOrdinals),files=writableGroups.map((group,index)=>({name:`${String(ordinals[index]).padStart(10,"0")}-${group.label}.sql`,statements:group.statements.length})),relocated=relocating.map((file,index)=>({from:file,to:`${String(ordinals[writableGroups.length+index]).padStart(10,"0")}-${file.replace(/^\d+[-_]/,"")}`})),corpus={dialect,models:sources.models.length,modelRoots:sources.roots,files,removed,removedObsolete,removedDataOnly,preserved,preservedOutOfScope,preservedData,relocated,unrebuildable,dir};if(options.dryRun)return ok(corpus);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(`;
33
33
  `)};
34
34
  `,content=`${GENERATED_MIGRATION_MARKER}
35
35
  ${body}`;writeFileSync(join(dir,files[index].name),(options.dialect??getDialect())==="postgres"?normalizePostgresMigrationSql(content):content)});for(const{from,to}of relocated)renameSync(join(dir,from),join(dir,to));trackIgnoredGeneratedMigrations(dir);saveMigrationSnapshot(plan,{dialect});return ok(corpus)}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{indexShape};export function droppedIndexName(statement){return/^\s*DROP\s+INDEX\s+(?:IF\s+EXISTS\s+)?(?:["'`[]?[\w$]+["'`\]]?\.)?["'`[]?([\w$]+)["'`\]]?/i.exec(statement)?.[1]?.toLowerCase()}export function indexCommittedMigrations(fileContents){const createdTables=new Set,indexes=new Map;for(const contents of fileContents)for(const statement of sqlStatementsOf(contents)){const table=createdTableName(statement);if(table){createdTables.add(table);continue}const shape=indexShape(statement);if(shape){if(!indexes.has(shape.name))indexes.set(shape.name,shape.key);continue}const dropped=droppedIndexName(statement);if(dropped)indexes.delete(dropped)}return{sql:normalizeSqlForComparison(fileContents.join(`
36
- `)),createdTables,indexShapes:new Set(indexes.values())}}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);const shape=indexShape(statement);if(shape&&index.indexShapes.has(shape.key))return!0;return index.sql.includes(normalizeSqlForComparison(statement))}export function persistedGroupLabel(label,statements){const table=/^create-(\w+)-table$/.exec(label)?.[1];if(!table||statements.some((statement)=>createdTableName(statement)))return label;const only=statements.length===1?indexShape(statements[0]):void 0;return only?`create-${only.name}-index-in-${table}`:`create-${table}-indexes`}export async function claimedMigrationNames(dir=migrationDirectory()){const ledger=await ledgerNamesForNumbering();if(ledger===null)log.debug("[migration] Could not read the migrations table; numbering from the corpus and its git history");return[...new Set([...ledger??[],...retiredMigrationNames(dir)])].sort()}async function ledgerNamesForNumbering(timeoutMs=5000){try{if(getDialect()==="sqlite"&&!usesLibsql()){const file=sqliteDatabasePath();if(file.includes(":memory:")||!existsSync(file))return null}}catch{return null}let timer;try{const query=Promise.resolve(db.unsafe("SELECT migration FROM migrations").execute()),timeout=new Promise((done)=>{timer=setTimeout(()=>done("timeout"),timeoutMs)}),rows=await Promise.race([query,timeout]);if(rows==="timeout"){query.catch(()=>{});return null}return(Array.isArray(rows)?rows:[]).map((row)=>String(row.migration??"")).filter((name)=>name.length>0)}catch(error){const message=error instanceof Error?error.message:String(error);return/no such table|does not exist|doesn't exist/i.test(message)?[]:null}finally{if(timer)clearTimeout(timer)}}export function persistGeneratedMigrations(sqlStatements,previousPlan,claimed=[]){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")).sort())committed.push(readFileSync(join(migrationsDir,f),"utf8"))}catch{}const index=indexCommittedMigrations(committed),groups=groupGeneratedStatements(sqlStatements,previousPlan);let written=0,diskFiles=[];try{diskFiles=readdirSync(migrationsDir)}catch{}const reserved=reservedMigrationOrdinals(claimed),taken=new Set([...diskFiles,...claimed]);let cursor=nextMigrationOrdinal(diskFiles,claimed);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.`);const shape=indexShape(stmt);if(shape)log.debug(`[migration] Skipping generated index "${shape.name}" - the committed corpus already builds the same index on "${shape.table}".`);return!1});if(fresh.length===0)continue;const label=persistedGroupLabel(group.label,fresh);while(taken.has(`${String(cursor).padStart(10,"0")}-${label}.sql`))cursor=nextFreeMigrationOrdinal(cursor+1,reserved);const filename=`${String(cursor).padStart(10,"0")}-${label}.sql`,filePath=join(migrationsDir,filename),body=`${fresh.map((s)=>s.trim().replace(/;\s*$/,"")).join(`;
36
+ `)),createdTables,indexShapes:new Set(indexes.values())}}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);const shape=indexShape(statement);if(shape&&index.indexShapes.has(shape.key))return!0;return index.sql.includes(normalizeSqlForComparison(statement))}export function persistedGroupLabel(label,statements){const table=/^create-(\w+)-table$/.exec(label)?.[1];if(!table||statements.some((statement)=>createdTableName(statement)))return label;const only=statements.length===1?indexShape(statements[0]):void 0;return only?`create-${only.name}-index-in-${table}`:`create-${table}-indexes`}export async function claimedMigrationNames(dir=migrationDirectory()){const ledger=await ledgerNamesForNumbering();if(ledger===null)log.debug("[migration] Could not read the migrations table; numbering from the corpus and its git history");return[...new Set([...ledger??[],...retiredMigrationNames(dir)])].sort()}async function ledgerNamesForNumbering(timeoutMs=5000){if(generationDialect&&generationDialect!==attachedDriver())return null;try{if(getDialect()==="sqlite"&&!usesLibsql()){const file=sqliteDatabasePath();if(file.includes(":memory:")||!existsSync(file))return null}}catch{return null}let timer;try{const query=Promise.resolve(db.unsafe("SELECT migration FROM migrations").execute()),timeout=new Promise((done)=>{timer=setTimeout(()=>done("timeout"),timeoutMs)}),rows=await Promise.race([query,timeout]);if(rows==="timeout"){query.catch(()=>{});return null}return(Array.isArray(rows)?rows:[]).map((row)=>String(row.migration??"")).filter((name)=>name.length>0)}catch(error){const message=error instanceof Error?error.message:String(error);return/no such table|does not exist|doesn't exist/i.test(message)?[]:null}finally{if(timer)clearTimeout(timer)}}export function persistGeneratedMigrations(sqlStatements,previousPlan,claimed=[]){if(!sqlStatements?.length)return 0;const migrationsDir=migrationDirectory();log.debug(`[migration] Writing generated migrations into ${migrationsDir}`);try{require("node:fs").mkdirSync(migrationsDir,{recursive:!0})}catch{}const committed=[];try{for(const f of readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql")).sort())committed.push(readFileSync(join(migrationsDir,f),"utf8"))}catch{}const index=indexCommittedMigrations(committed),groups=groupGeneratedStatements(sqlStatements,previousPlan);let written=0,diskFiles=[];try{diskFiles=readdirSync(migrationsDir)}catch{}const reserved=reservedMigrationOrdinals(claimed),taken=new Set([...diskFiles,...claimed]);let cursor=nextMigrationOrdinal(diskFiles,claimed);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.`);const shape=indexShape(stmt);if(shape)log.debug(`[migration] Skipping generated index "${shape.name}" - the committed corpus already builds the same index on "${shape.table}".`);return!1});if(fresh.length===0)continue;const label=persistedGroupLabel(group.label,fresh);while(taken.has(`${String(cursor).padStart(10,"0")}-${label}.sql`))cursor=nextFreeMigrationOrdinal(cursor+1,reserved);const filename=`${String(cursor).padStart(10,"0")}-${label}.sql`,filePath=join(migrationsDir,filename),body=`${fresh.map((s)=>s.trim().replace(/;\s*$/,"")).join(`;
37
37
  `)};
38
38
  `,content=`${GENERATED_MIGRATION_MARKER}
39
39
  ${body}`;writeFileSync(filePath,getDialect()==="postgres"?normalizePostgresMigrationSql(content):content);log.debug(`[migration] Wrote ${filename} (${fresh.length} stmt${fresh.length===1?"":"s"})`);taken.add(filename);written+=1;cursor=nextFreeMigrationOrdinal(cursor+1,reserved)}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},
@@ -17,6 +17,15 @@ export declare function modelStagingDir(): string;
17
17
  * the table still generates normally.
18
18
  */
19
19
  export declare function declaredTableName(source: ModelSource): string;
20
+ /**
21
+ * The framework's default models this app's schema leaves out, by the same
22
+ * rule {@link resolveModelSources} applies, without staging anything.
23
+ *
24
+ * For callers that only need to know what is excluded, such as the route
25
+ * bootstrap deciding whether a default route's action can work at all
26
+ * (stacksjs/stacks#2943).
27
+ */
28
+ export declare function excludedFrameworkModels(options?: { userRoot?: string, frameworkRoot?: string, includeFrameworkDefaults?: boolean }): ModelSource[];
20
29
  /**
21
30
  * Resolve models from userland, falling back to the framework defaults.
22
31
  *
@@ -3,4 +3,4 @@ import{existsSync,lstatSync,mkdirSync,readdirSync,readFileSync,rmSync,symlinkSyn
3
3
  ${model.package}: ${model.file}
4
4
  Only one can win, so neither package can be trusted to own its table. Rename one, or have both depend on a shared package that owns the model.`);seen.set(model.name,model)}const frameworkNames=new Set(framework.map((model)=>model.name));for(const model of packaged){if(!frameworkNames.has(model.name))continue;throw Error(`The package '${model.package}' ships a '${model.name}' model, which the framework already owns.
5
5
  ${model.file}
6
- The framework's own code reads that table, so a package cannot redefine it. A package uses the host application's model instead: this is why a package never ships its own User.`)}}export function modelStagingDir(){return path.frameworkRuntimePath("model-sources")}function stage(models){const dir=modelStagingDir();try{rmSync(dir,{recursive:!0,force:!0})}catch{}mkdirSync(dir,{recursive:!0});for(const model of models){const target=join(dir,`${model.name}.ts`);try{symlinkSync(model.file,target)}catch{try{writeFileSync(target,readFileSync(model.file))}catch{}}}return dir}export function declaredTableName(source){let contents="";try{contents=readFileSync(source.file,"utf8")}catch{}const declared=contents.match(/^\s*table:\s*['"`]([^'"`]+)['"`]/m);if(declared?.[1])return declared[1].toLowerCase();const named=contents.match(/^\s*name:\s*['"`]([^'"`]+)['"`]/m);return plural(snakeCase(named?.[1]??source.name)).toLowerCase()}function shouldIncludeFrameworkDefaults(){const flag=process.env.STACKS_INCLUDE_FRAMEWORK_MODELS;if(flag==="1"||flag==="true")return!0;if(flag==="0"||flag==="false")return!1;return config?.database?.models?.includeFrameworkDefaults===!0}export function resolveModelSources(options={}){const userRoot=options.userRoot??path.userModelsPath(),frameworkRoot=options.frameworkRoot??path.frameworkPath("defaults/app/Models"),user=collectModels(userRoot,"user"),framework=collectModels(frameworkRoot,"framework"),packaged=(options.packageRoots??packageModelRoots()).flatMap((root)=>collectModels(root.dir,"package",root.package));assertPackageModelsAreUsable(packaged,framework);if(user.length===0&&framework.length===0)return null;const useFramework=(options.includeFrameworkDefaults??shouldIncludeFrameworkDefaults())||user.length===0,contributing=useFramework?framework:[],excluded=useFramework?[]:framework,frameworkByName=new Map;for(const model of framework)frameworkByName.set(model.name,model);const shadowed=[];for(const model of user){const replaced=frameworkByName.get(model.name);if(replaced)shadowed.push({name:model.name,userFile:model.file,frameworkFile:replaced.file})}const byName=new Map;for(const model of contributing)byName.set(model.name,model);for(const model of user)byName.set(model.name,model);const models=[...byName.values()].sort((a,b)=>a.name.localeCompare(b.name)),roots=[];if(user.length>0)roots.push(userRoot);if(contributing.length>0)roots.push(frameworkRoot);const excludedTables=[...new Set([...excluded,...packaged].map(declaredTableName))].sort(),onlyUser=contributing.length===0,allFlat=models.every((m)=>basename(join(m.file,".."))===basename(onlyUser?userRoot:frameworkRoot));if(!options.forceStage&&roots.length===1&&allFlat)return{dir:roots[0],models,roots,staged:!1,shadowed,excluded,excludedTables};return{dir:stage(models),models,roots,staged:!0,shadowed,excluded,excludedTables}}export function cleanupModelStaging(){const dir=modelStagingDir();try{if(existsSync(dir)&&lstatSync(dir).isDirectory())rmSync(dir,{recursive:!0,force:!0})}catch{}}export function declaredModelTables(options={}){let packageRoots=options.packageRoots;if(!packageRoots)try{packageRoots=packageModelRoots()}catch{packageRoots=[]}const models=[...collectModels(options.userRoot??path.userModelsPath(),"user"),...collectModels(options.frameworkRoot??path.frameworkPath("defaults/app/Models"),"framework"),...packageRoots.flatMap((root)=>collectModels(root.dir,"package",root.package))];return new Set(models.map(declaredTableName))}
6
+ The framework's own code reads that table, so a package cannot redefine it. A package uses the host application's model instead: this is why a package never ships its own User.`)}}export function modelStagingDir(){return path.frameworkRuntimePath("model-sources")}function stage(models){const dir=modelStagingDir();try{rmSync(dir,{recursive:!0,force:!0})}catch{}mkdirSync(dir,{recursive:!0});for(const model of models){const target=join(dir,`${model.name}.ts`);try{symlinkSync(model.file,target)}catch{try{writeFileSync(target,readFileSync(model.file))}catch{}}}return dir}export function declaredTableName(source){let contents="";try{contents=readFileSync(source.file,"utf8")}catch{}const declared=contents.match(/^\s*table:\s*['"`]([^'"`]+)['"`]/m);if(declared?.[1])return declared[1].toLowerCase();const named=contents.match(/^\s*name:\s*['"`]([^'"`]+)['"`]/m);return plural(snakeCase(named?.[1]??source.name)).toLowerCase()}function shouldIncludeFrameworkDefaults(){const flag=process.env.STACKS_INCLUDE_FRAMEWORK_MODELS;if(flag==="1"||flag==="true")return!0;if(flag==="0"||flag==="false")return!1;return config?.database?.models?.includeFrameworkDefaults===!0}function frameworkDefaultsContribute(userModelCount,includeFrameworkDefaults){return(includeFrameworkDefaults??shouldIncludeFrameworkDefaults())||userModelCount===0}export function excludedFrameworkModels(options={}){const user=collectModels(options.userRoot??path.userModelsPath(),"user");if(frameworkDefaultsContribute(user.length,options.includeFrameworkDefaults))return[];return collectModels(options.frameworkRoot??path.frameworkPath("defaults/app/Models"),"framework")}export function resolveModelSources(options={}){const userRoot=options.userRoot??path.userModelsPath(),frameworkRoot=options.frameworkRoot??path.frameworkPath("defaults/app/Models"),user=collectModels(userRoot,"user"),framework=collectModels(frameworkRoot,"framework"),packaged=(options.packageRoots??packageModelRoots()).flatMap((root)=>collectModels(root.dir,"package",root.package));assertPackageModelsAreUsable(packaged,framework);if(user.length===0&&framework.length===0)return null;const useFramework=frameworkDefaultsContribute(user.length,options.includeFrameworkDefaults),contributing=useFramework?framework:[],excluded=useFramework?[]:framework,frameworkByName=new Map;for(const model of framework)frameworkByName.set(model.name,model);const shadowed=[];for(const model of user){const replaced=frameworkByName.get(model.name);if(replaced)shadowed.push({name:model.name,userFile:model.file,frameworkFile:replaced.file})}const byName=new Map;for(const model of contributing)byName.set(model.name,model);for(const model of user)byName.set(model.name,model);const models=[...byName.values()].sort((a,b)=>a.name.localeCompare(b.name)),roots=[];if(user.length>0)roots.push(userRoot);if(contributing.length>0)roots.push(frameworkRoot);const excludedTables=[...new Set([...excluded,...packaged].map(declaredTableName))].sort(),onlyUser=contributing.length===0,allFlat=models.every((m)=>basename(join(m.file,".."))===basename(onlyUser?userRoot:frameworkRoot));if(!options.forceStage&&roots.length===1&&allFlat)return{dir:roots[0],models,roots,staged:!1,shadowed,excluded,excludedTables};return{dir:stage(models),models,roots,staged:!0,shadowed,excluded,excludedTables}}export function cleanupModelStaging(){const dir=modelStagingDir();try{if(existsSync(dir)&&lstatSync(dir).isDirectory())rmSync(dir,{recursive:!0,force:!0})}catch{}}export function declaredModelTables(options={}){let packageRoots=options.packageRoots;if(!packageRoots)try{packageRoots=packageModelRoots()}catch{packageRoots=[]}const models=[...collectModels(options.userRoot??path.userModelsPath(),"user"),...collectModels(options.frameworkRoot??path.frameworkPath("defaults/app/Models"),"framework"),...packageRoots.flatMap((root)=>collectModels(root.dir,"package",root.package))];return new Set(models.map(declaredTableName))}
package/dist/seeder.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { faker } from '@stacksjs/faker';
1
2
  import type { Attribute, Model } from '@stacksjs/types';
2
3
  /**
3
4
  * Run application seeders from `database/seeders` in deterministic path order.
@@ -230,8 +231,13 @@ export declare interface SeederModel {
230
231
  attributes: Record<string, Attribute>
231
232
  model: Model
232
233
  filePath: string
234
+ after: string[]
233
235
  seedable: boolean
234
236
  }
237
+ /** What a factory receives as its third argument. */
238
+ export declare interface FactoryContext {
239
+ pick: (model: string, column?: string) => Promise<unknown>
240
+ }
235
241
  /** The rows a foreign key can point at, by the column that points. */
236
242
  export declare interface RelationPool {
237
243
  column: string
@@ -242,6 +248,7 @@ export declare interface ParentRelation {
242
248
  model: string
243
249
  column: string
244
250
  }
251
+ declare type FactoryFunction = (faker: unknown, attributes: Readonly<Record<string, unknown>>, context: FactoryContext) => unknown;
245
252
  /**
246
253
  * Base contract for application seeders stored in `database/seeders`.
247
254
  *
@@ -253,6 +260,10 @@ export declare abstract class Seeder {
253
260
  static tags: readonly string[];
254
261
  abstract run(): Promise<void> | void;
255
262
  }
263
+ /** A `pick()` that could not be satisfied. Never replaced with a default. */
264
+ export declare class SeedReferenceError extends Error {
265
+ name: string;
266
+ }
256
267
  // Legacy exports for backwards compatibility
257
268
  export { seed as runSeeders };
258
269
  export { freshSeed as freshWithSeed };
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=[];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={},generated={};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,generated)}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;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=[],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);if(unique.length>0){for(let attempt=0;attempt<3&&!claimUniqueValues(record,unique,used,0);attempt++)record=await generateRecord(model.attributes,model.name,!1);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}}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)){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=[];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 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{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};
@@ -60,7 +60,7 @@ import{log}from"@stacksjs/logging";import{db}from"./utils";import{resolvedDataba
60
60
  upvoteable_id INTEGER NOT NULL,
61
61
  upvoteable_type VARCHAR(255) NOT NULL,
62
62
  ${createdAt(sql)}
63
- )`}export function traitTableColumnGuarantees(sql){return[{table:"taggables",column:"taggable_id",definition:`INTEGER NOT NULL DEFAULT ${UNSCOPED_OWNER_ID}`},{table:"categorizables",column:"categorizable_id",definition:`INTEGER NOT NULL DEFAULT ${UNSCOPED_OWNER_ID}`},{table:"commentables",column:"author_name",definition:"VARCHAR(255)"},{table:"commentables",column:"author_email",definition:"VARCHAR(255)"}]}export function traitTableIndexSql(){return["CREATE INDEX IF NOT EXISTS chat_messages_thread_index ON chat_messages (conversation_id, id)","CREATE INDEX IF NOT EXISTS chat_conversations_first_index ON chat_conversations (scope, participant_type, first_id)","CREATE INDEX IF NOT EXISTS chat_conversations_second_index ON chat_conversations (scope, participant_type, second_id)","CREATE INDEX IF NOT EXISTS commentables_owner_index ON commentables (commentables_type, commentables_id)","CREATE INDEX IF NOT EXISTS commentables_status_index ON commentables (status)","CREATE UNIQUE INDEX IF NOT EXISTS taggables_owner_slug_unique ON taggables (taggable_type, taggable_id, slug)","CREATE UNIQUE INDEX IF NOT EXISTS categorizables_owner_slug_unique ON categorizables (categorizable_type, categorizable_id, slug)","CREATE INDEX IF NOT EXISTS commentable_upvotes_target_index ON commentable_upvotes (upvoteable_type, upvoteable_id)","CREATE UNIQUE INDEX IF NOT EXISTS commentable_upvotes_user_unique ON commentable_upvotes (upvoteable_type, upvoteable_id, user_id)","CREATE UNIQUE INDEX IF NOT EXISTS taggable_models_owner_unique ON taggable_models (tag_id, taggable_id, taggable_type)","CREATE UNIQUE INDEX IF NOT EXISTS categorizable_models_owner_unique ON categorizable_models (category_id, categorizable_id, categorizable_type)"]}export async function likeableTargets(){const{path}=await import("@stacksjs/path"),{globSync}=await import("@stacksjs/storage"),{getTableName}=await import("@stacksjs/model-meta"),{getLikeableForeignKey,getUpvoteTableName}=await import("./drivers/helpers"),modelFiles=globSync([path.userModelsPath("*.ts"),path.defaultsPath("app/Models/**/*.ts")],{absolute:!0}),targets=new Map;for(const modelFile of modelFiles){let model;try{model=(await import(modelFile)).default}catch{continue}if(!model?.traits?.likeable)continue;const tableName=await getTableName(model,modelFile);if(!tableName)continue;const table=getUpvoteTableName(model,tableName);if(!table||!/^[a-z_]\w*$/i.test(table))continue;const foreignKey=getLikeableForeignKey(model,tableName);if(!/^[a-z_]\w*$/i.test(foreignKey))continue;targets.set(table,{table,foreignKey})}return[...targets.values()]}export function messagingTableSql(sql){return[`CREATE TABLE IF NOT EXISTS chat_conversations (
63
+ )`}export function traitTableColumnGuarantees(sql){return[{table:"chat_messages",column:"attachments",definition:"TEXT"},{table:"taggables",column:"taggable_id",definition:`INTEGER NOT NULL DEFAULT ${UNSCOPED_OWNER_ID}`},{table:"categorizables",column:"categorizable_id",definition:`INTEGER NOT NULL DEFAULT ${UNSCOPED_OWNER_ID}`},{table:"commentables",column:"author_name",definition:"VARCHAR(255)"},{table:"commentables",column:"author_email",definition:"VARCHAR(255)"}]}export function traitTableIndexSql(){return["CREATE INDEX IF NOT EXISTS chat_messages_thread_index ON chat_messages (conversation_id, id)","CREATE INDEX IF NOT EXISTS chat_conversations_first_index ON chat_conversations (scope, participant_type, first_id)","CREATE INDEX IF NOT EXISTS chat_conversations_second_index ON chat_conversations (scope, participant_type, second_id)","CREATE INDEX IF NOT EXISTS commentables_owner_index ON commentables (commentables_type, commentables_id)","CREATE INDEX IF NOT EXISTS commentables_status_index ON commentables (status)","CREATE UNIQUE INDEX IF NOT EXISTS taggables_owner_slug_unique ON taggables (taggable_type, taggable_id, slug)","CREATE UNIQUE INDEX IF NOT EXISTS categorizables_owner_slug_unique ON categorizables (categorizable_type, categorizable_id, slug)","CREATE INDEX IF NOT EXISTS commentable_upvotes_target_index ON commentable_upvotes (upvoteable_type, upvoteable_id)","CREATE UNIQUE INDEX IF NOT EXISTS commentable_upvotes_user_unique ON commentable_upvotes (upvoteable_type, upvoteable_id, user_id)","CREATE UNIQUE INDEX IF NOT EXISTS taggable_models_owner_unique ON taggable_models (tag_id, taggable_id, taggable_type)","CREATE UNIQUE INDEX IF NOT EXISTS categorizable_models_owner_unique ON categorizable_models (category_id, categorizable_id, categorizable_type)"]}export async function likeableTargets(){const{path}=await import("@stacksjs/path"),{globSync}=await import("@stacksjs/storage"),{getTableName}=await import("@stacksjs/model-meta"),{getLikeableForeignKey,getUpvoteTableName}=await import("./drivers/helpers"),modelFiles=globSync([path.userModelsPath("*.ts"),path.defaultsPath("app/Models/**/*.ts")],{absolute:!0}),targets=new Map;for(const modelFile of modelFiles){let model;try{model=(await import(modelFile)).default}catch{continue}if(!model?.traits?.likeable)continue;const tableName=await getTableName(model,modelFile);if(!tableName)continue;const table=getUpvoteTableName(model,tableName);if(!table||!/^[a-z_]\w*$/i.test(table))continue;const foreignKey=getLikeableForeignKey(model,tableName);if(!/^[a-z_]\w*$/i.test(foreignKey))continue;targets.set(table,{table,foreignKey})}return[...targets.values()]}export function messagingTableSql(sql){return[`CREATE TABLE IF NOT EXISTS chat_conversations (
64
64
  id VARCHAR(64) PRIMARY KEY,
65
65
  scope VARCHAR(255) NOT NULL,
66
66
  participant_type VARCHAR(255) NOT NULL,
@@ -72,6 +72,10 @@ import{log}from"@stacksjs/logging";import{db}from"./utils";import{resolvedDataba
72
72
  conversation_id VARCHAR(64) NOT NULL REFERENCES chat_conversations(id) ON DELETE CASCADE,
73
73
  sender_id BIGINT NOT NULL,
74
74
  body TEXT NOT NULL,
75
+ -- What the message carries besides its text, as the JSON the messaging
76
+ -- trait validates (orm/src/messaging.ts). Null for a message that is
77
+ -- only words, which is nearly all of them.
78
+ attachments TEXT,
75
79
  client_key VARCHAR(100) NOT NULL,
76
80
  created_at ${sql.datetime} NOT NULL,
77
81
  read_at ${sql.nullableTimestamp},
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.261",
5
+ "version": "0.75.263",
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.261",
70
- "@stacksjs/env": "0.75.261",
71
- "@stacksjs/error-handling": "0.75.261",
72
- "@stacksjs/faker": "0.75.261",
73
- "@stacksjs/features": "0.75.261",
74
- "@stacksjs/logging": "0.75.261",
75
- "@stacksjs/model-meta": "0.75.261",
76
- "@stacksjs/path": "0.75.261",
77
- "@stacksjs/query-builder": "0.75.261",
78
- "@stacksjs/security": "0.75.261",
79
- "@stacksjs/storage": "0.75.261",
80
- "@stacksjs/strings": "0.75.261",
69
+ "@stacksjs/config": "0.75.263",
70
+ "@stacksjs/env": "0.75.263",
71
+ "@stacksjs/error-handling": "0.75.263",
72
+ "@stacksjs/faker": "0.75.263",
73
+ "@stacksjs/features": "0.75.263",
74
+ "@stacksjs/logging": "0.75.263",
75
+ "@stacksjs/model-meta": "0.75.263",
76
+ "@stacksjs/path": "0.75.263",
77
+ "@stacksjs/query-builder": "0.75.263",
78
+ "@stacksjs/security": "0.75.263",
79
+ "@stacksjs/storage": "0.75.263",
80
+ "@stacksjs/strings": "0.75.263",
81
81
  "@stacksjs/ts-validation": "^0.5.6",
82
82
  "bun-query-builder": "^0.3.9",
83
83
  "dynamodb-tooling": "^0.3.6"
84
84
  },
85
85
  "devDependencies": {
86
- "@stacksjs/cli": "0.75.261",
87
- "@stacksjs/router": "0.75.261",
88
- "@stacksjs/utils": "0.75.261",
86
+ "@stacksjs/cli": "0.75.263",
87
+ "@stacksjs/router": "0.75.263",
88
+ "@stacksjs/utils": "0.75.263",
89
89
  "better-dx": "^0.2.24"
90
90
  }
91
91
  }