@stacksjs/database 0.75.12 → 0.75.14

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.
@@ -127,4 +127,21 @@ export declare function ensureUsersAuthColumns(sql: SqlHelpers, options?: { verb
127
127
  * Create all authentication tables
128
128
  */
129
129
  export declare function migrateAuthTables(options?: { verbose?: boolean }): Promise<{ success: boolean, error?: string }>;
130
+ /**
131
+ * Every table {@link migrateAuthTables} creates. The auth layer owns these: their
132
+ * schema is whatever that function declares, created before the model
133
+ * migrations run and repaired by it on every migrate.
134
+ *
135
+ * So the migration generator must never emit DDL for them, even when a model
136
+ * maps one (`PersonalAccessToken` over `oauth_access_tokens`). A model's
137
+ * attributes describe the columns it reads, not the table: an app whose
138
+ * snapshot predated that model was handed a `create-oauth_access_tokens-table`
139
+ * built from the model alone, without `user_id` or `oauth_client_id`, and where
140
+ * that ran first the auth layer's CREATE IF NOT EXISTS became a no-op and its
141
+ * backfill failed with "no such column: user_id".
142
+ *
143
+ * Kept in step with the CREATE statements below by
144
+ * `tests/auth-owned-tables.test.ts`.
145
+ */
146
+ export declare const AUTH_TABLES: readonly string[];
130
147
  declare type SqlHelpers = ReturnType<typeof sqlHelpers>;
@@ -1,4 +1,4 @@
1
- import{randomBytes}from"node:crypto";import{log}from"@stacksjs/logging";import{env as envVars}from"@stacksjs/env";import{makeHash}from"@stacksjs/security";import{db}from"./utils";import{sqlHelpers}from"./sql-helpers";import{indexSqlForDialect,isDuplicateColumnError,isDuplicateIndexError}from"./dialect";function getDbDriver(){return envVars.DB_CONNECTION||"sqlite"}export function usersEmailVerifiedAtSql(sql){return`ALTER TABLE users ADD COLUMN email_verified_at ${sql.nullableTimestamp}`}export function usersPasswordChangedAtSql(sql){return`ALTER TABLE users ADD COLUMN password_changed_at ${sql.nullableTimestamp}`}export function usersTwoFactorColumnsSql(sql){return["ALTER TABLE users ADD COLUMN two_factor_secret VARCHAR(255)",`ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN NOT NULL DEFAULT ${sql.boolFalse}`,"ALTER TABLE users ADD COLUMN two_factor_last_used_step BIGINT"]}export function usersStripeIdSql(){return"ALTER TABLE users ADD COLUMN stripe_id VARCHAR(255)"}export function oauthAccessTokenTokenableColumnsSql(){return["ALTER TABLE oauth_access_tokens ADD COLUMN tokenable_type VARCHAR(255)","ALTER TABLE oauth_access_tokens ADD COLUMN tokenable_id INTEGER"]}export function oauthAccessTokenTokenableBackfillSql(usersTable="users"){return[`UPDATE oauth_access_tokens SET tokenable_type = '${usersTable}', tokenable_id = user_id WHERE tokenable_id IS NULL`]}export function oauthAccessTokenDeviceColumnsSql(){return["ALTER TABLE oauth_access_tokens ADD COLUMN user_agent VARCHAR(255)","ALTER TABLE oauth_access_tokens ADD COLUMN ip_address VARCHAR(45)"]}export function passwordResetsExpiresAtSql(){return["ALTER TABLE password_resets ADD COLUMN expires_at TIMESTAMP"]}export async function ensureUsersAuthColumns(sql,options={}){const alters=[usersEmailVerifiedAtSql(sql),usersPasswordChangedAtSql(sql),...usersTwoFactorColumnsSql(sql),usersStripeIdSql()];for(const alterSql of alters)try{await db.unsafe(alterSql).execute()}catch{if(options.verbose)log.debug(`[auth-tables] Skipped (already applied or users missing): ${alterSql}`)}try{await db.unsafe(indexSqlForDialect("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_stripe_id ON users(stripe_id)",getDbDriver())).execute()}catch{if(options.verbose)log.debug("[auth-tables] Skipped users.stripe_id unique index (already applied or users missing)")}}export async function migrateAuthTables(options={}){const dbDriver=getDbDriver(),sql=sqlHelpers(dbDriver),{isPostgres,boolTrue,now,pkColumn,nullableTimestamp,datetime,utcNow}=sql;if(options.verbose)log.info(`Creating auth tables for ${dbDriver}...`);try{if(options.verbose)log.info("Creating oauth_clients table...");await db.unsafe(`
1
+ import{randomBytes}from"node:crypto";import{log}from"@stacksjs/logging";import{env as envVars}from"@stacksjs/env";import{makeHash}from"@stacksjs/security";import{db}from"./utils";import{sqlHelpers}from"./sql-helpers";import{indexSqlForDialect,isDuplicateColumnError,isDuplicateIndexError}from"./dialect";function getDbDriver(){return envVars.DB_CONNECTION||"sqlite"}export const AUTH_TABLES=["oauth_clients","oauth_access_tokens","oauth_refresh_tokens","password_resets","email_verifications","passkeys","webauthn_challenges","two_factor_challenges","two_factor_pending_secrets"];export function usersEmailVerifiedAtSql(sql){return`ALTER TABLE users ADD COLUMN email_verified_at ${sql.nullableTimestamp}`}export function usersPasswordChangedAtSql(sql){return`ALTER TABLE users ADD COLUMN password_changed_at ${sql.nullableTimestamp}`}export function usersTwoFactorColumnsSql(sql){return["ALTER TABLE users ADD COLUMN two_factor_secret VARCHAR(255)",`ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN NOT NULL DEFAULT ${sql.boolFalse}`,"ALTER TABLE users ADD COLUMN two_factor_last_used_step BIGINT"]}export function usersStripeIdSql(){return"ALTER TABLE users ADD COLUMN stripe_id VARCHAR(255)"}export function oauthAccessTokenTokenableColumnsSql(){return["ALTER TABLE oauth_access_tokens ADD COLUMN tokenable_type VARCHAR(255)","ALTER TABLE oauth_access_tokens ADD COLUMN tokenable_id INTEGER"]}export function oauthAccessTokenTokenableBackfillSql(usersTable="users"){return[`UPDATE oauth_access_tokens SET tokenable_type = '${usersTable}', tokenable_id = user_id WHERE tokenable_id IS NULL`]}export function oauthAccessTokenDeviceColumnsSql(){return["ALTER TABLE oauth_access_tokens ADD COLUMN user_agent VARCHAR(255)","ALTER TABLE oauth_access_tokens ADD COLUMN ip_address VARCHAR(45)"]}export function passwordResetsExpiresAtSql(){return["ALTER TABLE password_resets ADD COLUMN expires_at TIMESTAMP"]}export async function ensureUsersAuthColumns(sql,options={}){const alters=[usersEmailVerifiedAtSql(sql),usersPasswordChangedAtSql(sql),...usersTwoFactorColumnsSql(sql),usersStripeIdSql()];for(const alterSql of alters)try{await db.unsafe(alterSql).execute()}catch{if(options.verbose)log.debug(`[auth-tables] Skipped (already applied or users missing): ${alterSql}`)}try{await db.unsafe(indexSqlForDialect("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_stripe_id ON users(stripe_id)",getDbDriver())).execute()}catch{if(options.verbose)log.debug("[auth-tables] Skipped users.stripe_id unique index (already applied or users missing)")}}export async function migrateAuthTables(options={}){const dbDriver=getDbDriver(),sql=sqlHelpers(dbDriver),{isPostgres,boolTrue,now,pkColumn,nullableTimestamp,datetime,utcNow}=sql;if(options.verbose)log.info(`Creating auth tables for ${dbDriver}...`);try{if(options.verbose)log.info("Creating oauth_clients table...");await db.unsafe(`
2
2
  CREATE TABLE IF NOT EXISTS oauth_clients (
3
3
  ${pkColumn},
4
4
  user_id BIGINT,
@@ -0,0 +1,43 @@
1
+ import type { Database } from 'bun:sqlite';
2
+ /** Parse one `CREATE UNIQUE INDEX`, or `null` when the statement is anything else. */
3
+ export declare function parseUniqueIndex(statement: string, file: string): DeclaredUniqueIndex | null;
4
+ /**
5
+ * The unique indexes a file declares when that is ALL it does, else `null`.
6
+ *
7
+ * A file that mixes them with anything else is not a unique-index file: its
8
+ * other statements decide what the file means, and replaying it is not safe.
9
+ */
10
+ export declare function uniqueIndexOnlyFile(content: string, file: string): DeclaredUniqueIndex[] | null;
11
+ /**
12
+ * Every unique-index-only file in the corpus, keyed by file name, with the
13
+ * indexes a later `DROP INDEX` retired already removed.
14
+ *
15
+ * A file whose indexes were all retired stays in the map with an empty list,
16
+ * so a caller can still tell it apart from an ordinary migration.
17
+ */
18
+ export declare function corpusUniqueIndexes(corpus: Array<{ file: string, content: string }>): Map<string, DeclaredUniqueIndex[]>;
19
+ /** Read the corpus directory. A missing directory is an empty corpus. */
20
+ export declare function readCorpus(migrationsDir: string): Array<{ file: string, content: string }>;
21
+ export declare function hasIndex(db: Database, name: string): boolean;
22
+ /**
23
+ * Whether the index can still be put back: its table exists and so does every
24
+ * plain column it covers. Either one missing means a later migration removed
25
+ * it deliberately, and replaying the index would fail rather than repair.
26
+ */
27
+ export declare function uniqueIndexIsRestorable(db: Database, index: DeclaredUniqueIndex): boolean;
28
+ /**
29
+ * Re-create every declared unique index a later rebuild dropped. Returns what
30
+ * it restored, in corpus order.
31
+ *
32
+ * A failure is thrown, not swallowed: it is a UNIQUE violation, meaning
33
+ * duplicate rows got in while the index was missing, and the migrate must say
34
+ * so rather than finish green on a table that no longer enforces the rule.
35
+ */
36
+ export declare function restoreDroppedUniqueIndexes(db: Database, migrationsDir: string): DeclaredUniqueIndex[];
37
+ export declare interface DeclaredUniqueIndex {
38
+ file: string
39
+ name: string
40
+ table: string
41
+ columns: string[]
42
+ statement: string
43
+ }
@@ -0,0 +1 @@
1
+ import{readdirSync,readFileSync}from"node:fs";import{join}from"node:path";import{sqlStatementsOf}from"./sql-statements";const quoted="[\"'`]?(\\w+)[\"'`]?",CREATE_UNIQUE_INDEX=new RegExp(`^\\s*CREATE\\s+UNIQUE\\s+INDEX\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${quoted}\\s+ON\\s+${quoted}\\s*\\(`,"i"),DROP_INDEX=new RegExp(`^\\s*DROP\\s+INDEX\\s+(?:IF\\s+EXISTS\\s+)?${quoted}`,"i"),PLAIN_COLUMN=new RegExp(`^${quoted}(?:\\s+COLLATE\\s+\\w+)?(?:\\s+(?:ASC|DESC))?$`,"i");function columnTerms(statement,start){const terms=[];let depth=0,current="";for(let i=start;i<statement.length;i++){const char=statement[i];if(char==="("){depth++;if(depth===1)continue}else if(char===")"){depth--;if(depth===0){terms.push(current.trim());return terms}}else if(char===","&&depth===1){terms.push(current.trim());current="";continue}current+=char}return terms}export function parseUniqueIndex(statement,file){const match=CREATE_UNIQUE_INDEX.exec(statement);if(!match?.[1]||!match[2])return null;const columns=columnTerms(statement,match[0].length-1).map((term)=>PLAIN_COLUMN.exec(term)?.[1]).filter((column)=>Boolean(column));return{file,name:match[1],table:match[2],columns,statement}}export function uniqueIndexOnlyFile(content,file){const statements=sqlStatementsOf(content);if(statements.length===0)return null;const declared=statements.map((statement)=>parseUniqueIndex(statement,file));return declared.every((index)=>index!==null)?declared:null}export function corpusUniqueIndexes(corpus){const ordered=[...corpus].sort((a,b)=>a.file.localeCompare(b.file)),declared=new Map,lastEvent=new Map;for(const{file,content}of ordered){const only=uniqueIndexOnlyFile(content,file);if(only)declared.set(file,only);for(const statement of sqlStatementsOf(content)){const created=CREATE_UNIQUE_INDEX.exec(statement)?.[1];if(created){lastEvent.set(created.toLowerCase(),"create");continue}const dropped=DROP_INDEX.exec(statement)?.[1];if(dropped)lastEvent.set(dropped.toLowerCase(),"drop")}}for(const[file,indexes]of declared)declared.set(file,indexes.filter((index)=>lastEvent.get(index.name.toLowerCase())!=="drop"));return declared}export function readCorpus(migrationsDir){let files;try{files=readdirSync(migrationsDir).filter((file)=>file.endsWith(".sql"))}catch{return[]}return files.map((file)=>({file,content:readFileSync(join(migrationsDir,file),"utf8")}))}export function hasIndex(db,name){return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'index' AND lower(name) = lower(?)").get(name))}export function uniqueIndexIsRestorable(db,index){const table=db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND lower(name) = lower(?)").get(index.table);if(!table)return!1;const present=new Set(db.prepare("SELECT name FROM pragma_table_info(?)").all(table.name).map((column)=>column.name.toLowerCase()));return index.columns.every((column)=>present.has(column.toLowerCase()))}export function restoreDroppedUniqueIndexes(db,migrationsDir){const restored=[];for(const indexes of corpusUniqueIndexes(readCorpus(migrationsDir)).values())for(const index of indexes){if(hasIndex(db,index.name)||!uniqueIndexIsRestorable(db,index))continue;try{db.exec(index.statement)}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}`)}restored.push(index)}return restored}
package/dist/fk-audit.js CHANGED
@@ -1,4 +1,4 @@
1
- import{existsSync}from"node:fs";import{dirname}from"node:path";import{plural,snakeCase}from"@stacksjs/strings";import{path}from"@stacksjs/path";import{globSync}from"@stacksjs/storage";import{dialectCapabilities,isKnownDialect,toSqlIntrospectionDialect}from"./dialect";export function safeGlob(pattern){const metaIdx=pattern.search(/[*?[]/),root=metaIdx===-1?dirname(pattern):dirname(pattern.slice(0,metaIdx));if(!existsSync(root))return[];try{return globSync(pattern,{absolute:!0})}catch{return[]}}function modelTable(model){return model.table||plural(snakeCase(model.name||""))}function modelNameForColumn(column){return column.replace(/_id$/,"").split("_").map((part)=>part.charAt(0).toUpperCase()+part.slice(1)).join("")}export function getDeclaredFKsFromModels(models){const meta=new Map(models.map((model)=>[String(model.name??""),{primaryKey:String(model.primaryKey??"id"),table:modelTable(model)}])),declared=[],seen=new Set,push=(fk)=>{const key=`${fk.fromTable}.${fk.fromColumn}\u2192${fk.toTable}.${fk.toColumn}`.toLowerCase();if(seen.has(key))return;seen.add(key);declared.push(fk)};for(const model of models){const fromTable=modelTable(model),modelName=String(model.name??""),attributes=model.attributes??{},belongsTo=model.belongsTo,relations=Array.isArray(belongsTo)?belongsTo:belongsTo&&typeof belongsTo==="object"?Object.keys(belongsTo).map((name)=>({model:name})):[],declaresBelongsTo=relations.length>0;for(const[attributeName,attribute]of Object.entries(attributes)){const fromColumn=snakeCase(attributeName);if(!fromColumn.endsWith("_id")||attribute.foreignKey===!1)continue;if(attribute.foreignKey&&typeof attribute.foreignKey==="object"){push({fromTable,fromColumn,toTable:attribute.foreignKey.table,toColumn:attribute.foreignKey.column??"id",model:modelName});continue}const declaredTarget=relations.some((entry)=>{const relatedName=typeof entry==="string"?entry:String(entry.model??"");return(typeof entry==="object"&&entry.foreignKey?String(entry.foreignKey):`${snakeCase(relatedName)}_id`)===fromColumn});if(declaresBelongsTo&&!declaredTarget)continue;const related=meta.get(modelNameForColumn(fromColumn));if(related)push({fromTable,fromColumn,toTable:related.table,toColumn:related.primaryKey,model:modelName})}for(const entry of relations){const relatedName=typeof entry==="string"?entry:String(entry.model??"");if(!relatedName)continue;const fromColumn=typeof entry==="object"&&entry.foreignKey?String(entry.foreignKey):`${snakeCase(relatedName)}_id`;if(Object.keys(attributes).some((attribute)=>snakeCase(attribute)===fromColumn))continue;const related=meta.get(relatedName);if(related)push({fromTable,fromColumn,toTable:related.table,toColumn:related.primaryKey,model:modelName})}}return declared}export async function getDeclaredFKs(){const userModelFiles=safeGlob(path.userModelsPath("*.ts")),modelFiles=userModelFiles.length>0?userModelFiles:safeGlob(path.storagePath("framework/defaults/app/Models/**/*.ts")),models=[];for(const modelFile of modelFiles){let model;try{model=(await import(modelFile)).default}catch{continue}if(model&&typeof model==="object")models.push(model)}return getDeclaredFKsFromModels(models)}export async function getLiveFKs(){const{db}=await import("./utils"),dialect=await currentDialect();if(dialect==="sqlite")return getSqliteLiveFKs(db);if(dialect==="mysql")return getMysqlLiveFKs(db);if(dialect==="postgres")return getPostgresLiveFKs(db);return[]}export async function getLiveTables(){const{db}=await import("./utils"),dialect=await currentDialect(),query=dialect==="sqlite"?"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'":dialect==="mysql"?"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()":dialect==="postgres"?"SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()":"";if(!query)return new Set;try{const rows=await db.unsafe(query);return new Set((Array.isArray(rows)?rows:[]).map((row)=>row.name).filter((name)=>Boolean(name)).map((name)=>name.toLowerCase()))}catch{return new Set}}export async function auditForeignKeys(){const declared=await getDeclaredFKs(),live=await getLiveFKs(),liveKeys=new Set(live.map((fk)=>`${fk.fromTable.toLowerCase()}.${fk.fromColumn.toLowerCase()}\u2192${fk.toTable.toLowerCase()}.${fk.toColumn.toLowerCase()}`)),liveTables=await getLiveTables();return{declared,live,...classifyDeclaredFKs(declared,liveKeys,liveTables)}}export function classifyDeclaredFKs(declared,liveKeys,liveTables){const canClassify=liveTables.size>0,missing=[],absentTable=[];for(const d of declared){const key=`${d.fromTable.toLowerCase()}.${d.fromColumn.toLowerCase()}\u2192${d.toTable.toLowerCase()}.${d.toColumn.toLowerCase()}`;if(liveKeys.has(key))continue;if(canClassify&&!liveTables.has(d.fromTable.toLowerCase()))absentTable.push(d);else missing.push(d)}return{missing,absentTable}}export function fkKey(fk){return`${fk.fromTable.toLowerCase()}.${fk.fromColumn.toLowerCase()}\u2192${fk.toTable.toLowerCase()}.${fk.toColumn.toLowerCase()}`}export async function findFkOrphans(dialect){if((dialect??await currentDialect())!=="sqlite")return{supported:!1,total:0,orphans:[]};const{db}=await import("./utils"),rows=await db.unsafe("PRAGMA foreign_key_check").execute(),checkRows=Array.isArray(rows)?rows:[],fkListCache=new Map;async function fkListFor(table){if(fkListCache.has(table))return fkListCache.get(table);if(!/^[a-z_]\w*$/i.test(table)){fkListCache.set(table,[]);return[]}const list=await db.unsafe(`PRAGMA foreign_key_list("${table}")`).execute(),arr=Array.isArray(list)?list:[];fkListCache.set(table,arr);return arr}const grouped=new Map;for(const raw of checkRows){const r=raw,table=String(r.table??""),parent=String(r.parent??"");if(!table||!parent)continue;const fkid=Number(r.fkid??0);let column="";const fkList=await fkListFor(table);for(const fk of fkList){const f=fk;if(Number(f.id)===fkid&&f.from){column=String(f.from);break}}const key=`${table}\x00${parent}\x00${fkid}`;let entry=grouped.get(key);if(!entry){entry={table,column,parent,count:0,sampleRowids:[]};grouped.set(key,entry)}entry.count++;if(entry.sampleRowids.length<5&&typeof r.rowid==="number")entry.sampleRowids.push(r.rowid)}const orphans=[...grouped.values()];return{supported:!0,total:orphans.reduce((sum,o)=>sum+o.count,0),orphans}}async function currentDialect(){const driver=((await import("@stacksjs/env")).env?.DB_CONNECTION??"sqlite").toLowerCase();if(!isKnownDialect(driver)||!dialectCapabilities(driver).supportsForeignKeys)return"other";return toSqlIntrospectionDialect(driver)}async function getSqliteLiveFKs(db){const tables=await db.unsafe("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"),rows=Array.isArray(tables)?tables:[],fks=[];for(const row of rows){const fromTable=row.name;if(!fromTable)continue;if(!/^[a-z_][\w]*$/i.test(fromTable))continue;const fkRows=await db.unsafe(`PRAGMA foreign_key_list("${fromTable}")`);for(const fk of Array.isArray(fkRows)?fkRows:[]){const r=fk;if((r.seq??0)!==0)continue;if(!r.from||!r.to||!r.table)continue;fks.push({fromTable,fromColumn:r.from,toTable:r.table,toColumn:r.to})}}return fks}async function getMysqlLiveFKs(db){const rows=await db.unsafe(`
1
+ import{existsSync}from"node:fs";import{dirname}from"node:path";import{plural,snakeCase}from"@stacksjs/strings";import{path}from"@stacksjs/path";import{globSync}from"@stacksjs/storage";import{dialectCapabilities,isKnownDialect,toSqlIntrospectionDialect}from"./dialect";export function safeGlob(pattern){const metaIdx=pattern.search(/[*?[]/),root=metaIdx===-1?dirname(pattern):dirname(pattern.slice(0,metaIdx));if(!existsSync(root))return[];try{return globSync(pattern,{absolute:!0})}catch{return[]}}function modelTable(model){return model.table||plural(snakeCase(model.name||""))}function modelNameForColumn(column){return column.replace(/_id$/,"").split("_").map((part)=>part.charAt(0).toUpperCase()+part.slice(1)).join("")}export function getDeclaredFKsFromModels(models){const meta=new Map(models.map((model)=>[String(model.name??""),{primaryKey:String(model.primaryKey??"id"),table:modelTable(model)}])),declared=[],seen=new Set,push=(fk)=>{const key=`${fk.fromTable}.${fk.fromColumn}\u2192${fk.toTable}.${fk.toColumn}`.toLowerCase();if(seen.has(key))return;seen.add(key);declared.push(fk)};for(const model of models){const fromTable=modelTable(model),modelName=String(model.name??""),attributes=model.attributes??{},belongsTo=model.belongsTo,relations=Array.isArray(belongsTo)?belongsTo:belongsTo&&typeof belongsTo==="object"?Object.keys(belongsTo).map((name)=>({model:name})):[],declaresBelongsTo=relations.length>0,columns=new Set(Object.keys(attributes).map((name)=>snakeCase(name))),polymorphic=(column)=>columns.has(`${column.slice(0,-3)}_type`);for(const[attributeName,attribute]of Object.entries(attributes)){const fromColumn=snakeCase(attributeName);if(!fromColumn.endsWith("_id")||attribute.foreignKey===!1||polymorphic(fromColumn))continue;if(attribute.foreignKey&&typeof attribute.foreignKey==="object"){push({fromTable,fromColumn,toTable:attribute.foreignKey.table,toColumn:attribute.foreignKey.column??"id",model:modelName});continue}const declaredTarget=relations.some((entry)=>{const relatedName=typeof entry==="string"?entry:String(entry.model??"");return(typeof entry==="object"&&entry.foreignKey?String(entry.foreignKey):`${snakeCase(relatedName)}_id`)===fromColumn});if(declaresBelongsTo&&!declaredTarget)continue;const related=meta.get(modelNameForColumn(fromColumn));if(related)push({fromTable,fromColumn,toTable:related.table,toColumn:related.primaryKey,model:modelName})}for(const entry of relations){const relatedName=typeof entry==="string"?entry:String(entry.model??"");if(!relatedName)continue;const fromColumn=typeof entry==="object"&&entry.foreignKey?String(entry.foreignKey):`${snakeCase(relatedName)}_id`;if(Object.keys(attributes).some((attribute)=>snakeCase(attribute)===fromColumn)||polymorphic(fromColumn))continue;const related=meta.get(relatedName);if(related)push({fromTable,fromColumn,toTable:related.table,toColumn:related.primaryKey,model:modelName})}}return declared}export async function getDeclaredFKs(){const userModelFiles=safeGlob(path.userModelsPath("*.ts")),modelFiles=userModelFiles.length>0?userModelFiles:safeGlob(path.storagePath("framework/defaults/app/Models/**/*.ts")),models=[];for(const modelFile of modelFiles){let model;try{model=(await import(modelFile)).default}catch{continue}if(model&&typeof model==="object")models.push(model)}return getDeclaredFKsFromModels(models)}export async function getLiveFKs(){const{db}=await import("./utils"),dialect=await currentDialect();if(dialect==="sqlite")return getSqliteLiveFKs(db);if(dialect==="mysql")return getMysqlLiveFKs(db);if(dialect==="postgres")return getPostgresLiveFKs(db);return[]}export async function getLiveTables(){const{db}=await import("./utils"),dialect=await currentDialect(),query=dialect==="sqlite"?"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'":dialect==="mysql"?"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()":dialect==="postgres"?"SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()":"";if(!query)return new Set;try{const rows=await db.unsafe(query);return new Set((Array.isArray(rows)?rows:[]).map((row)=>row.name).filter((name)=>Boolean(name)).map((name)=>name.toLowerCase()))}catch{return new Set}}export async function auditForeignKeys(){const declared=await getDeclaredFKs(),live=await getLiveFKs(),liveKeys=new Set(live.map((fk)=>`${fk.fromTable.toLowerCase()}.${fk.fromColumn.toLowerCase()}\u2192${fk.toTable.toLowerCase()}.${fk.toColumn.toLowerCase()}`)),liveTables=await getLiveTables();return{declared,live,...classifyDeclaredFKs(declared,liveKeys,liveTables)}}export function classifyDeclaredFKs(declared,liveKeys,liveTables){const canClassify=liveTables.size>0,missing=[],absentTable=[];for(const d of declared){const key=`${d.fromTable.toLowerCase()}.${d.fromColumn.toLowerCase()}\u2192${d.toTable.toLowerCase()}.${d.toColumn.toLowerCase()}`;if(liveKeys.has(key))continue;if(canClassify&&!liveTables.has(d.fromTable.toLowerCase()))absentTable.push(d);else missing.push(d)}return{missing,absentTable}}export function fkKey(fk){return`${fk.fromTable.toLowerCase()}.${fk.fromColumn.toLowerCase()}\u2192${fk.toTable.toLowerCase()}.${fk.toColumn.toLowerCase()}`}export async function findFkOrphans(dialect){if((dialect??await currentDialect())!=="sqlite")return{supported:!1,total:0,orphans:[]};const{db}=await import("./utils"),rows=await db.unsafe("PRAGMA foreign_key_check").execute(),checkRows=Array.isArray(rows)?rows:[],fkListCache=new Map;async function fkListFor(table){if(fkListCache.has(table))return fkListCache.get(table);if(!/^[a-z_]\w*$/i.test(table)){fkListCache.set(table,[]);return[]}const list=await db.unsafe(`PRAGMA foreign_key_list("${table}")`).execute(),arr=Array.isArray(list)?list:[];fkListCache.set(table,arr);return arr}const grouped=new Map;for(const raw of checkRows){const r=raw,table=String(r.table??""),parent=String(r.parent??"");if(!table||!parent)continue;const fkid=Number(r.fkid??0);let column="";const fkList=await fkListFor(table);for(const fk of fkList){const f=fk;if(Number(f.id)===fkid&&f.from){column=String(f.from);break}}const key=`${table}\x00${parent}\x00${fkid}`;let entry=grouped.get(key);if(!entry){entry={table,column,parent,count:0,sampleRowids:[]};grouped.set(key,entry)}entry.count++;if(entry.sampleRowids.length<5&&typeof r.rowid==="number")entry.sampleRowids.push(r.rowid)}const orphans=[...grouped.values()];return{supported:!0,total:orphans.reduce((sum,o)=>sum+o.count,0),orphans}}async function currentDialect(){const driver=((await import("@stacksjs/env")).env?.DB_CONNECTION??"sqlite").toLowerCase();if(!isKnownDialect(driver)||!dialectCapabilities(driver).supportsForeignKeys)return"other";return toSqlIntrospectionDialect(driver)}async function getSqliteLiveFKs(db){const tables=await db.unsafe("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"),rows=Array.isArray(tables)?tables:[],fks=[];for(const row of rows){const fromTable=row.name;if(!fromTable)continue;if(!/^[a-z_][\w]*$/i.test(fromTable))continue;const fkRows=await db.unsafe(`PRAGMA foreign_key_list("${fromTable}")`);for(const fk of Array.isArray(fkRows)?fkRows:[]){const r=fk;if((r.seq??0)!==0)continue;if(!r.from||!r.to||!r.table)continue;fks.push({fromTable,fromColumn:r.from,toTable:r.table,toColumn:r.to})}}return fks}async function getMysqlLiveFKs(db){const rows=await db.unsafe(`
2
2
  SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME
3
3
  FROM information_schema.KEY_COLUMN_USAGE
4
4
  WHERE TABLE_SCHEMA = DATABASE()
@@ -1,3 +1,4 @@
1
+ import { sqlStatementsOf } from './sql-statements';
1
2
  import type { DatabaseRenameResult, RenameSqlRunner } from './framework-renames';
2
3
  import type { MigrationOperation, MigrationPlan } from '@stacksjs/query-builder';
3
4
  import type { Result } from '@stacksjs/error-handling';
@@ -26,6 +27,17 @@ export declare function prepareMigrationModelsDir(): {
26
27
  */
27
28
  protectedTables: string[]
28
29
  };
30
+ /**
31
+ * Leave out generated DDL for tables the framework creates at runtime rather
32
+ * than through a migration - the auth tables, see {@link AUTH_TABLES}.
33
+ *
34
+ * A model may map one of them to read it, and that puts the table in the
35
+ * differ's plan; the generated CREATE, built from the model's attributes alone,
36
+ * then races the auth layer's own and can win with half the columns. The
37
+ * snapshot still records the table, so the same statements are not proposed
38
+ * again next run.
39
+ */
40
+ export declare function withoutRuntimeOwnedTableSql(statements: string[], owned?: readonly string[]): { statements: string[], removed: string[] };
29
41
  /**
30
42
  * Drop the generated `DROP TABLE`s for tables that are simply out of the
31
43
  * generator's scope, rather than deleted.
@@ -76,19 +88,6 @@ export declare function withoutProtectedTableDropSql(statements: string[], prote
76
88
  * disk: the corpus is tracked source, and this sweep cannot tell a
77
89
  * generated file from a hand-written one (stacksjs/stacks#2234).
78
90
  */
79
- /**
80
- * Split a migration file into its statements.
81
- *
82
- * Comments come out FIRST, before the split on `;`. Splitting first and then
83
- * dropping the chunks that begin with `--` looks equivalent and is not: a
84
- * file that opens with a comment header — which every hand-written migration
85
- * in this repo does — glues that header to its first statement, so the chunk
86
- * begins with `--` and the statement disappears with the comment. Everything
87
- * downstream then reasons about a file it has only partly read, and the
88
- * ADD COLUMN reconciliation below would happily record a file as fully
89
- * applied while its first column had never been created.
90
- */
91
- export declare function sqlStatementsOf(content: string): string[];
92
91
  /**
93
92
  * Make a Postgres `CREATE TYPE … AS ENUM` statement safe to run twice, and make
94
93
  * it mean something when the type is already there.
@@ -486,7 +485,28 @@ export declare function generatedStatementIsRedundant(statement: string, index:
486
485
  * `alter-<table>-<col>`, `create-<index>-index-in-<table>`, or
487
486
  * `drop-<table>-table`. Anything we can't match falls back to `auto-misc`.
488
487
  */
489
- export declare function groupGeneratedStatements(sqlStatements: string[]): GeneratedGroup[];
488
+ export declare function groupGeneratedStatements(sqlStatements: string[], previousPlan?: MigrationPlan): GeneratedGroup[];
489
+ /**
490
+ * Move generated `DROP TABLE` migrations after everything else in the batch,
491
+ * children before parents.
492
+ *
493
+ * bun-query-builder emits a model's removal as a DROP ahead of the rebuilds and
494
+ * alters in the same diff, and a group's file number follows that order. So a
495
+ * removed model's table was dropped while other tables still referenced it:
496
+ * the SQLite rebuild that takes `delivery_routes.driver_id REFERENCES drivers`
497
+ * away ran one file LATER than `DROP TABLE drivers`, whose implicit delete then
498
+ * failed with "FOREIGN KEY constraint failed" wherever a route pointed at a
499
+ * driver. MySQL and Postgres refuse the same drop outright.
500
+ *
501
+ * Dropping last is always at least as safe: nothing else in a diff can need a
502
+ * table to be gone - except creating one of the same name, so a drop whose
503
+ * table a later group creates keeps its place. Among the drops, a table that
504
+ * references another dropped table goes first. The references come from
505
+ * `previousPlan`, the snapshot the diff was taken against, which is the only
506
+ * place a dropped table's columns are still described; without it drops run in
507
+ * reverse emission order, since tables are created parents first.
508
+ */
509
+ export declare function withTableDropsLast(groups: GeneratedGroup[], previousPlan?: MigrationPlan): GeneratedGroup[];
490
510
  /**
491
511
  * Generate fresh migrations (full regeneration, ignoring previous state)
492
512
  */
@@ -551,7 +571,7 @@ export declare interface CommittedMigrationIndex {
551
571
  sql: string
552
572
  createdTables: Set<string>
553
573
  }
554
- declare interface GeneratedGroup {
574
+ export declare interface GeneratedGroup {
555
575
  label: string
556
576
  statements: string[]
557
577
  }
@@ -563,3 +583,4 @@ export declare interface MigrationResult {
563
583
  direction: 'Up' | 'Down'
564
584
  status: 'Success' | 'Error' | 'NotExecuted'
565
585
  }
586
+ export { sqlStatementsOf } from './sql-statements';
@@ -1,9 +1,8 @@
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,executeMigration as qbExecuteMigration,generateDiffOperations,generateMigration as qbGenerateMigration,resetDatabase as qbResetDatabase,config as qbConfig,saveMigrationSnapshot,setConfig}from"@stacksjs/query-builder";import{db,qbSnapshotDir,resetDatabaseConnection}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{acquireMigrationLock}from"./migration-lock";import{relativeMigrationDirectory,resolveMigrationDirectory}from"./migration-path";import{ensureNotificationForeignKeys,migrateNotificationTables,notificationTablesMissingCreateStatements}from"./notification-tables";import{traitTableNames}from"./trait-tables";import{env as envVars}from"@stacksjs/env";import{getConnectionDefaults}from"./defaults";import{isVitessSharded,toSqlIntrospectionDialect}from"./dialect";const databaseEnv={DB_CONNECTION:process.env.DB_CONNECTION||envVars.DB_CONNECTION,DB_DATABASE_PATH:process.env.DB_DATABASE_PATH||envVars.DB_DATABASE_PATH,DB_DATABASE:process.env.DB_DATABASE||envVars.DB_DATABASE,DB_HOST:process.env.DB_HOST||envVars.DB_HOST,DB_PORT:process.env.DB_PORT?Number(process.env.DB_PORT):envVars.DB_PORT,DB_USERNAME:process.env.DB_USERNAME||envVars.DB_USERNAME,DB_PASSWORD:process.env.DB_PASSWORD||envVars.DB_PASSWORD,DB_VITESS_SHARDED:process.env.DB_VITESS_SHARDED||envVars.DB_VITESS_SHARDED},dbDriver=databaseEnv.DB_CONNECTION||"sqlite",sqliteDefaults=getConnectionDefaults("sqlite",databaseEnv),mysqlDefaults=getConnectionDefaults("mysql",databaseEnv),singlestoreDefaults=getConnectionDefaults("singlestore",databaseEnv),vitessDefaults=getConnectionDefaults("vitess",databaseEnv),postgresDefaults=getConnectionDefaults("postgres",databaseEnv),dbConfig={default:dbDriver,connections:{sqlite:{database:sqliteDefaults.database,prefix:""},mysql:{name:mysqlDefaults.database,host:mysqlDefaults.host,username:mysqlDefaults.username,password:mysqlDefaults.password,port:mysqlDefaults.port,prefix:""},singlestore:{name:singlestoreDefaults.database,host:singlestoreDefaults.host,username:singlestoreDefaults.username,password:singlestoreDefaults.password,port:singlestoreDefaults.port,prefix:""},vitess:{name:vitessDefaults.database,host:vitessDefaults.host,username:vitessDefaults.username,password:vitessDefaults.password,port:vitessDefaults.port,prefix:"",sharded:isVitessSharded(databaseEnv.DB_VITESS_SHARDED)},postgres:{name:postgresDefaults.database,host:postgresDefaults.host,username:postgresDefaults.username,password:postgresDefaults.password,port:postgresDefaults.port,prefix:""}}};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==="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, mysql, singlestore, vitess, postgres.");throw Error(`[database] Unknown DB_CONNECTION "${driver}". Allowed values: sqlite, mysql, singlestore, vitess, postgres, dynamodb.`)}function getQbDialect(){return getDriver()==="singlestore"?"singlestore":getDialect()}function migrationDirectory(dialect=getQbDialect()){return resolveMigrationDirectory(dialect,{configured:qbConfig.migrationDir,snapshotDir:qbSnapshotDir()})}function configureQueryBuilder(targetDialect=getQbDialect(),vitessSharded){const connectionConfig=dbConfig.connections[targetDialect];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||""}});resetDatabaseConnection()}export function prepareMigrationModelsDir(){const sources=resolveModelSources(),excludedTables=sources?.excludedTables??[];return{modelsDir:sources?.dir??path.userModelsPath(),skip:!sources,excludedTables,protectedTables:[...new Set([...excludedTables,...traitTableNames()])]}}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 sqlStatementsOf(content){const statements=[];let current="",quote=null,dollarTag=null;for(let i=0;i<content.length;i++){const char=content[i];if(dollarTag){current+=char;if(char==="$"&&content.startsWith(dollarTag,i)){current+=content.slice(i+1,i+dollarTag.length);i+=dollarTag.length-1;dollarTag=null}continue}if(quote){current+=char;if(quote==="single"&&char==="'"||quote==="double"&&char==='"')quote=null;continue}if(char==="-"&&content[i+1]==="-"){const newline=content.indexOf(`
2
- `,i);if(newline===-1)break;i=newline-1;continue}const dollar=char==="$"?/^\$[A-Za-z_]*\$/.exec(content.slice(i)):null;if(dollar){dollarTag=dollar[0];current+=dollarTag;i+=dollarTag.length-1;continue}if(char==="'"){quote="single";current+=char;continue}if(char==='"'){quote="double";current+=char;continue}if(char===";"){const trimmed=current.trim();if(trimmed.length>0)statements.push(trimmed);current="";continue}current+=char}const trailing=current.trim();if(trailing.length>0)statements.push(trailing);return statements}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,executeMigration as qbExecuteMigration,generateDiffOperations,generateMigration as qbGenerateMigration,resetDatabase as qbResetDatabase,config as qbConfig,saveMigrationSnapshot,setConfig}from"@stacksjs/query-builder";import{db,qbSnapshotDir,resetDatabaseConnection}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{acquireMigrationLock}from"./migration-lock";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{corpusUniqueIndexes,hasIndex,restoreDroppedUniqueIndexes,uniqueIndexIsRestorable}from"./declared-unique-indexes";export{sqlStatementsOf}from"./sql-statements";import{env as envVars}from"@stacksjs/env";import{getConnectionDefaults}from"./defaults";import{isVitessSharded,toSqlIntrospectionDialect}from"./dialect";const databaseEnv={DB_CONNECTION:process.env.DB_CONNECTION||envVars.DB_CONNECTION,DB_DATABASE_PATH:process.env.DB_DATABASE_PATH||envVars.DB_DATABASE_PATH,DB_DATABASE:process.env.DB_DATABASE||envVars.DB_DATABASE,DB_HOST:process.env.DB_HOST||envVars.DB_HOST,DB_PORT:process.env.DB_PORT?Number(process.env.DB_PORT):envVars.DB_PORT,DB_USERNAME:process.env.DB_USERNAME||envVars.DB_USERNAME,DB_PASSWORD:process.env.DB_PASSWORD||envVars.DB_PASSWORD,DB_VITESS_SHARDED:process.env.DB_VITESS_SHARDED||envVars.DB_VITESS_SHARDED},dbDriver=databaseEnv.DB_CONNECTION||"sqlite",sqliteDefaults=getConnectionDefaults("sqlite",databaseEnv),mysqlDefaults=getConnectionDefaults("mysql",databaseEnv),singlestoreDefaults=getConnectionDefaults("singlestore",databaseEnv),vitessDefaults=getConnectionDefaults("vitess",databaseEnv),postgresDefaults=getConnectionDefaults("postgres",databaseEnv),dbConfig={default:dbDriver,connections:{sqlite:{database:sqliteDefaults.database,prefix:""},mysql:{name:mysqlDefaults.database,host:mysqlDefaults.host,username:mysqlDefaults.username,password:mysqlDefaults.password,port:mysqlDefaults.port,prefix:""},singlestore:{name:singlestoreDefaults.database,host:singlestoreDefaults.host,username:singlestoreDefaults.username,password:singlestoreDefaults.password,port:singlestoreDefaults.port,prefix:""},vitess:{name:vitessDefaults.database,host:vitessDefaults.host,username:vitessDefaults.username,password:vitessDefaults.password,port:vitessDefaults.port,prefix:"",sharded:isVitessSharded(databaseEnv.DB_VITESS_SHARDED)},postgres:{name:postgresDefaults.database,host:postgresDefaults.host,username:postgresDefaults.username,password:postgresDefaults.password,port:postgresDefaults.port,prefix:""}}};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==="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, mysql, singlestore, vitess, postgres.");throw Error(`[database] Unknown DB_CONNECTION "${driver}". Allowed values: sqlite, mysql, singlestore, vitess, postgres, dynamodb.`)}function getQbDialect(){return getDriver()==="singlestore"?"singlestore":getDialect()}function migrationDirectory(dialect=getQbDialect()){return resolveMigrationDirectory(dialect,{configured:qbConfig.migrationDir,snapshotDir:qbSnapshotDir()})}function configureQueryBuilder(targetDialect=getQbDialect(),vitessSharded){const connectionConfig=dbConfig.connections[targetDialect];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||""}});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(`
3
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(`
4
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};`}
5
4
  ${missing.join(`
6
- `)}`})}function enumMembers(list){return[...list.matchAll(/'(?:[^']|'')*'/g)].map((match)=>match[0])}export function preprocessSqliteMigrations(){const migrationsDir=migrationDirectory("sqlite"),quarantined=[];try{for(const stale of readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql.unsupported"))){const original=join(migrationsDir,stale.slice(0,-12));try{if(!existsSync(original))renameSync(join(migrationsDir,stale),original);else unlinkSync(join(migrationsDir,stale))}catch{}}}catch{}let files;try{files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql"))}catch{return quarantined}const droppedMigrations=[],skipMigration=(file,reason)=>{log.info(`Skipping migration on SQLite (${reason}): ${file}`);droppedMigrations.push(file)},partitionUnsupported=(file,filePath,statements,pattern,feature)=>{const unsupported=statements.filter((s)=>pattern.test(s));if(unsupported.length===statements.length){skipMigration(file,`SQLite does not support ${feature}`);return"skipped"}const hiddenPath=`${filePath}.unsupported`;try{renameSync(filePath,hiddenPath)}catch(error){log.error(`[migration] Could not quarantine ${file}: ${error instanceof Error?error.message:String(error)}`);return"runnable"}quarantined.push({original:filePath,hidden:hiddenPath,feature});log.warn(`[migration] ${file} mixes ${unsupported.length} ${feature} statement(s) SQLite cannot run with ${statements.length-unsupported.length} it can, so none of it was applied. Split them into separate migrations, or run this app on MySQL/Postgres. The rest of the corpus was applied.`);return"quarantined"},retireMigration=(file,reason)=>{log.info(`Not running no-op migration (${reason}): ${file}`);droppedMigrations.push(file)},replayMigrations=[],addConstraintPattern=/^\s*ALTER\s+TABLE\s+.+\s+ADD\s+CONSTRAINT\s+/i,createTypePattern=/^\s*CREATE\s+TYPE\s+/i,createUniqueIndexPattern=/^\s*CREATE\s+UNIQUE\s+INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i,dropColumnPattern=/^\s*ALTER\s+TABLE\s+["']?(\w+)["']?\s+DROP\s+COLUMN\s+["']?(\w+)["']?\s*$/i,addColumnPattern=/^\s*ALTER\s+TABLE\s+["']?(\w+)["']?\s+ADD\s+COLUMN\s+["'`]?(\w+)["'`]?/i,createTablePattern=/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(\w+)["']?/i,createTableEarliest=new Map;for(const file of files){const m=file.match(/^\d+-create-(\w+)-table\.sql$/);if(!m||!m[1])continue;const tableName=m[1],existing=createTableEarliest.get(tableName);if(!existing||file<existing)createTableEarliest.set(tableName,file)}const earlierCreateDefinesColumn=(migrationFile,table,column)=>{const createFile=createTableEarliest.get(table);if(!createFile||createFile>=migrationFile)return!1;try{const createContent=readFileSync(join(migrationsDir,createFile),"utf8"),createStatement=sqlStatementsOf(createContent).find((statement)=>statement.match(createTablePattern)?.[1]===table);if(!createStatement)return!1;const escapedColumn=column.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return new RegExp(`(?:^|[,(])\\s*["'\`]?${escapedColumn}["'\`]?\\s+`,"i").test(createStatement)}catch{return!1}},sqliteDbPath=sqliteDatabasePath();let sqliteDb=null;if(existsSync(sqliteDbPath))try{const{Database}=require("bun:sqlite");sqliteDb=new Database(sqliteDbPath,{readonly:!0})}catch{}const migrationWasRecorded=(file)=>{if(!sqliteDb)return!1;try{return Boolean(sqliteDb.prepare("SELECT 1 FROM migrations WHERE migration = ? LIMIT 1").get(file))}catch{return!1}};for(const file of files){log.debug(`[migration] Running: ${file}`);const filePath=join(migrationsDir,file),content=readFileSync(filePath,"utf-8"),statements=sqlStatementsOf(content);if(statements.length===0)continue;const uniqueIndexNames=statements.map((s)=>s.match(createUniqueIndexPattern)?.[1]).filter((name)=>Boolean(name));if(sqliteDb&&uniqueIndexNames.length===statements.length){const indexExists=sqliteDb.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?");if(uniqueIndexNames.filter((name)=>!indexExists.get(name)).length>0){log.info(`Re-queueing unique-index migration (index missing from database): ${file}`);replayMigrations.push(file)}continue}if(migrationWasRecorded(file))continue;const firstStatement=statements[0],createTableMatch=firstStatement?firstStatement.match(createTablePattern):null;if(createTableMatch&&createTableMatch[1]){const tableName=createTableMatch[1],earliest=createTableEarliest.get(tableName);if(earliest&&earliest!==file&&!isPackageMigration(file)){retireMigration(file,`duplicate create-table for "${tableName}" (kept ${earliest})`);continue}}if(statements.some((s)=>addConstraintPattern.test(s))){if(partitionUnsupported(file,filePath,statements,addConstraintPattern,"ALTER TABLE ADD CONSTRAINT")!=="runnable")continue}if(statements.some((s)=>createTypePattern.test(s))){if(partitionUnsupported(file,filePath,statements,createTypePattern,"CREATE TYPE (enum types)")!=="runnable")continue}const addColumnTargets=statements.map((s)=>s.match(addColumnPattern)).filter((m)=>Boolean(m?.[1]&&m[2])).map((m)=>({table:m[1],column:m[2]}));if(addColumnTargets.length>0&&addColumnTargets.length===statements.length){const satisfied=addColumnTargets.filter(({table,column})=>{try{if(sqliteDb){const safeTableName=table.replace(/[^a-zA-Z0-9_]/g,""),columns=sqliteDb.prepare(`PRAGMA table_info("${safeTableName}")`).all();if(columns.some((col)=>col.name===column))return!0;if(columns.length>0)return!1}return earlierCreateDefinesColumn(file,table,column)}catch{return!1}});if(satisfied.length===addColumnTargets.length){skipMigration(file,"every column it adds already exists or is defined by an earlier create-table migration");continue}if(satisfied.length>0)log.warn(`[migration] ${file} is partially applied: ${satisfied.map((p)=>`${p.table}.${p.column}`).join(", ")} already exist${satisfied.length===1?"s":""} or will be created earlier, the rest do not. SQLite cannot skip a single ADD COLUMN, so this file will fail. Add the remaining columns by hand, or split the file so the applied statements sit in their own migration.`)}if(statements.some((s)=>dropColumnPattern.test(s))){let modified=!1;const filteredStatements=[];for(const stmt of statements){const dropColMatch=stmt.match(dropColumnPattern);if(dropColMatch&&dropColMatch[1]&&dropColMatch[2]){const tableName=dropColMatch[1],columnName=dropColMatch[2];if(!sqliteDb){if(earlierCreateDefinesColumn(file,tableName,columnName)){filteredStatements.push(stmt);continue}log.info(`Skipping DROP COLUMN "${columnName}" - no database exists yet: ${file}`);modified=!0;continue}try{const safeTableName=tableName.replace(/[^a-zA-Z0-9_]/g,""),columns=sqliteDb.prepare(`PRAGMA table_info("${safeTableName}")`).all();if(columns.length===0){if(earlierCreateDefinesColumn(file,tableName,columnName)){filteredStatements.push(stmt);continue}log.info(`Skipping DROP COLUMN "${columnName}" - table "${tableName}" does not exist yet: ${file}`);modified=!0;continue}if(!columns.some((col)=>col.name===columnName)){log.info(`Skipping DROP COLUMN "${columnName}" from "${tableName}" - column does not exist: ${file}`);modified=!0;continue}}catch{if(earlierCreateDefinesColumn(file,tableName,columnName)){filteredStatements.push(stmt);continue}log.info(`Skipping DROP COLUMN "${columnName}" - table "${tableName}" not found: ${file}`);modified=!0;continue}}filteredStatements.push(stmt)}if(modified){if(filteredStatements.length===0)retireMigration(file,"columns already absent from table");else writeFileSync(filePath,`${filteredStatements.join(`;
5
+ `)}`})}function enumMembers(list){return[...list.matchAll(/'(?:[^']|'')*'/g)].map((match)=>match[0])}export function preprocessSqliteMigrations(){const migrationsDir=migrationDirectory("sqlite"),quarantined=[];try{for(const stale of readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql.unsupported"))){const original=join(migrationsDir,stale.slice(0,-12));try{if(!existsSync(original))renameSync(join(migrationsDir,stale),original);else unlinkSync(join(migrationsDir,stale))}catch{}}}catch{}let files;try{files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql"))}catch{return quarantined}const droppedMigrations=[],skipMigration=(file,reason)=>{log.info(`Skipping migration on SQLite (${reason}): ${file}`);droppedMigrations.push(file)},partitionUnsupported=(file,filePath,statements,pattern,feature)=>{const unsupported=statements.filter((s)=>pattern.test(s));if(unsupported.length===statements.length){skipMigration(file,`SQLite does not support ${feature}`);return"skipped"}const hiddenPath=`${filePath}.unsupported`;try{renameSync(filePath,hiddenPath)}catch(error){log.error(`[migration] Could not quarantine ${file}: ${error instanceof Error?error.message:String(error)}`);return"runnable"}quarantined.push({original:filePath,hidden:hiddenPath,feature});log.warn(`[migration] ${file} mixes ${unsupported.length} ${feature} statement(s) SQLite cannot run with ${statements.length-unsupported.length} it can, so none of it was applied. Split them into separate migrations, or run this app on MySQL/Postgres. The rest of the corpus was applied.`);return"quarantined"},retireMigration=(file,reason)=>{log.info(`Not running no-op migration (${reason}): ${file}`);droppedMigrations.push(file)},replayMigrations=[],addConstraintPattern=/^\s*ALTER\s+TABLE\s+.+\s+ADD\s+CONSTRAINT\s+/i,createTypePattern=/^\s*CREATE\s+TYPE\s+/i,uniqueIndexFiles=corpusUniqueIndexes(files.map((file)=>({file,content:readFileSync(join(migrationsDir,file),"utf-8")}))),dropColumnPattern=/^\s*ALTER\s+TABLE\s+["']?(\w+)["']?\s+DROP\s+COLUMN\s+["']?(\w+)["']?\s*$/i,addColumnPattern=/^\s*ALTER\s+TABLE\s+["']?(\w+)["']?\s+ADD\s+COLUMN\s+["'`]?(\w+)["'`]?/i,createTablePattern=/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(\w+)["']?/i,createTableEarliest=new Map;for(const file of files){const m=file.match(/^\d+-create-(\w+)-table\.sql$/);if(!m||!m[1])continue;const tableName=m[1],existing=createTableEarliest.get(tableName);if(!existing||file<existing)createTableEarliest.set(tableName,file)}const earlierCreateDefinesColumn=(migrationFile,table,column)=>{const createFile=createTableEarliest.get(table);if(!createFile||createFile>=migrationFile)return!1;try{const createContent=readFileSync(join(migrationsDir,createFile),"utf8"),createStatement=sqlStatementsOf(createContent).find((statement)=>statement.match(createTablePattern)?.[1]===table);if(!createStatement)return!1;const escapedColumn=column.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return new RegExp(`(?:^|[,(])\\s*["'\`]?${escapedColumn}["'\`]?\\s+`,"i").test(createStatement)}catch{return!1}},sqliteDbPath=sqliteDatabasePath();let sqliteDb=null;if(existsSync(sqliteDbPath))try{const{Database}=require("bun:sqlite");sqliteDb=new Database(sqliteDbPath,{readonly:!0})}catch{}const migrationWasRecorded=(file)=>{if(!sqliteDb)return!1;try{return Boolean(sqliteDb.prepare("SELECT 1 FROM migrations WHERE migration = ? LIMIT 1").get(file))}catch{return!1}};for(const file of files){log.debug(`[migration] Running: ${file}`);const filePath=join(migrationsDir,file),content=readFileSync(filePath,"utf-8"),statements=sqlStatementsOf(content);if(statements.length===0)continue;const declaredUniques=uniqueIndexFiles.get(file);if(sqliteDb&&declaredUniques){const liveDb=sqliteDb;if(declaredUniques.filter((index)=>!hasIndex(liveDb,index.name)&&uniqueIndexIsRestorable(liveDb,index)).length>0){log.info(`Re-queueing unique-index migration (index missing from database): ${file}`);replayMigrations.push(file)}continue}if(migrationWasRecorded(file))continue;const firstStatement=statements[0],createTableMatch=firstStatement?firstStatement.match(createTablePattern):null;if(createTableMatch&&createTableMatch[1]){const tableName=createTableMatch[1],earliest=createTableEarliest.get(tableName);if(earliest&&earliest!==file&&!isPackageMigration(file)){retireMigration(file,`duplicate create-table for "${tableName}" (kept ${earliest})`);continue}}if(statements.some((s)=>addConstraintPattern.test(s))){if(partitionUnsupported(file,filePath,statements,addConstraintPattern,"ALTER TABLE ADD CONSTRAINT")!=="runnable")continue}if(statements.some((s)=>createTypePattern.test(s))){if(partitionUnsupported(file,filePath,statements,createTypePattern,"CREATE TYPE (enum types)")!=="runnable")continue}const addColumnTargets=statements.map((s)=>s.match(addColumnPattern)).filter((m)=>Boolean(m?.[1]&&m[2])).map((m)=>({table:m[1],column:m[2]}));if(addColumnTargets.length>0&&addColumnTargets.length===statements.length){const satisfied=addColumnTargets.filter(({table,column})=>{try{if(sqliteDb){const safeTableName=table.replace(/[^a-zA-Z0-9_]/g,""),columns=sqliteDb.prepare(`PRAGMA table_info("${safeTableName}")`).all();if(columns.some((col)=>col.name===column))return!0;if(columns.length>0)return!1}return earlierCreateDefinesColumn(file,table,column)}catch{return!1}});if(satisfied.length===addColumnTargets.length){skipMigration(file,"every column it adds already exists or is defined by an earlier create-table migration");continue}if(satisfied.length>0)log.warn(`[migration] ${file} is partially applied: ${satisfied.map((p)=>`${p.table}.${p.column}`).join(", ")} already exist${satisfied.length===1?"s":""} or will be created earlier, the rest do not. SQLite cannot skip a single ADD COLUMN, so this file will fail. Add the remaining columns by hand, or split the file so the applied statements sit in their own migration.`)}if(statements.some((s)=>dropColumnPattern.test(s))){let modified=!1;const filteredStatements=[];for(const stmt of statements){const dropColMatch=stmt.match(dropColumnPattern);if(dropColMatch&&dropColMatch[1]&&dropColMatch[2]){const tableName=dropColMatch[1],columnName=dropColMatch[2];if(!sqliteDb){if(earlierCreateDefinesColumn(file,tableName,columnName)){filteredStatements.push(stmt);continue}log.info(`Skipping DROP COLUMN "${columnName}" - no database exists yet: ${file}`);modified=!0;continue}try{const safeTableName=tableName.replace(/[^a-zA-Z0-9_]/g,""),columns=sqliteDb.prepare(`PRAGMA table_info("${safeTableName}")`).all();if(columns.length===0){if(earlierCreateDefinesColumn(file,tableName,columnName)){filteredStatements.push(stmt);continue}log.info(`Skipping DROP COLUMN "${columnName}" - table "${tableName}" does not exist yet: ${file}`);modified=!0;continue}if(!columns.some((col)=>col.name===columnName)){log.info(`Skipping DROP COLUMN "${columnName}" from "${tableName}" - column does not exist: ${file}`);modified=!0;continue}}catch{if(earlierCreateDefinesColumn(file,tableName,columnName)){filteredStatements.push(stmt);continue}log.info(`Skipping DROP COLUMN "${columnName}" - table "${tableName}" not found: ${file}`);modified=!0;continue}}filteredStatements.push(stmt)}if(modified){if(filteredStatements.length===0)retireMigration(file,"columns already absent from table");else writeFileSync(filePath,`${filteredStatements.join(`;
7
6
  `)};
8
7
  `);continue}}}if(sqliteDb)try{sqliteDb.close()}catch{}if(droppedMigrations.length>0||replayMigrations.length>0)try{const dbPath=sqliteDatabasePath();mkdirSync(dirname(dbPath),{recursive:!0});const{Database}=require("bun:sqlite"),writeDb=new Database(dbPath);try{writeDb.exec(`CREATE TABLE IF NOT EXISTS migrations (
9
8
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -12,10 +11,10 @@ ${missing.join(`
12
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)}
13
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(){if(databaseBootstrapped)return;await ensureDatabaseExists();databaseBootstrapped=!0}export function resetDatabaseBootstrapCache(){databaseBootstrapped=!1}async function hideDisabledFeatureMigrations(){const hidden=[];try{const{appModelClaimsTable,FEATURE_NAMES,migrationFeature,migrationTable}=await import("@stacksjs/features"),{feature:isFeatureEnabled}=await import("@stacksjs/config"),fs=await import("node:fs/promises"),migrationsDir=migrationDirectory();if(!existsSync(migrationsDir))return hidden;const disabledFeatures=new Set(FEATURE_NAMES.filter((f)=>!isFeatureEnabled(f)));if(disabledFeatures.size===0)return hidden;const files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql")),gatedTables=new Set;for(const file of files){const owner=migrationFeature(file);if(!owner||!disabledFeatures.has(owner))continue;const table=migrationTable(file);if(table&&appModelClaimsTable(table))continue;if(table)gatedTables.add(table.toLowerCase());const original=join(migrationsDir,file),hiddenPath=`${original}.disabled`;await fs.rename(original,hiddenPath);hidden.push({original,hidden:hiddenPath,feature:owner})}for(const file of files){const filePath=join(migrationsDir,file);if(!existsSync(filePath))continue;const sql=readFileSync(filePath,"utf8"),filtered=withoutGatedStatements(sql,gatedTables);if(filtered===sql)continue;const backup=`${filePath}.ungated`;await fs.rename(filePath,backup);writeFileSync(filePath,filtered);hidden.push({original:filePath,hidden:backup,feature:"mixed"})}if(hidden.length>0){const summary=Object.entries(hidden.reduce((acc,h)=>{acc[h.feature]=(acc[h.feature]??0)+1;return acc},{})).map(([f,n])=>`${f}: ${n}`).join(", ");log.info(`[migration] Skipping ${hidden.length} migration(s) for disabled features (${summary}). Run \`./buddy <feature>:install\` to enable.`)}}catch{}return hidden}function afterCommonTableExpressions(statement){if(!/^\s*WITH\b/i.test(statement))return statement;let depth=0;for(let index=0;index<statement.length;index++){const char=statement[index];if(char==="("){depth++;continue}if(char!==")")continue;depth--;if(depth!==0)continue;const rest=statement.slice(index+1);if(/^\s*,/.test(rest))continue;return rest}return statement}export function statementTable(statement){const body=afterCommonTableExpressions(statement),patterns=[/^\s*ALTER\s+TABLE\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?([a-z0-9_]+)["`]?/i,/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["`]?([a-z0-9_]+)["`]?/i,/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?[a-z0-9_]+["`]?\s+ON\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*UPDATE\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*INSERT\s+(?:OR\s+\w+\s+)?INTO\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*DELETE\s+FROM\s+["`]?([a-z0-9_]+)["`]?/i];for(const pattern of patterns){const match=pattern.exec(body);if(match)return match[1].toLowerCase()}return null}export function statementReferencesTable(statement,table){const escaped=table.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return new RegExp(`\\b(?:FROM|JOIN|UPDATE|INTO)\\s+["\`\\[]?${escaped}["\`\\]]?\\b`,"i").test(statement)}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(`;
14
13
  `)};
15
- `}async function restoreHiddenMigrations(hidden){const fs=await import("node:fs/promises");for(const{original,hidden:h}of hidden)try{if(h.endsWith(".ungated"))await fs.rm(original,{force:!0});await fs.rename(h,original)}catch{}}export async function countAppliedMigrations(){try{const row=await db.selectFrom("migrations").select((eb)=>eb.fn.count("id").as("n")).executeTakeFirst();if(!row)return 0;const n=Number(row.n??row.N??0);return Number.isFinite(n)?n:0}catch{return 0}}async function writeMigrateMarker(appliedCount){try{const fs=await import("node:fs/promises"),dir=path.frameworkRuntimePath();await fs.mkdir(dir,{recursive:!0});const file=`${dir}/last-migrate-result.json`,body=JSON.stringify({appliedCount,completedAt:new Date().toISOString()});await fs.writeFile(file,body,"utf8")}catch{}}export function idempotentSql(sql){const header=/^(?:[^\S\n]*--[^\n]*\n)+/.exec(sql)?.[0]??"",stmts=sql.slice(header.length).split(";").map((s)=>s.trim()).filter(Boolean);if(stmts.length===0)return sql;const out=[];for(const raw of stmts){const stmt=raw.replace(/\bADD\s+COLUMN\s+(?!IF\s+NOT\s+EXISTS\b)/gi,"ADD COLUMN IF NOT EXISTS "),m=/^ALTER\s+TABLE\s+("?\w+"?)\s+ADD\s+CONSTRAINT\s+("?\w+"?)/i.exec(stmt);if(m){const drops=[`ALTER TABLE ${m[1]} DROP CONSTRAINT IF EXISTS ${m[2]}`],fk=/\bFOREIGN\s+KEY\s*\(\s*"?(\w+)"?\s*\)/i.exec(stmt);if(fk){const table=m[1].replace(/"/g,"");drops.push(`ALTER TABLE ${m[1]} DROP CONSTRAINT IF EXISTS "${table}_${fk[1]}_fkey"`)}const already=new Set;for(let i=out.length-1;i>=0;i--){const previous=out[i];if(!/^ALTER\s+TABLE\s+"?\w+"?\s+DROP\s+CONSTRAINT\b/i.test(previous))break;already.add(previous.toUpperCase())}for(const drop of drops)if(!already.has(drop.toUpperCase()))out.push(drop)}out.push(stmt)}return`${header}${out.join(`;
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(){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]??"",stmts=sql.slice(header.length).split(";").map((s)=>s.trim()).filter(Boolean);if(stmts.length===0)return sql;const out=[];for(const raw of stmts){const stmt=raw.replace(/\bADD\s+COLUMN\s+(?!IF\s+NOT\s+EXISTS\b)/gi,"ADD COLUMN IF NOT EXISTS "),m=/^ALTER\s+TABLE\s+("?\w+"?)\s+ADD\s+CONSTRAINT\s+("?\w+"?)/i.exec(stmt);if(m){const drops=[`ALTER TABLE ${m[1]} DROP CONSTRAINT IF EXISTS ${m[2]}`],fk=/\bFOREIGN\s+KEY\s*\(\s*"?(\w+)"?\s*\)/i.exec(stmt);if(fk){const table=m[1].replace(/"/g,"");drops.push(`ALTER TABLE ${m[1]} DROP CONSTRAINT IF EXISTS "${table}_${fk[1]}_fkey"`)}const already=new Set;for(let i=out.length-1;i>=0;i--){const previous=out[i];if(!/^ALTER\s+TABLE\s+"?\w+"?\s+DROP\s+CONSTRAINT\b/i.test(previous))break;already.add(previous.toUpperCase())}for(const drop of drops)if(!already.has(drop.toUpperCase()))out.push(drop)}out.push(stmt)}return`${header}${out.join(`;
16
15
  `)};
17
- `}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}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)))continue;const next=orderPostgresColumnTypeChanges(guardPostgresEnumTypes(idempotentSql(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.`)}export async function runDatabaseMigration(){const startedAt=Date.now(),hidden=await hideDisabledFeatureMigrations();let quarantinedMigrations=[],lockHandle=null;try{log.debug("Migrating database...");await ensureDatabaseReady();configureQueryBuilder();const dialect=getDialect(),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=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(`
18
- `)}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();await catchUpFrameworkRenames();log.debug(`[migration] Running migrations from: ${corpusDir}`);await qbExecuteMigration(corpusDir);await catchUpFrameworkRenames();const notificationTables=await migrateNotificationTables();if(!notificationTables.success)throw Error(notificationTables.error||"Failed to prepare notification tables");await ensureNotificationForeignKeys();const appliedAfter=await countAppliedMigrations(),appliedCount=Math.max(0,appliedAfter-appliedBefore);await writeMigrateMarker(appliedCount);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)}}const FRAMEWORK_TABLES=["oauth_refresh_tokens","oauth_access_tokens","oauth_clients","passkeys","failed_jobs","jobs","notifications","password_reset_tokens"];export async function resetDatabase(){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(`
16
+ `}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}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)))continue;const next=orderPostgresColumnTypeChanges(guardPostgresEnumTypes(idempotentSql(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.`)}function restoreUniqueIndexesAfterBatch(corpusDir){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(){const startedAt=Date.now(),hidden=await hideDisabledFeatureMigrations();let quarantinedMigrations=[],lockHandle=null;try{log.debug("Migrating database...");await ensureDatabaseReady();configureQueryBuilder();const dialect=getDialect(),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=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(`
17
+ `)}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();await catchUpFrameworkRenames();log.debug(`[migration] Running migrations from: ${corpusDir}`);await qbExecuteMigration(corpusDir);await catchUpFrameworkRenames();if(dialect==="sqlite")restoreUniqueIndexesAfterBatch(corpusDir);const notificationTables=await migrateNotificationTables();if(!notificationTables.success)throw Error(notificationTables.error||"Failed to prepare notification tables");await ensureNotificationForeignKeys();const appliedAfter=await countAppliedMigrations(),appliedCount=Math.max(0,appliedAfter-appliedBefore);await writeMigrateMarker(appliedCount);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)}}const FRAMEWORK_TABLES=["oauth_refresh_tokens","oauth_access_tokens","oauth_clients","passkeys","failed_jobs","jobs","notifications","password_reset_tokens"];export async function resetDatabase(){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(`
19
18
  SELECT t.typname AS name
20
19
  FROM pg_type t
21
20
  JOIN pg_namespace n ON n.oid = t.typnamespace
@@ -26,15 +25,15 @@ Create it yourself with: ${manualCreateHint(target)}`);if(result.created)log.su
26
25
  JOIN pg_class c ON c.oid = a.attrelid
27
26
  WHERE a.atttypid = t.oid AND c.relkind = 'r' AND NOT a.attisdropped
28
27
  )
29
- `).execute(),names=(Array.isArray(raw)?raw:raw?.rows??[]).map((row)=>String(row.name??row.typname??"")).filter(Boolean);for(const name of names)try{await db.unsafe(`DROP TYPE IF EXISTS "${name}" CASCADE`).execute();log.debug(`Dropped orphaned enum type: ${name}`)}catch(error){log.warn(`Could not drop enum type ${name}: ${error instanceof Error?error.message:String(error)}`)}}catch(error){log.warn(`Could not list enum types to drop: ${error instanceof Error?error.message:String(error)}`)}}async function dropFrameworkTables(dialect){if(dialect==="mysql"||dialect==="vitess")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 0").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = OFF").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}for(const tableName of FRAMEWORK_TABLES)try{let dropSql;if(dialect==="postgres")dropSql=`DROP TABLE IF EXISTS "${tableName}" CASCADE`;else if(dialect==="mysql"||dialect==="vitess")dropSql=`DROP TABLE IF EXISTS \`${tableName}\``;else dropSql=`DROP TABLE IF EXISTS "${tableName}"`;log.info(`Dropping framework table: ${tableName}`);await db.unsafe(dropSql).execute();log.info(`Dropped framework table: ${tableName}`)}catch(error){const kind=classifyConnectionError(error);if(kind==="missing-database"||kind==="missing-role"||kind==="auth-failed"||kind==="server-unreachable"||kind==="timeout")throw error;log.warn(`Could not drop table ${tableName}: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="mysql"||dialect==="vitess")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 1").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = ON").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}}function frameworkRenamesForThisApp(){return applicableFrameworkRenames(declaredModelTables())}function describeRenames(renames){return renames.map((rename)=>`${rename.id}, ${rename.reference}`).join("; ")}export function catchUpSnapshotFrameworkRenames(options){configureQueryBuilder();const dialect=getQbDialect(),stored=readStoredMigrationPlan(dialect);if(!stored)return{changes:[]};const renames=frameworkRenamesForThisApp();if(renames.length===0)return{changes:[]};const{plan,changes}=applyFrameworkRenamesToPlan(stored,renames);if(changes.length===0)return{changes};if(options.persist){saveMigrationSnapshot(plan,{dialect});log.info(`[migration] Updated the model snapshot for framework renames (${describeRenames(renames)}): ${changes.join(", ")}`)}else log.debug(`[migration] Previewing against the snapshot with framework renames applied: ${changes.join(", ")}`);return{changes,plan}}export async function catchUpFrameworkRenames(options={}){const dialect=toSqlIntrospectionDialect(getDialect());if(dialect==="other")return{applied:[],skipped:[]};const renames=frameworkRenamesForThisApp();if(renames.length===0)return{applied:[],skipped:[]};const runner=options.runner??(async(sql)=>{const result=await db.unsafe(sql).execute();return Array.isArray(result)?result:[]}),result=await applyFrameworkRenamesToDatabase(runner,dialect,renames);if(result.applied.length>0)log.info(`[migration] Applied framework renames to the database (${describeRenames(renames)}): ${result.applied.join(", ")}`);for(const skipped of result.skipped)log.warn(`[migration] Framework rename not applied: ${skipped}`);return result}function resolveGenerateOptions(options){const applyRenames=options.applyRenames??(process.env.STACKS_MIGRATE_NO_RENAME==="1"?!1:void 0),fromDb=options.fromDb??(process.env.STACKS_MIGRATE_FROM_DB==="1"?!0:void 0);return{applyRenames,fromDb}}export async function previewPendingMigrations(options={}){try{return await pendingMigrationOperations(options)}catch(error){log.debug(`[migration] preview failed: ${error instanceof Error?error.message:String(error)}`);return[]}}export async function pendingMigrationOperations(options={}){configureQueryBuilder();const dialect=getDialect(),{modelsDir,skip,protectedTables}=prepareMigrationModelsDir();if(skip)return[];const{applyRenames,fromDb}=resolveGenerateOptions(options),qbDialect=getQbDialect(),result=await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,dryRun:!0,applyRenames,fromDb}),corrected=fromDb?void 0:catchUpSnapshotFrameworkRenames({persist:!1}).plan;if(corrected&&result.plan)result.operations=generateDiffOperations(corrected,result.plan,{applyRenames,dryRun:!0}).operations;let operations=result.operations??[];if(protectedTables.length>0){const excluded=new Set(protectedTables.map((table)=>table.toLowerCase()));operations=operations.filter((op)=>!(op.kind==="drop_table"&&excluded.has(op.table.toLowerCase())))}if(!operations.some((op)=>op.kind==="drop_column"))return operations;return withoutManagedColumnDrops(operations,await frameworkManagedColumns())}function snapshotDirLabel(){return qbConfig?.snapshotDir||qbSnapshotDir()}function resolveSnapshotDir(){const label=snapshotDirLabel();return isAbsolute(label)?label:resolve(process.cwd(),label)}function snapshotPathFor(dialect){return join(resolveSnapshotDir(),`model-snapshot.${dialect}.json`)}function readStoredMigrationPlan(dialect){const snapshotPath=snapshotPathFor(dialect);if(!existsSync(snapshotPath))return;let parsed;try{parsed=JSON.parse(readFileSync(snapshotPath,"utf8"))}catch(error){throw Error(`The migration snapshot is not valid JSON: ${snapshotPath}. Repair or regenerate it before creating migrations.`,{cause:error})}const candidate=parsed&&typeof parsed==="object"&&"plan"in parsed?parsed.plan:parsed;if(!candidate||typeof candidate!=="object"||!Array.isArray(candidate.tables))throw TypeError(`The migration snapshot has an invalid structure: ${snapshotPath}. Repair or regenerate it before creating migrations.`);return candidate}export function preserveMigrationPlanTableOrder(next,previous){const orderedByPrevious=(values,previousValues,keyFor)=>{if(!previousValues)return[...values];const previousOrder=new Map(previousValues.map((value,index)=>[keyFor(value),index])),nextOrder=new Map(values.map((value,index)=>[keyFor(value),index])),previousCount=previousValues.length;return[...values].sort((left,right)=>{const leftKey=keyFor(left),rightKey=keyFor(right),leftOrder=previousOrder.get(leftKey)??previousCount+nextOrder.get(leftKey),rightOrder=previousOrder.get(rightKey)??previousCount+nextOrder.get(rightKey);return leftOrder-rightOrder})},previousTables=new Map(previous?.tables.map((table)=>[table.table,table])??[]),tables=next.tables.map((table)=>{const previousTable=previousTables.get(table.table),previousColumns=new Map(previousTable?.columns.map((column)=>[column.name,column])??[]);return{...table,columns:orderedByPrevious(table.columns.map((column)=>{if(next.dialect!=="sqlite"||!column.enumTypeName||previousColumns.get(column.name)?.enumTypeName)return column;const portableColumn={...column};delete portableColumn.enumTypeName;return portableColumn}),previousTable?.columns,(column)=>column.name),indexes:orderedByPrevious(table.indexes,previousTable?.indexes,(index)=>index.name)}});return{...next,tables:orderedByPrevious(tables,previous?.tables,(table)=>table.table)}}function detectSnapshotDialectMismatch(dialect){const qbDir=resolveSnapshotDir();let files;try{files=readdirSync(qbDir)}catch{return null}const snapshotFor=(d)=>`model-snapshot.${d}.json`;if(files.includes(snapshotFor(dialect)))return null;for(const f of files){const m=/^model-snapshot\.(\w+)\.json$/.exec(f);if(m?.[1]&&m[1]!==dialect)return m[1]}return null}export async function generateMigrations(options={}){try{log.debug("Generating migrations...");configureQueryBuilder();const dialect=getDialect(),mismatch=detectSnapshotDialectMismatch(dialect),flatMigrationDir=join(process.cwd(),"database","migrations");if(mismatch&&migrationDirectory(dialect)===flatMigrationDir){const snapshotDir=snapshotDirLabel();return err(Error(`Refusing to generate migrations: resolved dialect "${dialect}" has no snapshot in ${snapshotDir}/, but "${mismatch}" does. DB_CONNECTION is likely unset or wrong (missing .env?) - generating now would write a full duplicate migration set in the wrong dialect. Set DB_CONNECTION=${mismatch} (or your intended dialect) and retry. To intentionally start a new dialect from scratch, remove ${snapshotDir}/model-snapshot.${mismatch}.json first.`))}catchUpSnapshotFrameworkRenames({persist:!0});const storedPlan=readStoredMigrationPlan(getQbDialect()),{modelsDir,skip,excludedTables,protectedTables}=prepareMigrationModelsDir();if(skip){log.debug("No app/Models directory found; using committed framework migrations");return ok("Migrations generated")}const{applyRenames,fromDb}=resolveGenerateOptions(options);log.debug(`[migration] Generating migrations for dialect: ${dialect}, models: ${modelsDir}`);if(excludedTables.length>0)log.debug(`[migration] ${excludedTables.length} framework default model(s) out of scope because app/Models defines this app's schema. Enable database.models.includeFrameworkDefaults (or STACKS_INCLUDE_FRAMEWORK_MODELS=1) to generate them too.`);const qbDialect=getQbDialect(),result=await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,dryRun:!0,applyRenames,fromDb});let sqlStatements=result.sqlStatements??[];if(dialect==="sqlite")sqlStatements=inlineSqliteAddedColumnReferences(sqlStatements,result.plan);if(result.hasChanges&&sqlStatements.length>0){const filtered=withoutManagedColumnDropSql(sqlStatements,await frameworkManagedColumns(),result.operations??[]);if(filtered.removed.length>0)log.debug(`[migration] Skipped ${filtered.removed.length} generated drop(s) of framework-managed column(s) (stacksjs/stacks#2075)`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0&&protectedTables.length>0){const filtered=withoutProtectedTableDropSql(sqlStatements,protectedTables,result.operations??[]);if(filtered.removed.length>0)log.info(`[migration] Left ${filtered.removed.length} framework-owned table(s) in place rather than dropping them. They are no longer generated because app/Models defines this app's schema; the tables and their data are untouched. Set database.models.includeFrameworkDefaults to keep generating them (stacksjs/stacks#2220).`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0){const dangling=findDanglingTypeReferences(sqlStatements);if(dangling.length>0){const created=createMissingEnumTypes(dangling,result.plan);if(created.statements.length>0){sqlStatements=[...created.statements,...sqlStatements];log.debug(`[migration] Created ${created.statements.length} enum type(s) an ALTER needed: ${created.defined.join(", ")}`)}const unresolved=dangling.filter((name)=>!created.defined.includes(name));if(unresolved.length>0){const before=sqlStatements.length;sqlStatements=sqlStatements.filter((statement)=>!referencesUndefinedType(statement,unresolved));log.warn(`[migration] Skipped ${before-sqlStatements.length} generated statement(s) referencing enum type(s) nothing creates and no model defines values for (${unresolved.slice(0,3).join(", ")}${unresolved.length>3?", \u2026":""}).`)}}}if(result.hasChanges&&sqlStatements.length>0&&!shadowDropsAllowed()){const shadowed=resolveModelSources()?.shadowed??[],drops=findShadowedColumnDrops(sqlStatements,shadowed);if(drops.length>0)return err(Error(shadowedDropMessage(drops)))}if(result.hasChanges){const written=persistGeneratedMigrations(sqlStatements);if(written>0)log.success(`Generated ${written} migration file${written===1?"":"s"}`);else log.debug("Migration generation produced no new files (already up to date)")}else log.debug("No changes detected");const stablePlan=preserveMigrationPlanTableOrder(result.plan,storedPlan);if(!storedPlan||JSON.stringify(stablePlan)!==JSON.stringify(storedPlan))saveMigrationSnapshot(stablePlan,{dialect:getQbDialect()});else log.debug("Model snapshot unchanged");return ok("Migrations generated")}catch(error){const because=error instanceof Error?error.message:String(error);return err(handleError(`Migration generation failed: ${because}`,error))}}export function referencesUndefinedType(statement,dangling){return dangling.some((name)=>statement.includes(`"${name}"`))}export function findDanglingTypeReferences(statements){const defined=new Set,referenced=new Set;for(const statement of statements){for(const match of statement.matchAll(/CREATE\s+TYPE\s+"([^"]+)"/gi))defined.add(match[1]);for(const match of statement.matchAll(/\bTYPE\s+"([^"]+)"/gi))referenced.add(match[1])}return[...referenced].filter((name)=>!defined.has(name)).sort()}export function createMissingEnumTypes(dangling,plan){if(dangling.length===0)return{statements:[],defined:[]};const values=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){if(!column.name||!column.enumValues?.length)continue;values.set(`${table.table}_${column.name}_type`,column.enumValues)}}const statements=[],defined=[];for(const name of dangling){const members=values.get(name);if(!members?.length)continue;const literals=members.map((member)=>`'${String(member).replaceAll("'","''")}'`).join(", "),guarded=guardPostgresEnumTypes(`CREATE TYPE "${name}" AS ENUM (${literals})`);statements.push(guarded.endsWith(";")?guarded:`${guarded};`);defined.push(name)}return{statements,defined}}export function inlineSqliteAddedColumnReferences(statements,plan){const references=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){const reference=column.references;if(!column.name||!reference?.table||!reference.column)continue;references.set(`${table.table}.${column.name}`,{table:reference.table,column:reference.column})}}if(references.size===0)return statements;return statements.map((statement)=>{if(/\bREFERENCES\b/i.test(statement))return statement;const match=statement.match(/^(\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+COLUMN\s+["`]?(\w+)["`]?\s+)([\s\S]*?)(;?\s*)$/i),reference=match?.[2]&&match[3]?references.get(`${match[2]}.${match[3]}`):void 0;if(!match||!reference)return statement;return`${match[1]}${match[4].trimEnd()} REFERENCES "${reference.table}"("${reference.column}")${match[5]}`})}export const GENERATED_MIGRATION_MARKER=["qb:generated","@generated by `buddy migrate:regenerate` - edits will be overwritten"].map((line)=>`-- ${line}`).join(`
28
+ `).execute(),names=(Array.isArray(raw)?raw:raw?.rows??[]).map((row)=>String(row.name??row.typname??"")).filter(Boolean);for(const name of names)try{await db.unsafe(`DROP TYPE IF EXISTS "${name}" CASCADE`).execute();log.debug(`Dropped orphaned enum type: ${name}`)}catch(error){log.warn(`Could not drop enum type ${name}: ${error instanceof Error?error.message:String(error)}`)}}catch(error){log.warn(`Could not list enum types to drop: ${error instanceof Error?error.message:String(error)}`)}}async function dropFrameworkTables(dialect){if(dialect==="mysql"||dialect==="vitess")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 0").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = OFF").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}for(const tableName of FRAMEWORK_TABLES)try{let dropSql;if(dialect==="postgres")dropSql=`DROP TABLE IF EXISTS "${tableName}" CASCADE`;else if(dialect==="mysql"||dialect==="vitess")dropSql=`DROP TABLE IF EXISTS \`${tableName}\``;else dropSql=`DROP TABLE IF EXISTS "${tableName}"`;log.info(`Dropping framework table: ${tableName}`);await db.unsafe(dropSql).execute();log.info(`Dropped framework table: ${tableName}`)}catch(error){const kind=classifyConnectionError(error);if(kind==="missing-database"||kind==="missing-role"||kind==="auth-failed"||kind==="server-unreachable"||kind==="timeout")throw error;log.warn(`Could not drop table ${tableName}: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="mysql"||dialect==="vitess")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 1").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = ON").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}}function frameworkRenamesForThisApp(){return applicableFrameworkRenames(declaredModelTables())}function describeRenames(renames){return renames.map((rename)=>`${rename.id}, ${rename.reference}`).join("; ")}export function catchUpSnapshotFrameworkRenames(options){configureQueryBuilder();const dialect=getQbDialect(),stored=readStoredMigrationPlan(dialect);if(!stored)return{changes:[]};const renames=frameworkRenamesForThisApp();if(renames.length===0)return{changes:[]};const{plan,changes}=applyFrameworkRenamesToPlan(stored,renames);if(changes.length===0)return{changes};if(options.persist){saveMigrationSnapshot(plan,{dialect});log.info(`[migration] Updated the model snapshot for framework renames (${describeRenames(renames)}): ${changes.join(", ")}`)}else log.debug(`[migration] Previewing against the snapshot with framework renames applied: ${changes.join(", ")}`);return{changes,plan}}export async function catchUpFrameworkRenames(options={}){const dialect=toSqlIntrospectionDialect(getDialect());if(dialect==="other")return{applied:[],skipped:[]};const renames=frameworkRenamesForThisApp();if(renames.length===0)return{applied:[],skipped:[]};const runner=options.runner??(async(sql)=>{const result=await db.unsafe(sql).execute();return Array.isArray(result)?result:[]}),result=await applyFrameworkRenamesToDatabase(runner,dialect,renames);if(result.applied.length>0)log.info(`[migration] Applied framework renames to the database (${describeRenames(renames)}): ${result.applied.join(", ")}`);for(const skipped of result.skipped)log.warn(`[migration] Framework rename not applied: ${skipped}`);return result}function resolveGenerateOptions(options){const applyRenames=options.applyRenames??(process.env.STACKS_MIGRATE_NO_RENAME==="1"?!1:void 0),fromDb=options.fromDb??(process.env.STACKS_MIGRATE_FROM_DB==="1"?!0:void 0);return{applyRenames,fromDb}}export async function previewPendingMigrations(options={}){try{return await pendingMigrationOperations(options)}catch(error){log.debug(`[migration] preview failed: ${error instanceof Error?error.message:String(error)}`);return[]}}export async function pendingMigrationOperations(options={}){configureQueryBuilder();const dialect=getDialect(),{modelsDir,skip,protectedTables}=prepareMigrationModelsDir();if(skip)return[];const{applyRenames,fromDb}=resolveGenerateOptions(options),qbDialect=getQbDialect(),result=await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,dryRun:!0,applyRenames,fromDb}),corrected=fromDb?void 0:catchUpSnapshotFrameworkRenames({persist:!1}).plan;if(corrected&&result.plan)result.operations=generateDiffOperations(corrected,result.plan,{applyRenames,dryRun:!0}).operations;const authOwned=new Set(AUTH_TABLES);let operations=(result.operations??[]).filter((op)=>!authOwned.has(op.table.toLowerCase()));if(protectedTables.length>0){const excluded=new Set(protectedTables.map((table)=>table.toLowerCase()));operations=operations.filter((op)=>!(op.kind==="drop_table"&&excluded.has(op.table.toLowerCase())))}if(!operations.some((op)=>op.kind==="drop_column"))return operations;return withoutManagedColumnDrops(operations,await frameworkManagedColumns())}function snapshotDirLabel(){return qbConfig?.snapshotDir||qbSnapshotDir()}function resolveSnapshotDir(){const label=snapshotDirLabel();return isAbsolute(label)?label:resolve(process.cwd(),label)}function snapshotPathFor(dialect){return join(resolveSnapshotDir(),`model-snapshot.${dialect}.json`)}function readStoredMigrationPlan(dialect){const snapshotPath=snapshotPathFor(dialect);if(!existsSync(snapshotPath))return;let parsed;try{parsed=JSON.parse(readFileSync(snapshotPath,"utf8"))}catch(error){throw Error(`The migration snapshot is not valid JSON: ${snapshotPath}. Repair or regenerate it before creating migrations.`,{cause:error})}const candidate=parsed&&typeof parsed==="object"&&"plan"in parsed?parsed.plan:parsed;if(!candidate||typeof candidate!=="object"||!Array.isArray(candidate.tables))throw TypeError(`The migration snapshot has an invalid structure: ${snapshotPath}. Repair or regenerate it before creating migrations.`);return candidate}export function preserveMigrationPlanTableOrder(next,previous){const orderedByPrevious=(values,previousValues,keyFor)=>{if(!previousValues)return[...values];const previousOrder=new Map(previousValues.map((value,index)=>[keyFor(value),index])),nextOrder=new Map(values.map((value,index)=>[keyFor(value),index])),previousCount=previousValues.length;return[...values].sort((left,right)=>{const leftKey=keyFor(left),rightKey=keyFor(right),leftOrder=previousOrder.get(leftKey)??previousCount+nextOrder.get(leftKey),rightOrder=previousOrder.get(rightKey)??previousCount+nextOrder.get(rightKey);return leftOrder-rightOrder})},previousTables=new Map(previous?.tables.map((table)=>[table.table,table])??[]),tables=next.tables.map((table)=>{const previousTable=previousTables.get(table.table),previousColumns=new Map(previousTable?.columns.map((column)=>[column.name,column])??[]);return{...table,columns:orderedByPrevious(table.columns.map((column)=>{if(next.dialect!=="sqlite"||!column.enumTypeName||previousColumns.get(column.name)?.enumTypeName)return column;const portableColumn={...column};delete portableColumn.enumTypeName;return portableColumn}),previousTable?.columns,(column)=>column.name),indexes:orderedByPrevious(table.indexes,previousTable?.indexes,(index)=>index.name)}});return{...next,tables:orderedByPrevious(tables,previous?.tables,(table)=>table.table)}}function detectSnapshotDialectMismatch(dialect){const qbDir=resolveSnapshotDir();let files;try{files=readdirSync(qbDir)}catch{return null}const snapshotFor=(d)=>`model-snapshot.${d}.json`;if(files.includes(snapshotFor(dialect)))return null;for(const f of files){const m=/^model-snapshot\.(\w+)\.json$/.exec(f);if(m?.[1]&&m[1]!==dialect)return m[1]}return null}export async function generateMigrations(options={}){try{log.debug("Generating migrations...");configureQueryBuilder();const dialect=getDialect(),mismatch=detectSnapshotDialectMismatch(dialect),flatMigrationDir=join(process.cwd(),"database","migrations");if(mismatch&&migrationDirectory(dialect)===flatMigrationDir){const snapshotDir=snapshotDirLabel();return err(Error(`Refusing to generate migrations: resolved dialect "${dialect}" has no snapshot in ${snapshotDir}/, but "${mismatch}" does. DB_CONNECTION is likely unset or wrong (missing .env?) - generating now would write a full duplicate migration set in the wrong dialect. Set DB_CONNECTION=${mismatch} (or your intended dialect) and retry. To intentionally start a new dialect from scratch, remove ${snapshotDir}/model-snapshot.${mismatch}.json first.`))}catchUpSnapshotFrameworkRenames({persist:!0});const storedPlan=readStoredMigrationPlan(getQbDialect()),{modelsDir,skip,excludedTables,protectedTables}=prepareMigrationModelsDir();if(skip){log.debug("No app/Models directory found; using committed framework migrations");return ok("Migrations generated")}const{applyRenames,fromDb}=resolveGenerateOptions(options);log.debug(`[migration] Generating migrations for dialect: ${dialect}, models: ${modelsDir}`);if(excludedTables.length>0)log.debug(`[migration] ${excludedTables.length} framework default model(s) out of scope because app/Models defines this app's schema. Enable database.models.includeFrameworkDefaults (or STACKS_INCLUDE_FRAMEWORK_MODELS=1) to generate them too.`);const qbDialect=getQbDialect(),result=await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,dryRun:!0,applyRenames,fromDb});let sqlStatements=result.sqlStatements??[];if(dialect==="sqlite")sqlStatements=inlineSqliteAddedColumnReferences(sqlStatements,result.plan);{const runtimeOwned=withoutRuntimeOwnedTableSql(sqlStatements);if(runtimeOwned.removed.length>0)log.debug(`[migration] Left out ${runtimeOwned.removed.length} generated statement(s) for tables the auth layer creates itself`);sqlStatements=runtimeOwned.statements}if(result.hasChanges&&sqlStatements.length>0){const filtered=withoutManagedColumnDropSql(sqlStatements,await frameworkManagedColumns(),result.operations??[]);if(filtered.removed.length>0)log.debug(`[migration] Skipped ${filtered.removed.length} generated drop(s) of framework-managed column(s) (stacksjs/stacks#2075)`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0&&protectedTables.length>0){const filtered=withoutProtectedTableDropSql(sqlStatements,protectedTables,result.operations??[]);if(filtered.removed.length>0)log.info(`[migration] Left ${filtered.removed.length} framework-owned table(s) in place rather than dropping them. They are no longer generated because app/Models defines this app's schema; the tables and their data are untouched. Set database.models.includeFrameworkDefaults to keep generating them (stacksjs/stacks#2220).`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0){const dangling=findDanglingTypeReferences(sqlStatements);if(dangling.length>0){const created=createMissingEnumTypes(dangling,result.plan);if(created.statements.length>0){sqlStatements=[...created.statements,...sqlStatements];log.debug(`[migration] Created ${created.statements.length} enum type(s) an ALTER needed: ${created.defined.join(", ")}`)}const unresolved=dangling.filter((name)=>!created.defined.includes(name));if(unresolved.length>0){const before=sqlStatements.length;sqlStatements=sqlStatements.filter((statement)=>!referencesUndefinedType(statement,unresolved));log.warn(`[migration] Skipped ${before-sqlStatements.length} generated statement(s) referencing enum type(s) nothing creates and no model defines values for (${unresolved.slice(0,3).join(", ")}${unresolved.length>3?", \u2026":""}).`)}}}if(result.hasChanges&&sqlStatements.length>0&&!shadowDropsAllowed()){const shadowed=resolveModelSources()?.shadowed??[],drops=findShadowedColumnDrops(sqlStatements,shadowed);if(drops.length>0)return err(Error(shadowedDropMessage(drops)))}if(result.hasChanges){const written=persistGeneratedMigrations(sqlStatements,storedPlan);if(written>0)log.success(`Generated ${written} migration file${written===1?"":"s"}`);else log.debug("Migration generation produced no new files (already up to date)")}else log.debug("No changes detected");const stablePlan=preserveMigrationPlanTableOrder(result.plan,storedPlan);if(!storedPlan||JSON.stringify(stablePlan)!==JSON.stringify(storedPlan))saveMigrationSnapshot(stablePlan,{dialect:getQbDialect()});else log.debug("Model snapshot unchanged");return ok("Migrations generated")}catch(error){const because=error instanceof Error?error.message:String(error);return err(handleError(`Migration generation failed: ${because}`,error))}}export function referencesUndefinedType(statement,dangling){return dangling.some((name)=>statement.includes(`"${name}"`))}export function findDanglingTypeReferences(statements){const defined=new Set,referenced=new Set;for(const statement of statements){for(const match of statement.matchAll(/CREATE\s+TYPE\s+"([^"]+)"/gi))defined.add(match[1]);for(const match of statement.matchAll(/\bTYPE\s+"([^"]+)"/gi))referenced.add(match[1])}return[...referenced].filter((name)=>!defined.has(name)).sort()}export function createMissingEnumTypes(dangling,plan){if(dangling.length===0)return{statements:[],defined:[]};const values=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){if(!column.name||!column.enumValues?.length)continue;values.set(`${table.table}_${column.name}_type`,column.enumValues)}}const statements=[],defined=[];for(const name of dangling){const members=values.get(name);if(!members?.length)continue;const literals=members.map((member)=>`'${String(member).replaceAll("'","''")}'`).join(", "),guarded=guardPostgresEnumTypes(`CREATE TYPE "${name}" AS ENUM (${literals})`);statements.push(guarded.endsWith(";")?guarded:`${guarded};`);defined.push(name)}return{statements,defined}}export function inlineSqliteAddedColumnReferences(statements,plan){const references=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){const reference=column.references;if(!column.name||!reference?.table||!reference.column)continue;references.set(`${table.table}.${column.name}`,{table:reference.table,column:reference.column})}}if(references.size===0)return statements;return statements.map((statement)=>{if(/\bREFERENCES\b/i.test(statement))return statement;const match=statement.match(/^(\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+COLUMN\s+["`]?(\w+)["`]?\s+)([\s\S]*?)(;?\s*)$/i),reference=match?.[2]&&match[3]?references.get(`${match[2]}.${match[3]}`):void 0;if(!match||!reference)return statement;return`${match[1]}${match[4].trimEnd()} REFERENCES "${reference.table}"("${reference.column}")${match[5]}`})}export const GENERATED_MIGRATION_MARKER=["qb:generated","@generated by `buddy migrate:regenerate` - edits will be overwritten"].map((line)=>`-- ${line}`).join(`
30
29
  `);export function isGeneratedMigration(dir,file){try{return readFileSync(join(dir,file),"utf8").slice(0,200).includes("@generated by `buddy migrate:regenerate`")}catch{return!1}}export function tablesOperatedOn(sql){const tables=new Set;for(const statement of sqlStatementsOf(sql)){const stmt=statement.trim(),direct=stmt.match(/^(?:CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?|ALTER\s+TABLE|DROP\s+TABLE(?:\s+IF\s+EXISTS)?|TRUNCATE\s+TABLE)\s+["'`]?(\w+)["'`]?/i);if(direct?.[1]){tables.add(direct[1].toLowerCase());continue}const index=stmt.match(/^CREATE\s+(?:UNIQUE\s+)?INDEX(?:\s+IF\s+NOT\s+EXISTS)?\s+\S+\s+ON\s+["'`]?(\w+)["'`]?/i);if(index?.[1])tables.add(index[1].toLowerCase())}return[...tables]}export function columnsDefinedByCreate(statement){const body=statement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?\w+["'`]?\s*\(([\s\S]*)\)\s*;?\s*$/i)?.[1];if(!body)return[];const parts=[];let depth=0,current="";for(const char of body){if(char==="(")depth++;if(char===")")depth--;if(char===","&&depth===0){parts.push(current);current="";continue}current+=char}parts.push(current);const constraint=/^\s*(?:PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK|CONSTRAINT|KEY|INDEX)\b/i;return parts.map((part)=>part.trim()).filter((part)=>part&&!constraint.test(part)).flatMap((part)=>part.match(/^["'`]?(\w+)["'`]?/)?.[1]??[])}export function columnsProducedByMigrations(dir,files,table){const columns=new Set,target=table.toLowerCase();for(const file of[...files].sort()){let content;try{content=readFileSync(join(dir,file),"utf8")}catch{continue}for(const statement of sqlStatementsOf(content)){const stmt=statement.trim();if(stmt.match(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i)?.[1]?.toLowerCase()===target){for(const column of columnsDefinedByCreate(stmt))columns.add(column.toLowerCase());continue}const added=stmt.match(/^ALTER\s+TABLE\s+["'`]?(\w+)["'`]?\s+ADD\s+(?:COLUMN\s+)?["'`]?(\w+)["'`]?/i);if(added?.[1]?.toLowerCase()===target&&added[2])columns.add(added[2].toLowerCase());const dropped=stmt.match(/^ALTER\s+TABLE\s+["'`]?(\w+)["'`]?\s+DROP\s+(?:COLUMN\s+)?["'`]?(\w+)["'`]?/i);if(dropped?.[1]?.toLowerCase()===target&&dropped[2])columns.delete(dropped[2].toLowerCase());const rebuilt=stmt.match(/^ALTER\s+TABLE\s+["'`]?_qb_tmp_(\w+)["'`]?\s+RENAME\s+TO\s+["'`]?(\w+)["'`]?/i);if(rebuilt?.[2]?.toLowerCase()===target){const temp=sqlStatementsOf(content).find((s)=>new RegExp(`^CREATE\\s+TABLE\\s+["'\`]?_qb_tmp_${rebuilt[1]}["'\`]?`,"i").test(s.trim()));if(temp){columns.clear();for(const column of columnsDefinedByCreate(temp))columns.add(column.toLowerCase())}}}}return columns}export function rootedTableCatchUpStatements(createStatement,existingColumns){const table=createStatement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i)?.[1];if(!table)return[];const body=createStatement.match(/\(([\s\S]*)\)\s*;?\s*$/)?.[1];if(!body)return[];const definitions=new Map;let depth=0,current="";const parts=[];for(const char of body){if(char==="(")depth++;if(char===")")depth--;if(char===","&&depth===0){parts.push(current);current="";continue}current+=char}parts.push(current);const constraint=/^\s*(?:PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK|CONSTRAINT|KEY|INDEX)\b/i;for(const raw of parts){const part=raw.trim();if(!part||constraint.test(part))continue;const name=part.match(/^["'`]?(\w+)["'`]?/)?.[1];if(name)definitions.set(name.toLowerCase(),part)}const statements=[];for(const[name,definition]of definitions){if(existingColumns.has(name))continue;if(/\b(?:PRIMARY\s+KEY|UNIQUE|AUTOINCREMENT)\b/i.test(definition))continue;const nullable=/\bNOT\s+NULL\b/i.test(definition)&&!/\bDEFAULT\b/i.test(definition)?definition.replace(/\s*\bNOT\s+NULL\b/i,""):definition;statements.push(`ALTER TABLE "${table}" ADD COLUMN ${nullable.trim()}`)}return statements}export function createdTablesOf(statements){const tables=new Set;for(const statement of statements){const match=statement.trim().match(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i);if(match?.[1])tables.add(match[1].toLowerCase())}return[...tables]}export function historicallyRootedTables(dir,files){const tables=new Set;for(const file of files){if(isGeneratedMigration(dir,file))continue;try{for(const statement of sqlStatementsOf(readFileSync(join(dir,file),"utf8"))){const match=statement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i);if(match?.[1])tables.add(match[1].toLowerCase())}}catch{}}return[...tables]}export function tablesDefinedByCorpus(dir,files){const tables=new Set;for(const file of files)try{for(const statement of sqlStatementsOf(readFileSync(join(dir,file),"utf8"))){const match=statement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i);if(match?.[1])tables.add(match[1].toLowerCase())}}catch{}return[...tables]}export function migrationTouchesRootedTable(dir,file,rootedTables){try{return tablesOperatedOn(readFileSync(join(dir,file),"utf8")).some((table)=>rootedTables.has(table.toLowerCase()))}catch{return!1}}export function allocateMigrationOrdinals(count,startAt,reserved){const ordinals=[];let cursor=startAt;while(ordinals.length<count){if(!reserved.has(cursor))ordinals.push(cursor);cursor+=1}return ordinals}export function migrationsOutsideCorpus(dir,files,corpusTables){const rebuilt=new Set(corpusTables.map((table)=>table.toLowerCase()));return files.filter((file)=>{let contents;try{contents=readFileSync(join(dir,file),"utf8")}catch{return!0}const touched=tablesOperatedOn(contents);if(touched.length===0)return!0;return touched.some((table)=>!rebuilt.has(table))})}function migrationOrdinal(file){const match=file.match(/^(\d+)/);return match?Number(match[1]):0}export async function regenerateMigrationCorpus(options={}){try{const dialect=options.dialect??getQbDialect();let requestedVitessSharded;if(dialect==="vitess")try{const{config,overridesReady}=await import("@stacksjs/config");await overridesReady;requestedVitessSharded=isVitessSharded(config?.database?.connections?.vitess?.sharded)}catch{requestedVitessSharded=isVitessSharded(dbConfig.connections.vitess.sharded)}configureQueryBuilder(dialect,requestedVitessSharded);const dir=options.dir??migrationDirectory(dialect),sources=resolveModelSources({forceStage:!0,...options.onlyExistingTables?{includeFrameworkDefaults:!0}:{}});if(!sources)return err(Error("No models found. Define models in app/Models, or ensure the framework defaults at storage/framework/defaults/app/Models are present."));try{writeFileSync(join(sources.dir,`.qb-migrations.${dialect}.json`),JSON.stringify({plan:{dialect,tables:[]}}))}catch{}const snapshotPath=join(resolveSnapshotDir(),`model-snapshot.${dialect}.json`),parkedSnapshot=`${snapshotPath}.regenerating`;let snapshotParked=!1;if(existsSync(snapshotPath))try{renameSync(snapshotPath,parkedSnapshot);snapshotParked=!0}catch{}let result;try{result=await qbGenerateMigration(sources.dir,{dialect,vitessSharded:requestedVitessSharded,dryRun:!0,full:!0})}finally{if(snapshotParked)try{renameSync(parkedSnapshot,snapshotPath)}catch{}}const statements=result.sqlStatements??[];if(statements.length===0)return err(Error(`The generator produced no SQL for dialect "${dialect}".`));const dangling=findDanglingTypeReferences(statements);if(dangling.length>0)return err(Error(`The generator emitted ${dangling.length} reference(s) to enum type(s) it never creates: ${dangling.slice(0,5).join(", ")}${dangling.length>5?`, +${dangling.length-5} more`:""}. Writing this corpus would fail partway through a migration. This is a bug in the migration generator, not in your models.`));let groups=groupGeneratedStatements(statements),existing=[];try{existing=readdirSync(dir).filter((f)=>f.endsWith(".sql")).sort()}catch{}let unrebuildable=[];if(options.onlyExistingTables){const corpusTables=new Set(tablesDefinedByCorpus(dir,existing));if(corpusTables.size===0)return err(Error(`No CREATE TABLE statements found in ${dir}, so there is nothing to regenerate in place. Run \`buddy migrate:regenerate <dialect>\` without --only-existing-tables to write a corpus from your models.`));const emitted=new Set(createdTablesOf(statements).map((table)=>table.toLowerCase()));unrebuildable=[...corpusTables].filter((table)=>!emitted.has(table)).sort();groups=groups.map((group)=>({...group,statements:group.statements.filter((statement)=>{const table=statementTable(statement);return table?corpusTables.has(table.toLowerCase()):!1})})).filter((group)=>group.statements.length>0);if(groups.length===0)return err(Error(`None of the ${corpusTables.size} table(s) in ${dir} have a model behind them, so none can be regenerated. Declare the models, or publish the framework ones with \`buddy publish model <Name>\`.`))}const rootedTables=new Set(options.replaceUnmarked||options.onlyExistingTables?[]:historicallyRootedTables(dir,existing)),outOfScope=new Set(migrationsOutsideCorpus(dir,existing,createdTablesOf(statements))),removed=(options.replaceUnmarked||options.onlyExistingTables?existing:existing.filter((file)=>isGeneratedMigration(dir,file))).filter((file)=>!isPackageMigration(file)).filter((file)=>{return!outOfScope.has(file)&&!migrationTouchesRootedTable(dir,file,rootedTables)}),preserved=existing.filter((file)=>!removed.includes(file)),preservedOutOfScope=preserved.filter((file)=>outOfScope.has(file)),catchUp=[];for(const table of rootedTables){const create=groups.flatMap((group)=>group.statements).find((statement)=>statementTable(statement)===table&&/^\s*CREATE\s+TABLE\b/i.test(statement));if(!create)continue;const produced=columnsProducedByMigrations(dir,preserved,table);if(produced.size===0)continue;catchUp.push(...rootedTableCatchUpStatements(create,produced))}const writableGroups=groups.map((group)=>({...group,statements:group.statements.filter((statement)=>{const table=statementTable(statement);return!table||!rootedTables.has(table)})})).filter((group)=>group.statements.length>0).concat(catchUp.length>0?[{label:"alter-rooted-tables-columns",statements:catchUp}]:[]),historicalBoundary=existing.filter((file)=>!isGeneratedMigration(dir,file)&&!isPackageMigration(file)).reduce((max,file)=>Math.max(max,migrationOrdinal(file)),0),startAt=rootedTables.size>0?historicalBoundary+1:preserved.filter((file)=>!isPackageMigration(file)).reduce((max,file)=>Math.max(max,migrationOrdinal(file)),0)+1,reservedOrdinals=new Set(preserved.map(migrationOrdinal)),ordinals=allocateMigrationOrdinals(writableGroups.length,startAt,reservedOrdinals),files=writableGroups.map((group,index)=>({name:`${String(ordinals[index]).padStart(10,"0")}-${group.label}.sql`,statements:group.statements.length}));if(options.dryRun)return ok({dialect,models:sources.models.length,modelRoots:sources.roots,files,removed,preserved,preservedOutOfScope,unrebuildable,dir});mkdirSync(dir,{recursive:!0});for(const file of removed)unlinkSync(join(dir,file));writableGroups.forEach((group,index)=>{const body=`${group.statements.map((s)=>s.trim().replace(/;\s*$/,"")).join(`;
31
30
  `)};
32
31
  `;writeFileSync(join(dir,files[index].name),`${GENERATED_MIGRATION_MARKER}
33
32
  ${body}`)});saveMigrationSnapshot(result.plan,{dialect});return ok({dialect,models:sources.models.length,modelRoots:sources.roots,files,removed,preserved,preservedOutOfScope,unrebuildable,dir})}catch(error){return err(handleError("Migration regeneration failed",error))}}export function createdTableName(statement){return/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`[]?([\w$]+)["'`\]]?/i.exec(statement)?.[1]?.toLowerCase()}export function indexCommittedMigrations(fileContents){const createdTables=new Set;for(const contents of fileContents)for(const statement of contents.split(";")){const table=createdTableName(statement);if(table)createdTables.add(table)}return{sql:normalizeSqlForComparison(fileContents.join(`
34
- `)),createdTables}}function normalizeSqlForComparison(sql){return sql.replace(/\s+/g," ").trim()}export function generatedStatementIsRedundant(statement,index){const table=createdTableName(statement);if(table)return index.createdTables.has(table);return index.sql.includes(normalizeSqlForComparison(statement))}function persistGeneratedMigrations(sqlStatements){if(!sqlStatements?.length)return 0;const migrationsDir=migrationDirectory();try{require("node:fs").mkdirSync(migrationsDir,{recursive:!0})}catch{}const committed=[];try{for(const f of readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql")))committed.push(readFileSync(join(migrationsDir,f),"utf8"))}catch{}const index=indexCommittedMigrations(committed),groups=groupGeneratedStatements(sqlStatements);let written=0,cursor=nextMigrationNumber(migrationsDir);for(const group of groups){const fresh=group.statements.filter((stmt)=>{if(!generatedStatementIsRedundant(stmt,index))return!0;const table=createdTableName(stmt);if(table)log.debug(`[migration] Skipping generated CREATE for "${table}" - the committed corpus already creates it.`);return!1});if(fresh.length===0)continue;const filename=`${String(cursor).padStart(10,"0")}-${group.label}.sql`,filePath=join(migrationsDir,filename),body=`${fresh.map((s)=>s.trim().replace(/;\s*$/,"")).join(`;
33
+ `)),createdTables}}function normalizeSqlForComparison(sql){return sql.replace(/\s+/g," ").trim()}export function generatedStatementIsRedundant(statement,index){const table=createdTableName(statement);if(table)return index.createdTables.has(table);return index.sql.includes(normalizeSqlForComparison(statement))}function persistGeneratedMigrations(sqlStatements,previousPlan){if(!sqlStatements?.length)return 0;const migrationsDir=migrationDirectory();try{require("node:fs").mkdirSync(migrationsDir,{recursive:!0})}catch{}const committed=[];try{for(const f of readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql")))committed.push(readFileSync(join(migrationsDir,f),"utf8"))}catch{}const index=indexCommittedMigrations(committed),groups=groupGeneratedStatements(sqlStatements,previousPlan);let written=0,cursor=nextMigrationNumber(migrationsDir);for(const group of groups){const fresh=group.statements.filter((stmt)=>{if(!generatedStatementIsRedundant(stmt,index))return!0;const table=createdTableName(stmt);if(table)log.debug(`[migration] Skipping generated CREATE for "${table}" - the committed corpus already creates it.`);return!1});if(fresh.length===0)continue;const filename=`${String(cursor).padStart(10,"0")}-${group.label}.sql`,filePath=join(migrationsDir,filename),body=`${fresh.map((s)=>s.trim().replace(/;\s*$/,"")).join(`;
35
34
  `)};
36
35
  `;writeFileSync(filePath,`${GENERATED_MIGRATION_MARKER}
37
36
  ${body}`);log.debug(`[migration] Wrote ${filename} (${fresh.length} stmt${fresh.length===1?"":"s"})`);written+=1;cursor+=1}return written}function normalizeCreateStatements(sqlStatements){const creates=[],constraints=[],passthrough=[];for(const raw of sqlStatements){const statement=raw.trim();if(!statement)continue;const create=statement.match(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/i);if(create?.[1]){creates.push({statement,table:create[1]});continue}const constraint=statement.match(/^ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+CONSTRAINT\s+([\s\S]+?);?$/i);if(constraint?.[1]&&constraint[2]){const references=[...constraint[2].matchAll(/REFERENCES\s+["`]?(\w+)["`]?/gi)].flatMap((match)=>match[1]?[match[1]]:[]);constraints.push({body:`CONSTRAINT ${constraint[2].replace(/;\s*$/,"")}`,references,statement,table:constraint[1]});continue}passthrough.push(statement)}if(creates.length===0)return sqlStatements.map((statement)=>statement.trim()).filter(Boolean);const createdTables=new Set(creates.map((create)=>create.table)),createOrder=new Map(creates.map((create,index)=>[create.table,index])),relevantConstraints=constraints.filter((constraint)=>createdTables.has(constraint.table)),unrelatedConstraints=constraints.filter((constraint)=>!createdTables.has(constraint.table)),dependencies=new Map(creates.map((create)=>[create.table,new Set(relevantConstraints.filter((constraint)=>constraint.table===create.table).flatMap((constraint)=>constraint.references).filter((reference)=>reference!==create.table&&createdTables.has(reference)))])),sortTables=(ignoredEdges=new Set)=>{const remaining=new Set(createdTables),sorted=[];while(remaining.size>0){const ready=[...remaining].filter((table)=>[...dependencies.get(table)??[]].every((dependency)=>{return!remaining.has(dependency)||ignoredEdges.has(`${table}->${dependency}`)})).sort((a,b)=>(createOrder.get(a)??0)-(createOrder.get(b)??0));if(ready.length===0)break;for(const table of ready){remaining.delete(table);sorted.push(table)}}return sorted},initiallySorted=sortTables(),cyclicTables=new Set([...createdTables].filter((table)=>!initiallySorted.includes(table))),deferred=relevantConstraints.filter((constraint)=>constraint.references.some((reference)=>{return reference!==constraint.table&&cyclicTables.has(constraint.table)&&cyclicTables.has(reference)})),deferredStatements=new Set(deferred.map((constraint)=>constraint.statement)),ignoredEdges=new Set(deferred.flatMap((constraint)=>constraint.references.map((reference)=>`${constraint.table}->${reference}`))),orderedTables=sortTables(ignoredEdges),byTable=new Map(creates.map((create)=>[create.table,create]));return[...orderedTables.map((table)=>{const create=byTable.get(table),inline=relevantConstraints.filter((constraint)=>constraint.table===table&&!deferredStatements.has(constraint.statement));if(inline.length===0)return create.statement;const closing=create.statement.lastIndexOf(")");if(closing<0)return create.statement;const before=create.statement.slice(0,closing).trimEnd(),after=create.statement.slice(closing);return`${before},
38
37
  ${inline.map((constraint)=>constraint.body).join(`,
39
38
  `)}
40
- ${after}`}),...passthrough,...unrelatedConstraints.map((constraint)=>constraint.statement),...deferred.map((constraint)=>constraint.statement)]}export function groupGeneratedStatements(sqlStatements){const normalizedStatements=normalizeCreateStatements(sqlStatements),groups=new Map,push=(label,stmt)=>{const list=groups.get(label)??[];list.push(stmt);groups.set(label,list)},createdTables=new Set(normalizedStatements.flatMap((raw)=>{const match=raw.trim().match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/i);return match?.[1]?[match[1]]:[]}));for(const raw of normalizedStatements){const stmt=raw.trim();if(!stmt)continue;const create=stmt.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/i);if(create){push(`create-${create[1]}-table`,stmt);continue}if(stmt.match(/^\s*CREATE\s+TYPE\s+/i)){push("create-database-types",stmt);continue}const alter=stmt.match(/^\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+(?:ADD\s+COLUMN\s+["`]?(\w+)["`]?|DROP\s+COLUMN\s+["`]?(\w+)["`]?|ADD\s+CONSTRAINT)/i),alterTable=alter?.[1];if(alter&&alterTable){const isCreateTimeConstraint=createdTables.has(alterTable)&&!alter[2]&&!alter[3];push(isCreateTimeConstraint?"create-foreign-key-constraints":`alter-${alterTable}-columns`,stmt);continue}const idx=stmt.match(/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?\s+ON\s+["`]?(\w+)["`]?/i),idxName=idx?.[1],idxTable=idx?.[2];if(idxName&&idxTable){push(createdTables.has(idxTable)?`create-${idxTable}-table`:`create-${idxName}-index-in-${idxTable}`,stmt);continue}const drop=stmt.match(/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["`]?(\w+)["`]?/i);if(drop){push(`drop-${drop[1]}-table`,stmt);continue}push("auto-misc",stmt)}return[...groups.entries()].map(([label,statements])=>({label,statements})).sort((a,b)=>Number(b.label==="create-database-types")-Number(a.label==="create-database-types"))}function nextMigrationNumber(migrationsDir){let max=0;try{for(const f of readdirSync(migrationsDir)){if(isPackageMigration(f))continue;const m=f.match(/^(\d+)-/);if(m?.[1])max=Math.max(max,Number.parseInt(m[1],10))}}catch{}return max+1}export async function generateMigrations2(){try{log.info("Generating fresh migrations...");configureQueryBuilder();const dialect=getDialect(),{modelsDir,skip}=prepareMigrationModelsDir();if(skip){log.info("No app/Models directory found; using committed framework migrations");return ok("Migrations generated")}const qbDialect=getQbDialect();await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,full:!0,dryRun:!0});log.success("Migrations generated");return ok("Migrations generated")}catch(error){return err(handleError("Fresh migration generation failed",error))}}
39
+ ${after}`}),...passthrough,...unrelatedConstraints.map((constraint)=>constraint.statement),...deferred.map((constraint)=>constraint.statement)]}export function groupGeneratedStatements(sqlStatements,previousPlan){const normalizedStatements=normalizeCreateStatements(sqlStatements),groups=new Map,push=(label,stmt)=>{const list=groups.get(label)??[];list.push(stmt);groups.set(label,list)},createdTables=new Set(normalizedStatements.flatMap((raw)=>{const match=raw.trim().match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/i);return match?.[1]?[match[1]]:[]}));for(const raw of normalizedStatements){const stmt=raw.trim();if(!stmt)continue;const create=stmt.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/i);if(create){push(`create-${create[1]}-table`,stmt);continue}if(stmt.match(/^\s*CREATE\s+TYPE\s+/i)){push("create-database-types",stmt);continue}const alter=stmt.match(/^\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+(?:ADD\s+COLUMN\s+["`]?(\w+)["`]?|DROP\s+COLUMN\s+["`]?(\w+)["`]?|ADD\s+CONSTRAINT)/i),alterTable=alter?.[1];if(alter&&alterTable){const isCreateTimeConstraint=createdTables.has(alterTable)&&!alter[2]&&!alter[3];push(isCreateTimeConstraint?"create-foreign-key-constraints":`alter-${alterTable}-columns`,stmt);continue}const idx=stmt.match(/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?\s+ON\s+["`]?(\w+)["`]?/i),idxName=idx?.[1],idxTable=idx?.[2];if(idxName&&idxTable){push(createdTables.has(idxTable)?`create-${idxTable}-table`:`create-${idxName}-index-in-${idxTable}`,stmt);continue}const drop=stmt.match(/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["`]?(\w+)["`]?/i);if(drop){push(`drop-${drop[1]}-table`,stmt);continue}push("auto-misc",stmt)}const ordered=[...groups.entries()].map(([label,statements])=>({label,statements})).sort((a,b)=>Number(b.label==="create-database-types")-Number(a.label==="create-database-types"));return withTableDropsLast(ordered,previousPlan)}const DROP_TABLE_GROUP_RE=/^drop-(\w+)-table$/;export function withTableDropsLast(groups,previousPlan){const dropOf=(group)=>group.label.match(DROP_TABLE_GROUP_RE)?.[1],recreated=new Set(groups.flatMap((group)=>group.statements.flatMap((statement)=>{const table=createdTableName(statement);return table?[table]:[]}))),movable=groups.filter((group)=>{const table=dropOf(group);return table!==void 0&&!recreated.has(table.toLowerCase())});if(movable.length===0)return groups;const rest=groups.filter((group)=>!movable.includes(group)),drops=[...movable].reverse(),references=new Map;for(const table of previousPlan?.tables??[])references.set(table.table,new Set(table.columns.flatMap((column)=>column.references?.table&&column.references.table!==table.table?[column.references.table]:[])));const sorted=[],visiting=new Set,visit=(group)=>{if(sorted.includes(group)||visiting.has(group))return;visiting.add(group);for(const other of drops)if(other!==group&&references.get(dropOf(other))?.has(dropOf(group)))visit(other);visiting.delete(group);sorted.push(group)};for(const group of drops)visit(group);return[...rest,...sorted]}function nextMigrationNumber(migrationsDir){let max=0;try{for(const f of readdirSync(migrationsDir)){if(isPackageMigration(f))continue;const m=f.match(/^(\d+)-/);if(m?.[1])max=Math.max(max,Number.parseInt(m[1],10))}}catch{}return max+1}export async function generateMigrations2(){try{log.info("Generating fresh migrations...");configureQueryBuilder();const dialect=getDialect(),{modelsDir,skip}=prepareMigrationModelsDir();if(skip){log.info("No app/Models directory found; using committed framework migrations");return ok("Migrations generated")}const qbDialect=getQbDialect();await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,full:!0,dryRun:!0});log.success("Migrations generated");return ok("Migrations generated")}catch(error){return err(handleError("Fresh migration generation failed",error))}}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Split a migration file into its statements.
3
+ *
4
+ * Comments come out FIRST, before the split on `;`. Splitting first and then
5
+ * dropping the chunks that begin with `--` looks equivalent and is not: a
6
+ * file that opens with a comment header — which every hand-written migration
7
+ * in this repo does — glues that header to its first statement, so the chunk
8
+ * begins with `--` and the statement disappears with the comment. Everything
9
+ * downstream then reasons about a file it has only partly read, and the
10
+ * ADD COLUMN reconciliation below would happily record a file as fully
11
+ * applied while its first column had never been created.
12
+ */
13
+ export declare function sqlStatementsOf(content: string): string[];
@@ -0,0 +1,2 @@
1
+ export function sqlStatementsOf(content){const statements=[];let current="",quote=null,dollarTag=null;for(let i=0;i<content.length;i++){const char=content[i];if(dollarTag){current+=char;if(char==="$"&&content.startsWith(dollarTag,i)){current+=content.slice(i+1,i+dollarTag.length);i+=dollarTag.length-1;dollarTag=null}continue}if(quote){current+=char;if(quote==="single"&&char==="'"||quote==="double"&&char==='"')quote=null;continue}if(char==="-"&&content[i+1]==="-"){const newline=content.indexOf(`
2
+ `,i);if(newline===-1)break;i=newline-1;continue}const dollar=char==="$"?/^\$[A-Za-z_]*\$/.exec(content.slice(i)):null;if(dollar){dollarTag=dollar[0];current+=dollarTag;i+=dollarTag.length-1;continue}if(char==="'"){quote="single";current+=char;continue}if(char==='"'){quote="double";current+=char;continue}if(char===";"){const trimmed=current.trim();if(trimmed.length>0)statements.push(trimmed);current="";continue}current+=char}const trailing=current.trim();if(trailing.length>0)statements.push(trailing);return statements}
@@ -60,4 +60,4 @@ import{log}from"@stacksjs/logging";import{env as envVars}from"@stacksjs/env";imp
60
60
  upvoteable_id INTEGER NOT NULL,
61
61
  upvoteable_type VARCHAR(255) NOT NULL,
62
62
  ${createdAt(sql)}
63
- )`}export function traitTableColumnGuarantees(sql){return[{table:"taggables",column:"taggable_id",definition:`INTEGER NOT NULL DEFAULT ${UNSCOPED_OWNER_ID}`},{table:"categorizables",column:"categorizable_id",definition:`INTEGER NOT NULL DEFAULT ${UNSCOPED_OWNER_ID}`},{table:"commentables",column:"author_name",definition:"VARCHAR(255)"},{table:"commentables",column:"author_email",definition:"VARCHAR(255)"}]}export function traitTableIndexSql(){return["CREATE INDEX IF NOT EXISTS commentables_owner_index ON commentables (commentables_type, commentables_id)","CREATE INDEX IF NOT EXISTS commentables_status_index ON commentables (status)","CREATE UNIQUE INDEX IF NOT EXISTS taggables_owner_slug_unique ON taggables (taggable_type, taggable_id, slug)","CREATE UNIQUE INDEX IF NOT EXISTS categorizables_owner_slug_unique ON categorizables (categorizable_type, categorizable_id, slug)","CREATE INDEX IF NOT EXISTS commentable_upvotes_target_index ON commentable_upvotes (upvoteable_type, upvoteable_id)","CREATE UNIQUE INDEX IF NOT EXISTS commentable_upvotes_user_unique ON commentable_upvotes (upvoteable_type, upvoteable_id, user_id)","CREATE UNIQUE INDEX IF NOT EXISTS taggable_models_unique ON taggable_models (tag_id, taggable_id, taggable_type)","CREATE UNIQUE INDEX IF NOT EXISTS categorizable_models_unique ON categorizable_models (category_id, categorizable_id, categorizable_type)"]}export async function likeableTargets(){const{path}=await import("@stacksjs/path"),{globSync}=await import("@stacksjs/storage"),{getTableName}=await import("@stacksjs/model-meta"),{getLikeableForeignKey,getUpvoteTableName}=await import("./drivers/helpers"),modelFiles=globSync([path.userModelsPath("*.ts"),path.storagePath("framework/defaults/app/Models/**/*.ts")],{absolute:!0}),targets=new Map;for(const modelFile of modelFiles){let model;try{model=(await import(modelFile)).default}catch{continue}if(!model?.traits?.likeable)continue;const tableName=await getTableName(model,modelFile);if(!tableName)continue;const table=getUpvoteTableName(model,tableName);if(!table||!/^[a-z_]\w*$/i.test(table))continue;const foreignKey=getLikeableForeignKey(model,tableName);if(!/^[a-z_]\w*$/i.test(foreignKey))continue;targets.set(table,{table,foreignKey})}return[...targets.values()]}export async function migrateTraitTables(options={}){const dbDriver=getDbDriver(),sql=sqlHelpers(dbDriver);if(options.verbose)log.info(`Creating polymorphic trait tables for ${dbDriver}...`);try{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}}}
63
+ )`}export function traitTableColumnGuarantees(sql){return[{table:"taggables",column:"taggable_id",definition:`INTEGER NOT NULL DEFAULT ${UNSCOPED_OWNER_ID}`},{table:"categorizables",column:"categorizable_id",definition:`INTEGER NOT NULL DEFAULT ${UNSCOPED_OWNER_ID}`},{table:"commentables",column:"author_name",definition:"VARCHAR(255)"},{table:"commentables",column:"author_email",definition:"VARCHAR(255)"}]}export function traitTableIndexSql(){return["CREATE INDEX IF NOT EXISTS commentables_owner_index ON commentables (commentables_type, commentables_id)","CREATE INDEX IF NOT EXISTS commentables_status_index ON commentables (status)","CREATE UNIQUE INDEX IF NOT EXISTS taggables_owner_slug_unique ON taggables (taggable_type, taggable_id, slug)","CREATE UNIQUE INDEX IF NOT EXISTS categorizables_owner_slug_unique ON categorizables (categorizable_type, categorizable_id, slug)","CREATE INDEX IF NOT EXISTS commentable_upvotes_target_index ON commentable_upvotes (upvoteable_type, upvoteable_id)","CREATE UNIQUE INDEX IF NOT EXISTS commentable_upvotes_user_unique ON commentable_upvotes (upvoteable_type, upvoteable_id, user_id)","CREATE UNIQUE INDEX IF NOT EXISTS taggable_models_owner_unique ON taggable_models (tag_id, taggable_id, taggable_type)","CREATE UNIQUE INDEX IF NOT EXISTS categorizable_models_owner_unique ON categorizable_models (category_id, categorizable_id, categorizable_type)"]}export async function likeableTargets(){const{path}=await import("@stacksjs/path"),{globSync}=await import("@stacksjs/storage"),{getTableName}=await import("@stacksjs/model-meta"),{getLikeableForeignKey,getUpvoteTableName}=await import("./drivers/helpers"),modelFiles=globSync([path.userModelsPath("*.ts"),path.storagePath("framework/defaults/app/Models/**/*.ts")],{absolute:!0}),targets=new Map;for(const modelFile of modelFiles){let model;try{model=(await import(modelFile)).default}catch{continue}if(!model?.traits?.likeable)continue;const tableName=await getTableName(model,modelFile);if(!tableName)continue;const table=getUpvoteTableName(model,tableName);if(!table||!/^[a-z_]\w*$/i.test(table))continue;const foreignKey=getLikeableForeignKey(model,tableName);if(!/^[a-z_]\w*$/i.test(foreignKey))continue;targets.set(table,{table,foreignKey})}return[...targets.values()]}export async function migrateTraitTables(options={}){const dbDriver=getDbDriver(),sql=sqlHelpers(dbDriver);if(options.verbose)log.info(`Creating polymorphic trait tables for ${dbDriver}...`);try{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.12",
5
+ "version": "0.75.14",
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.12",
70
- "@stacksjs/env": "0.75.12",
71
- "@stacksjs/error-handling": "0.75.12",
72
- "@stacksjs/faker": "^0.75.12",
73
- "@stacksjs/features": "0.75.12",
74
- "@stacksjs/logging": "0.75.12",
75
- "@stacksjs/model-meta": "0.75.12",
76
- "@stacksjs/path": "0.75.12",
77
- "@stacksjs/query-builder": "^0.75.12",
78
- "@stacksjs/security": "0.75.12",
79
- "@stacksjs/storage": "0.75.12",
80
- "@stacksjs/strings": "0.75.12",
69
+ "@stacksjs/config": "0.75.14",
70
+ "@stacksjs/env": "0.75.14",
71
+ "@stacksjs/error-handling": "0.75.14",
72
+ "@stacksjs/faker": "^0.75.14",
73
+ "@stacksjs/features": "0.75.14",
74
+ "@stacksjs/logging": "0.75.14",
75
+ "@stacksjs/model-meta": "0.75.14",
76
+ "@stacksjs/path": "0.75.14",
77
+ "@stacksjs/query-builder": "^0.75.14",
78
+ "@stacksjs/security": "0.75.14",
79
+ "@stacksjs/storage": "0.75.14",
80
+ "@stacksjs/strings": "0.75.14",
81
81
  "@stacksjs/ts-validation": "^0.5.6",
82
82
  "bun-query-builder": "^0.3.0",
83
83
  "dynamodb-tooling": "^0.3.2"
84
84
  },
85
85
  "devDependencies": {
86
- "@stacksjs/cli": "0.75.12",
87
- "@stacksjs/router": "0.75.12",
88
- "@stacksjs/utils": "0.75.12",
86
+ "@stacksjs/cli": "0.75.14",
87
+ "@stacksjs/router": "0.75.14",
88
+ "@stacksjs/utils": "0.75.14",
89
89
  "better-dx": "^0.2.24"
90
90
  }
91
91
  }