@stacksjs/database 0.75.151 → 0.75.152

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{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,notificationTablesMissingCreateStatements}from"./notification-tables";import{traitTableNames}from"./trait-tables";import{AUTH_TABLES}from"./auth-tables";import{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{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{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{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{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(`
@@ -11,14 +11,14 @@ ${missing.join(`
11
11
  )`);const insert=writeDb.prepare("INSERT OR IGNORE INTO migrations (migration) VALUES (?)");for(const migration of droppedMigrations)insert.run(migration);const unrecord=writeDb.prepare("DELETE FROM migrations WHERE migration = ?");for(const migration of replayMigrations)unrecord.run(migration)}finally{writeDb.close()}}catch(e){log.debug(`[migration] Could not record dropped migrations as executed: ${e}`)}return quarantined}function mayCreateMissingDatabase(){const signal=process.env.STACKS_CREATE_DATABASE;if(signal==="1")return!0;if(signal==="0")return!1;const policy=String(process.env.DB_CREATE_DATABASE||"").toLowerCase();return!(policy==="never"||policy==="false"||policy==="0")}function describeProbeFailure(target,kind,error){const where=describeTarget(target),detail=error instanceof Error?error.message:String(error??"");switch(kind){case"missing-role":return`The user "${target.username}" does not exist on ${where}. Set DB_USERNAME to a role that exists, or create it with: createuser -s ${target.username}`;case"auth-failed":return`Authentication failed for user "${target.username}" on ${where}. Check DB_USERNAME and DB_PASSWORD.`;case"server-unreachable":return`Could not reach the database server at ${target.host}:${target.port}. Check that it is running and that DB_HOST and DB_PORT are correct.`;case"timeout":return`Timed out connecting to the database server at ${target.host}:${target.port}. ${detail}`;case"permission-denied":return`The user "${target.username}" is not allowed to connect to "${target.database}" on ${where}.`;default:return`Could not connect to the database "${target.database}" on ${where}. ${detail}`}}async function ensureDatabaseExists(){const target=resolveConnectionTarget();if(!target)return;const probe=await probeTargetDatabase(target);if(probe.ok)return;if(probe.kind!=="missing-database")throw Error(describeProbeFailure(target,probe.kind,probe.error));if(!mayCreateMissingDatabase())throw Error(`The database "${target.database}" does not exist on ${describeTarget(target)}. Create it with: ${manualCreateHint(target)}`);const result=await createDatabase(target);if(!result.created&&result.error)throw Error(`The database "${target.database}" does not exist on ${describeTarget(target)} and it could not be created automatically. ${describeProbeFailure(target,result.kind,result.error)}
12
12
  Create it yourself with: ${manualCreateHint(target)}`);if(result.created)log.success(`Created database "${target.database}" on ${target.host}:${target.port}`)}let databaseBootstrapped=!1;export async function ensureDatabaseReady(){await ensureDatabaseConfigLoaded();if(databaseBootstrapped)return;await ensureDatabaseExists();databaseBootstrapped=!0}export function resetDatabaseBootstrapCache(){databaseBootstrapped=!1}export async function featureGatedMigrationFiles(dir=migrationDirectory()){const gated=[];try{const{appModelClaimsTable,FEATURE_NAMES,migrationFeature,migrationTable}=await import("@stacksjs/features"),{feature:isFeatureEnabled}=await import("@stacksjs/config");if(!existsSync(dir))return gated;const disabledFeatures=new Set(FEATURE_NAMES.filter((f)=>!isFeatureEnabled(f)));if(disabledFeatures.size===0)return gated;for(const file of readdirSync(dir).filter((f)=>f.endsWith(".sql"))){const owner=migrationFeature(file);if(!owner||!disabledFeatures.has(owner))continue;const table=migrationTable(file);if(table&&appModelClaimsTable(table))continue;gated.push({file,feature:owner,table})}}catch{}return gated}export async function featureGateForLedger(dir=migrationDirectory(getDialect())){const gated=await featureGatedMigrationFiles(dir),tables=new Set(gated.flatMap((entry)=>entry.table?[entry.table.toLowerCase()]:[]));return{exclude:new Set(gated.map((entry)=>entry.file)),rewrite:(sql)=>tables.size>0?withoutGatedStatements(sql,tables):sql}}async function hideDisabledFeatureMigrations(){const hidden=[];try{const fs=await import("node:fs/promises"),migrationsDir=migrationDirectory();if(!existsSync(migrationsDir))return hidden;const gated=await featureGatedMigrationFiles(migrationsDir);if(gated.length===0)return hidden;const files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql")),gatedTables=new Set;for(const{file,feature,table}of gated){if(table)gatedTables.add(table.toLowerCase());const original=join(migrationsDir,file),hiddenPath=`${original}.disabled`;await fs.rename(original,hiddenPath);hidden.push({original,hidden:hiddenPath,feature})}for(const file of files){const filePath=join(migrationsDir,file);if(!existsSync(filePath))continue;const sql=readFileSync(filePath,"utf8"),filtered=withoutGatedStatements(sql,gatedTables);if(filtered===sql)continue;const backup=`${filePath}.ungated`;await fs.rename(filePath,backup);writeFileSync(filePath,filtered);hidden.push({original:filePath,hidden:backup,feature:"mixed"})}if(hidden.length>0){const summary=Object.entries(hidden.reduce((acc,h)=>{acc[h.feature]=(acc[h.feature]??0)+1;return acc},{})).map(([f,n])=>`${f}: ${n}`).join(", ");log.info(`[migration] Skipping ${hidden.length} migration(s) for disabled features (${summary}). Run \`./buddy <feature>:install\` to enable.`)}}catch{}return hidden}function afterCommonTableExpressions(statement){if(!/^\s*WITH\b/i.test(statement))return statement;let depth=0;for(let index=0;index<statement.length;index++){const char=statement[index];if(char==="("){depth++;continue}if(char!==")")continue;depth--;if(depth!==0)continue;const rest=statement.slice(index+1);if(/^\s*,/.test(rest))continue;return rest}return statement}export function statementTable(statement){const body=afterCommonTableExpressions(statement),patterns=[/^\s*ALTER\s+TABLE\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?([a-z0-9_]+)["`]?/i,/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["`]?([a-z0-9_]+)["`]?/i,/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?[a-z0-9_]+["`]?\s+ON\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*UPDATE\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*INSERT\s+(?:OR\s+\w+\s+)?INTO\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*DELETE\s+FROM\s+["`]?([a-z0-9_]+)["`]?/i];for(const pattern of patterns){const match=pattern.exec(body);if(match)return match[1].toLowerCase()}return null}export function statementReferencesTable(statement,table){const escaped=table.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return new RegExp(`\\b(?:FROM|JOIN|UPDATE|INTO)\\s+["\`\\[]?${escaped}["\`\\]]?\\b`,"i").test(statement)}function rebuiltTable(statement){const rename=/^\s*ALTER\s+TABLE\s+["`]?([a-z0-9_]+)["`]?\s+RENAME\s+TO\s+["`]?([a-z0-9_]+)["`]?/i.exec(statement);if(rename?.[2])return rename[2].toLowerCase();const table=statementTable(statement);if(table?.startsWith("_qb_tmp_"))return table.slice(8);return null}export function withoutGatedStatements(sql,gated){if(gated.size===0)return sql;const statements=sql.split(";").map((s)=>s.trim()).filter(Boolean),kept=statements.filter((statement)=>{const table=rebuiltTable(statement)??statementTable(statement);if(table&&gated.has(table))return!1;for(const candidate of gated)if(statementReferencesTable(statement,candidate))return!1;return!0});if(kept.length===statements.length)return sql;return kept.length===0?"":`${kept.join(`;
13
13
  `)};
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 "),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(`;
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)||/\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.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 (
17
17
  id INTEGER PRIMARY KEY AUTOINCREMENT,
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 preflightTables=notificationTablesMissingCreateStatements(migrationSql);if(preflightTables.length>0){const preflight=await migrateNotificationTables({tables:preflightTables});if(!preflight.success)throw Error(preflight.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","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){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","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
@@ -19,21 +19,39 @@ export declare function notificationPreferencesTableSql(sql: SqlHelpers): string
19
19
  */
20
20
  export declare function notificationDeliveriesTableSql(sql: SqlHelpers): string;
21
21
  /**
22
- * Return framework notification tables that a migration corpus does not
23
- * create itself.
22
+ * Return the framework notification tables a migration corpus needs created
23
+ * before it runs, each with the reason it is in the list.
24
24
  *
25
25
  * Old generated corpora can contain UPDATE or SQLite rebuild statements for
26
26
  * these tables without containing their original CREATE TABLE migrations. A
27
27
  * fresh database therefore needs the framework guarantee before that corpus
28
28
  * runs. When the corpus does declare a table, the model-owned CREATE remains
29
29
  * authoritative and the framework must not pre-create a narrower shape.
30
+ *
31
+ * Carrying the reason out of here is the point. Both branches below mean
32
+ * "create this one now", but only the second means the corpus has a shape of
33
+ * its own that is about to lose, and this is the only place that fact is
34
+ * known: by the time the guarantee is executing DDL, a narrow table and a
35
+ * table nobody else wanted look identical (stacksjs/stacks#2896).
30
36
  */
31
- export declare function notificationTablesMissingCreateStatements(sql: string): NotificationTableName[];
37
+ export declare function notificationTablePreflight(sql: string): NotificationTablePreflight[];
38
+ /**
39
+ * What to tell an application whose own migrations create a table the
40
+ * framework is about to create first, or null when the create is routine.
41
+ *
42
+ * Pure, so the condition and the wording are both testable without a database,
43
+ * and so the decision lives beside the classification that produces it.
44
+ */
45
+ export declare function preflightShapeWarning(entry: NotificationTablePreflight): string | null;
32
46
  /**
33
47
  * Create the notification + notification_preferences tables. Idempotent
34
48
  * (`IF NOT EXISTS`), so it's safe to run on every `buddy migrate`.
49
+ *
50
+ * `tables` narrows the work to a subset. Pass {@link NotificationTablePreflight}
51
+ * entries rather than bare names when the corpus has already been read: the
52
+ * reason travels with the name and the shape check can then say what it knows.
35
53
  */
36
- export declare function migrateNotificationTables(options?: { verbose?: boolean, tables?: readonly NotificationTableName[] }): Promise<{ success: boolean, error?: string }>;
54
+ export declare function migrateNotificationTables(options?: { verbose?: boolean, tables?: readonly (NotificationTableName | NotificationTablePreflight)[] }): Promise<{ success: boolean, error?: string }>;
37
55
  /**
38
56
  * The foreign keys on `notifications.user_id` and
39
57
  * `notification_deliveries.user_id`, added after the model batch has run.
@@ -65,5 +83,22 @@ export declare function migrateNotificationTables(options?: { verbose?: boolean,
65
83
  */
66
84
  export declare function ensureNotificationForeignKeys(options?: { verbose?: boolean }): Promise<void>;
67
85
  export declare const notificationTableNames: readonly ['notifications', 'notification_preferences', 'notification_deliveries'];
86
+ export declare interface NotificationTablePreflight {
87
+ table: NotificationTableName
88
+ reason: NotificationTablePreflightReason
89
+ }
68
90
  declare type SqlHelpers = ReturnType<typeof sqlHelpers>;
69
91
  export type NotificationTableName = typeof notificationTableNames[number];
92
+ /**
93
+ * Why a framework notification table has to be created before the corpus runs.
94
+ *
95
+ * `no-create-statement`: the corpus never creates it, so the framework owns
96
+ * the table outright and pre-creating it is simply correct.
97
+ *
98
+ * `referenced-before-create`: the corpus does create it, but only after an
99
+ * earlier statement reads it, so a fresh database cannot replay the history
100
+ * unless the table is already standing. Pre-creating it is still necessary and
101
+ * also lossy, because the corpus's own `CREATE TABLE IF NOT EXISTS` then runs
102
+ * against a table that exists and does nothing at all.
103
+ */
104
+ export type NotificationTablePreflightReason = 'no-create-statement' | 'referenced-before-create';
@@ -29,4 +29,4 @@ import{log}from"@stacksjs/logging";import{env as envVars}from"@stacksjs/env";imp
29
29
  sent_at ${nullableTimestamp},
30
30
  created_at ${datetime} DEFAULT ${utcNow},
31
31
  updated_at ${nullableTimestamp}
32
- )`}async function createIndex(statement,dialect){try{await db.unsafe(indexSqlForDialect(statement,dialect)).execute()}catch(error){if(!isDuplicateIndexError(error))throw error}}const REQUIRED_COLUMNS={notifications:["id","user_id","type","data","read_at"],notification_preferences:["id","user_id","channel","enabled","category"],notification_deliveries:["id","user_id","channel","recipient","body","status"]};export const notificationTableNames=["notifications","notification_preferences","notification_deliveries"];export function notificationTablesMissingCreateStatements(sql){return notificationTableNames.filter((table)=>{const escaped=table.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),create=new RegExp(`\\bCREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?["\\x60\\[]?${escaped}["\\x60\\]]?\\s*\\(`,"i").exec(sql);if(!create)return!0;const referencePattern=new RegExp(`\\b${escaped}\\b`,"i"),beforeCreate=sql.slice(0,create.index);return referencePattern.test(beforeCreate)})}async function warnOnShapeMismatch(table){const required=REQUIRED_COLUMNS[table];if(!required)return;try{const rows=await db.unsafe(`SELECT * FROM ${table} WHERE 1 = 0`).execute(),known=Array.isArray(rows)&&rows.length>0?Object.keys(rows[0]):null,missing=[];for(const column of required){if(known){if(!known.includes(column))missing.push(column);continue}try{await db.unsafe(`SELECT ${column} FROM ${table} WHERE 1 = 0`).execute()}catch{missing.push(column)}}if(missing.length>0)log.warn(`[notifications] "${table}" already exists without ${missing.join(", ")}. CREATE TABLE IF NOT EXISTS left it untouched, so the notification driver will fail on those columns. Something else owns this table - either rename it, or stop using the framework's notification tables.`)}catch{}}export async function migrateNotificationTables(options={}){const dbDriver=getDbDriver(),sql=sqlHelpers(dbDriver),tables=new Set(options.tables??notificationTableNames);if(options.verbose)log.info(`Creating notification tables for ${dbDriver}...`);try{if(tables.has("notifications")){if(options.verbose)log.info("Creating notifications table...");await warnOnShapeMismatch("notifications");await db.unsafe(notificationsTableSql(sql)).execute();await createIndex("CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_id)",dbDriver)}if(tables.has("notification_preferences")){if(options.verbose)log.info("Creating notification_preferences table...");await warnOnShapeMismatch("notification_preferences");await db.unsafe(notificationPreferencesTableSql(sql)).execute();await createIndex("CREATE INDEX IF NOT EXISTS idx_notification_preferences_user ON notification_preferences (user_id)",dbDriver)}if(tables.has("notification_deliveries")){if(options.verbose)log.info("Creating notification deliveries table...");await warnOnShapeMismatch("notification_deliveries");await db.unsafe(notificationDeliveriesTableSql(sql)).execute();await createIndex("CREATE INDEX IF NOT EXISTS idx_notification_deliveries_channel ON notification_deliveries (channel)",dbDriver);await createIndex("CREATE INDEX IF NOT EXISTS idx_notification_deliveries_status ON notification_deliveries (status)",dbDriver)}if(options.verbose)log.success("Notification tables created");return{success:!0}}catch(error){const message=error instanceof Error?error.message:String(error);log.error(`Failed to create notification tables: ${message}`);return{success:!1,error:message}}}export async function ensureNotificationForeignKeys(options={}){const dbDriver=getDbDriver();if(dialectCapabilities(dbDriver).wire==="sqlite")return;for(const table of["notifications","notification_deliveries"]){const statements=[`ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS "${table}_user_id_fk"`,`ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS "${table}_user_id_fkey"`,`ALTER TABLE ${table} ADD CONSTRAINT "${table}_user_id_fk" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE`];for(const statement of statements)try{await db.unsafe(statement).execute()}catch(error){if(options.verbose)log.debug(`[notification-tables] Skipped: ${statement} (${error instanceof Error?error.message:String(error)})`)}}}
32
+ )`}async function createIndex(statement,dialect){try{await db.unsafe(indexSqlForDialect(statement,dialect)).execute()}catch(error){if(!isDuplicateIndexError(error))throw error}}const REQUIRED_COLUMNS={notifications:["id","user_id","type","data","read_at"],notification_preferences:["id","user_id","channel","enabled","category"],notification_deliveries:["id","user_id","channel","recipient","body","status"]};export const notificationTableNames=["notifications","notification_preferences","notification_deliveries"];export function notificationTablePreflight(sql){const preflight=[];for(const table of notificationTableNames){const escaped=table.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),create=new RegExp(`\\bCREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?["\\x60\\[]?${escaped}["\\x60\\]]?\\s*\\(`,"i").exec(sql);if(!create){preflight.push({table,reason:"no-create-statement"});continue}const referencePattern=new RegExp(`\\b${escaped}\\b`,"i"),beforeCreate=sql.slice(0,create.index);if(referencePattern.test(beforeCreate))preflight.push({table,reason:"referenced-before-create"})}return preflight}export function preflightShapeWarning(entry){if(entry.reason!=="referenced-before-create")return null;return`[notifications] "${entry.table}" is created by your own migrations, but an earlier migration reads it first, so the framework has to create it before the batch runs. Your CREATE TABLE IF NOT EXISTS then finds the table already there and does nothing, leaving it with the framework's columns rather than the ones your model declares, so the first index or insert on a column only you declare fails with "column does not exist". Move whatever reads "${entry.table}" after the migration that creates it.`}async function warnOnShapeMismatch(entry){const table=entry.table,required=REQUIRED_COLUMNS[table];if(!required)return;try{const rows=await db.unsafe(`SELECT * FROM ${table} WHERE 1 = 0`).execute(),known=Array.isArray(rows)&&rows.length>0?Object.keys(rows[0]):null,missing=[];for(const column of required){if(known){if(!known.includes(column))missing.push(column);continue}try{await db.unsafe(`SELECT ${column} FROM ${table} WHERE 1 = 0`).execute()}catch{missing.push(column)}}if(missing.length>0)log.warn(`[notifications] "${table}" already exists without ${missing.join(", ")}. CREATE TABLE IF NOT EXISTS left it untouched, so the notification driver will fail on those columns. Something else owns this table - either rename it, or stop using the framework's notification tables.`)}catch{const warning=preflightShapeWarning(entry);if(warning)log.warn(warning)}}export async function migrateNotificationTables(options={}){const dbDriver=getDbDriver(),sql=sqlHelpers(dbDriver),requested=new Map;for(const entry of options.tables??notificationTableNames){const normalized=typeof entry==="string"?{table:entry,reason:"no-create-statement"}:entry;requested.set(normalized.table,normalized)}if(options.verbose)log.info(`Creating notification tables for ${dbDriver}...`);try{const notifications=requested.get("notifications");if(notifications){if(options.verbose)log.info("Creating notifications table...");await warnOnShapeMismatch(notifications);await db.unsafe(notificationsTableSql(sql)).execute();await createIndex("CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_id)",dbDriver)}const preferences=requested.get("notification_preferences");if(preferences){if(options.verbose)log.info("Creating notification_preferences table...");await warnOnShapeMismatch(preferences);await db.unsafe(notificationPreferencesTableSql(sql)).execute();await createIndex("CREATE INDEX IF NOT EXISTS idx_notification_preferences_user ON notification_preferences (user_id)",dbDriver)}const deliveries=requested.get("notification_deliveries");if(deliveries){if(options.verbose)log.info("Creating notification deliveries table...");await warnOnShapeMismatch(deliveries);await db.unsafe(notificationDeliveriesTableSql(sql)).execute();await createIndex("CREATE INDEX IF NOT EXISTS idx_notification_deliveries_channel ON notification_deliveries (channel)",dbDriver);await createIndex("CREATE INDEX IF NOT EXISTS idx_notification_deliveries_status ON notification_deliveries (status)",dbDriver)}if(options.verbose)log.success("Notification tables created");return{success:!0}}catch(error){const message=error instanceof Error?error.message:String(error);log.error(`Failed to create notification tables: ${message}`);return{success:!1,error:message}}}export async function ensureNotificationForeignKeys(options={}){const dbDriver=getDbDriver();if(dialectCapabilities(dbDriver).wire==="sqlite")return;for(const table of["notifications","notification_deliveries"]){const statements=[`ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS "${table}_user_id_fk"`,`ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS "${table}_user_id_fkey"`,`ALTER TABLE ${table} ADD CONSTRAINT "${table}_user_id_fk" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE`];for(const statement of statements)try{await db.unsafe(statement).execute()}catch(error){if(options.verbose)log.debug(`[notification-tables] Skipped: ${statement} (${error instanceof Error?error.message:String(error)})`)}}}
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.151",
5
+ "version": "0.75.152",
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.151",
70
- "@stacksjs/env": "0.75.151",
71
- "@stacksjs/error-handling": "0.75.151",
72
- "@stacksjs/faker": "0.75.151",
73
- "@stacksjs/features": "0.75.151",
74
- "@stacksjs/logging": "0.75.151",
75
- "@stacksjs/model-meta": "0.75.151",
76
- "@stacksjs/path": "0.75.151",
77
- "@stacksjs/query-builder": "0.75.151",
78
- "@stacksjs/security": "0.75.151",
79
- "@stacksjs/storage": "0.75.151",
80
- "@stacksjs/strings": "0.75.151",
69
+ "@stacksjs/config": "0.75.152",
70
+ "@stacksjs/env": "0.75.152",
71
+ "@stacksjs/error-handling": "0.75.152",
72
+ "@stacksjs/faker": "0.75.152",
73
+ "@stacksjs/features": "0.75.152",
74
+ "@stacksjs/logging": "0.75.152",
75
+ "@stacksjs/model-meta": "0.75.152",
76
+ "@stacksjs/path": "0.75.152",
77
+ "@stacksjs/query-builder": "0.75.152",
78
+ "@stacksjs/security": "0.75.152",
79
+ "@stacksjs/storage": "0.75.152",
80
+ "@stacksjs/strings": "0.75.152",
81
81
  "@stacksjs/ts-validation": "^0.5.6",
82
82
  "bun-query-builder": "^0.3.8",
83
83
  "dynamodb-tooling": "^0.3.6"
84
84
  },
85
85
  "devDependencies": {
86
- "@stacksjs/cli": "0.75.151",
87
- "@stacksjs/router": "0.75.151",
88
- "@stacksjs/utils": "0.75.151",
86
+ "@stacksjs/cli": "0.75.152",
87
+ "@stacksjs/router": "0.75.152",
88
+ "@stacksjs/utils": "0.75.152",
89
89
  "better-dx": "^0.2.24"
90
90
  }
91
91
  }