@stacksjs/database 0.75.186 → 0.75.188

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.
@@ -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}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,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(`
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(`
@@ -18,7 +18,7 @@ Create it yourself with: ${manualCreateHint(target)}`);if(result.created)log.su
18
18
  migration TEXT NOT NULL UNIQUE,
19
19
  executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
20
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){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(`
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(`
22
22
  SELECT t.typname AS name
23
23
  FROM pg_type t
24
24
  JOIN pg_namespace n ON n.oid = t.typnamespace
@@ -114,6 +114,13 @@ export declare function traitTableIndexSql(): string[];
114
114
  export declare function likeableTargets(): Promise<Array<{ table: string, foreignKey: string }>>;
115
115
  /** Persistent direct messaging. Owned by migrations, never created during requests. */
116
116
  export declare function messagingTableSql(sql: SqlHelpers): string[];
117
+ /**
118
+ * Native tables needed by model-owned foreign keys must precede the corpus.
119
+ * Only the framework's own tables are eligible: an application's CREATE stays
120
+ * authoritative. Use the same DDL as the post-migration guarantee and include
121
+ * native parents before their dependents, without creating unrelated tables.
122
+ */
123
+ export declare function traitTablePrerequisiteSql(corpus: string, sql: SqlHelpers): string[];
117
124
  /**
118
125
  * Create the polymorphic trait tables. Idempotent (`IF NOT EXISTS`), so it's
119
126
  * safe to run on every `buddy migrate`.
@@ -1,4 +1,4 @@
1
- import{log}from"@stacksjs/logging";import{db}from"./utils";import{resolvedDatabaseDriver}from"./dialect";import{sqlHelpers}from"./sql-helpers";import{indexSqlForDialect,isDuplicateColumnError,isDuplicateIndexError}from"./dialect";export{indexSqlForDialect}from"./dialect";function createdAt(sql){return`created_at ${sql.datetime}`}function updatedAt(sql){return`updated_at ${sql.nullableTimestamp}`}export function traitTableNames(){return["chat_messages","chat_conversations","commentables","taggables","categorizables","commentable_upvotes","taggable_models","categorizable_models"]}export const UNSCOPED_OWNER_ID=0;export function commentablesTableSql(sql){const{pkColumn,boolTrue,utcNow}=sql;return`CREATE TABLE IF NOT EXISTS commentables (
1
+ import{log}from"@stacksjs/logging";import{db}from"./utils";import{resolvedDatabaseDriver}from"./dialect";import{sqlHelpers}from"./sql-helpers";import{indexSqlForDialect,isDuplicateColumnError,isDuplicateIndexError}from"./dialect";import{SQL_IDENTIFIER,sqlStatementsOf}from"./sql-statements";export{indexSqlForDialect}from"./dialect";function createdAt(sql){return`created_at ${sql.datetime}`}function updatedAt(sql){return`updated_at ${sql.nullableTimestamp}`}export function traitTableNames(){return["chat_messages","chat_conversations","commentables","taggables","categorizables","commentable_upvotes","taggable_models","categorizable_models"]}export const UNSCOPED_OWNER_ID=0;export function commentablesTableSql(sql){const{pkColumn,boolTrue,utcNow}=sql;return`CREATE TABLE IF NOT EXISTS commentables (
2
2
  ${pkColumn},
3
3
  title VARCHAR(255) NOT NULL,
4
4
  body TEXT NOT NULL,
@@ -76,4 +76,4 @@ import{log}from"@stacksjs/logging";import{db}from"./utils";import{resolvedDataba
76
76
  created_at ${sql.datetime} NOT NULL,
77
77
  read_at ${sql.nullableTimestamp},
78
78
  UNIQUE (conversation_id, sender_id, client_key)
79
- )`]}export async function migrateTraitTables(options={}){const dbDriver=resolvedDatabaseDriver(),sql=sqlHelpers(dbDriver);if(options.verbose)log.info(`Creating polymorphic trait tables for ${dbDriver}...`);try{for(const statement of messagingTableSql(sql))await db.unsafe(statement).execute();if(options.verbose)log.info("Creating commentables table...");await db.unsafe(commentablesTableSql(sql)).execute();if(options.verbose)log.info("Creating taggables table...");await db.unsafe(taggablesTableSql(sql)).execute();if(options.verbose)log.info("Creating categorizables table...");await db.unsafe(categorizablesTableSql(sql)).execute();if(options.verbose)log.info("Creating commentable_upvotes table...");await db.unsafe(commentableUpvotesTableSql(sql)).execute();if(options.verbose)log.info("Creating taggable_models pivot...");await db.unsafe(taggableModelsTableSql(sql)).execute();if(options.verbose)log.info("Creating categorizable_models pivot...");await db.unsafe(categorizableModelsTableSql(sql)).execute();try{for(const{table,foreignKey}of await likeableTargets()){if(options.verbose)log.info(`Creating ${table} table...`);await db.unsafe(likesTableSql(sql,table,foreignKey)).execute()}}catch(error){log.debug(`[trait-tables] Skipped likeable tables: ${error instanceof Error?error.message:String(error)}`)}for(const{table,column,definition}of traitTableColumnGuarantees(sql))try{await db.unsafe(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`).execute();if(options.verbose)log.info(`Added missing ${table}.${column}`)}catch(error){if(!isDuplicateColumnError(error))throw error}for(const statement of traitTableIndexSql())try{await db.unsafe(indexSqlForDialect(statement,dbDriver)).execute()}catch(error){if(!isDuplicateIndexError(error))throw error}if(options.verbose)log.success("Polymorphic trait tables created");return{success:!0}}catch(error){const message=error instanceof Error?error.message:String(error);log.error(`Failed to create polymorphic trait tables: ${message}`);return{success:!1,error:message}}}
79
+ )`]}export function traitTablePrerequisiteSql(corpus,sql){const statements=sqlStatementsOf(corpus).map((statement)=>statement.replace(/'(?:''|[^'])*'/g,"''")),create=new RegExp(`\\bCREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${SQL_IDENTIFIER}\\s*\\(`,"i"),references=(statement)=>[...statement.matchAll(new RegExp(`\\bREFERENCES\\s+${SQL_IDENTIFIER}\\s*\\(`,"gi"))].map((match)=>match[1].toLowerCase()),declared=new Set(statements.map((statement)=>create.exec(statement)?.[1]?.toLowerCase()).filter(Boolean)),native=new Map([...messagingTableSql(sql),commentablesTableSql(sql),taggablesTableSql(sql),categorizablesTableSql(sql),commentableUpvotesTableSql(sql),taggableModelsTableSql(sql),categorizableModelsTableSql(sql)].map((statement)=>[create.exec(statement)[1].toLowerCase(),statement])),plan=[],visited=new Set;function visit(table){if(declared.has(table)||visited.has(table)||!native.has(table))return;visited.add(table);const statement=native.get(table);for(const parent of references(statement))visit(parent);plan.push(statement)}for(const statement of statements)for(const table of references(statement))visit(table);return plan}export async function migrateTraitTables(options={}){const dbDriver=resolvedDatabaseDriver(),sql=sqlHelpers(dbDriver);if(options.verbose)log.info(`Creating polymorphic trait tables for ${dbDriver}...`);try{for(const statement of messagingTableSql(sql))await db.unsafe(statement).execute();if(options.verbose)log.info("Creating commentables table...");await db.unsafe(commentablesTableSql(sql)).execute();if(options.verbose)log.info("Creating taggables table...");await db.unsafe(taggablesTableSql(sql)).execute();if(options.verbose)log.info("Creating categorizables table...");await db.unsafe(categorizablesTableSql(sql)).execute();if(options.verbose)log.info("Creating commentable_upvotes table...");await db.unsafe(commentableUpvotesTableSql(sql)).execute();if(options.verbose)log.info("Creating taggable_models pivot...");await db.unsafe(taggableModelsTableSql(sql)).execute();if(options.verbose)log.info("Creating categorizable_models pivot...");await db.unsafe(categorizableModelsTableSql(sql)).execute();try{for(const{table,foreignKey}of await likeableTargets()){if(options.verbose)log.info(`Creating ${table} table...`);await db.unsafe(likesTableSql(sql,table,foreignKey)).execute()}}catch(error){log.debug(`[trait-tables] Skipped likeable tables: ${error instanceof Error?error.message:String(error)}`)}for(const{table,column,definition}of traitTableColumnGuarantees(sql))try{await db.unsafe(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`).execute();if(options.verbose)log.info(`Added missing ${table}.${column}`)}catch(error){if(!isDuplicateColumnError(error))throw error}for(const statement of traitTableIndexSql())try{await db.unsafe(indexSqlForDialect(statement,dbDriver)).execute()}catch(error){if(!isDuplicateIndexError(error))throw error}if(options.verbose)log.success("Polymorphic trait tables created");return{success:!0}}catch(error){const message=error instanceof Error?error.message:String(error);log.error(`Failed to create polymorphic trait tables: ${message}`);return{success:!1,error:message}}}
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.186",
5
+ "version": "0.75.188",
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.186",
70
- "@stacksjs/env": "0.75.186",
71
- "@stacksjs/error-handling": "0.75.186",
72
- "@stacksjs/faker": "0.75.186",
73
- "@stacksjs/features": "0.75.186",
74
- "@stacksjs/logging": "0.75.186",
75
- "@stacksjs/model-meta": "0.75.186",
76
- "@stacksjs/path": "0.75.186",
77
- "@stacksjs/query-builder": "0.75.186",
78
- "@stacksjs/security": "0.75.186",
79
- "@stacksjs/storage": "0.75.186",
80
- "@stacksjs/strings": "0.75.186",
69
+ "@stacksjs/config": "0.75.188",
70
+ "@stacksjs/env": "0.75.188",
71
+ "@stacksjs/error-handling": "0.75.188",
72
+ "@stacksjs/faker": "0.75.188",
73
+ "@stacksjs/features": "0.75.188",
74
+ "@stacksjs/logging": "0.75.188",
75
+ "@stacksjs/model-meta": "0.75.188",
76
+ "@stacksjs/path": "0.75.188",
77
+ "@stacksjs/query-builder": "0.75.188",
78
+ "@stacksjs/security": "0.75.188",
79
+ "@stacksjs/storage": "0.75.188",
80
+ "@stacksjs/strings": "0.75.188",
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.186",
87
- "@stacksjs/router": "0.75.186",
88
- "@stacksjs/utils": "0.75.186",
86
+ "@stacksjs/cli": "0.75.188",
87
+ "@stacksjs/router": "0.75.188",
88
+ "@stacksjs/utils": "0.75.188",
89
89
  "better-dx": "^0.2.24"
90
90
  }
91
91
  }