@stacksjs/database 0.75.159 → 0.75.160

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- import{existsSync,readdirSync,readFileSync}from"node:fs";import{join}from"node:path";import{resolvedDatabaseDriver}from"./dialect";import{comparableIndexKey,postgresIndexStatement}from"./index-shape";import{resolveMigrationDirectory}from"./migration-path";import{sqlStatementsOf}from"./sql-statements";const SAFE_MIGRATION_FILE=/^[\w.-]+\.sql$/,IDENT=String.raw`["\`\[]?([A-Za-z_]\w*)["\`\]]?`;export function stripForEffects(sql){let out="",i=0;const blank=(text)=>text.replace(/[^\n]/g," ");while(i<sql.length){const rest=sql.slice(i),line=rest.match(/^--[^\n]*/);if(line){out+=blank(line[0]);i+=line[0].length;continue}if(rest.startsWith("/*")){const end=rest.indexOf("*/"),chunk=end===-1?rest:rest.slice(0,end+2);out+=blank(chunk);i+=chunk.length;continue}if(rest[0]==="'"){let j=1;while(j<rest.length&&rest[j]!=="'")j++;const chunk=rest.slice(0,Math.min(j+1,rest.length));out+=blank(chunk);i+=chunk.length;continue}out+=sql[i];i+=1}return out}function statementsOf(sql){return stripForEffects(sql).split(";").map((s)=>s.trim()).filter((s)=>s.length>0)}export function logicalName(file){return file.replace(/^\d+[-_]/,"").replace(/\.sql$/i,"")}export function migrationRemovals(sql){const removals=[],seen=new Set,push=(effect)=>{const key=effectKey(effect);if(seen.has(key))return;seen.add(key);removals.push(effect)};for(const statement of statementsOf(sql)){const index=new RegExp(String.raw`^DROP\s+INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+EXISTS\s+)?${IDENT}`,"i").exec(statement);if(index?.[1]){push({kind:"index",name:index[1]});continue}const table=new RegExp(String.raw`^DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?${IDENT}`,"i").exec(statement);if(table?.[1]){push({kind:"table",name:table[1]});continue}const enumType=new RegExp(String.raw`^DROP\s+TYPE\s+(?:IF\s+EXISTS\s+)?${IDENT}`,"i").exec(statement);if(enumType?.[1]){push({kind:"enum",name:enumType[1]});continue}const alter=new RegExp(String.raw`^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?${IDENT}\s+(.*)$`,"is").exec(statement);if(!alter?.[1])continue;const owner=alter[1];for(const clause of(alter[2]??"").split(",")){const constraint=new RegExp(String.raw`^\s*DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?${IDENT}`,"i").exec(clause);if(constraint?.[1]){push({kind:"constraint",table:owner,name:constraint[1]});continue}const column=new RegExp(String.raw`^\s*DROP\s+(?:COLUMN\s+)?(?:IF\s+EXISTS\s+)?${IDENT}`,"i").exec(clause);if(column?.[1]&&!/^(?:constraint|index|key|primary|foreign|unique|check|default|partition)$/i.test(column[1]))push({kind:"column",table:owner,name:column[1]})}}return removals}export function migrationEffects(sql){const effects=[],seen=new Set,renamedAway=new Set,push=(effect)=>{const key=effectKey(effect);if(seen.has(key))return;seen.add(key);effects.push(effect)},shapes=new Map;for(const statement of sqlStatementsOf(sql)){const match=/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?["`[]?([\w$]+)/i.exec(statement),key=match?.[1]?comparableIndexKey(statement):void 0;if(match?.[1]&&key)shapes.set(match[1].toLowerCase(),key)}for(const statement of statementsOf(sql)){const create=new RegExp(String.raw`^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"i").exec(statement);if(create?.[1]){push({kind:"table",name:create[1]});continue}const rename=new RegExp(String.raw`^ALTER\s+TABLE\s+${IDENT}\s+RENAME\s+TO\s+${IDENT}`,"i").exec(statement);if(rename?.[2]){if(rename[1])renamedAway.add(rename[1].toLowerCase());push({kind:"table",name:rename[2]});continue}const index=new RegExp(String.raw`^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}(?:\s+ON\s+${IDENT})?`,"i").exec(statement);if(index?.[1]){const shape=shapes.get(index[1].toLowerCase());push({kind:"index",name:index[1],...index[2]?{table:index[2]}:{},...shape?{shape}:{}});continue}const enumType=new RegExp(String.raw`^CREATE\s+TYPE\s+${IDENT}\s+AS\s+ENUM`,"i").exec(statement);if(enumType?.[1]){push({kind:"enum",name:enumType[1]});continue}const alter=new RegExp(String.raw`^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?${IDENT}\s+(.*)$`,"is").exec(statement);if(!alter?.[1]||!alter[2])continue;const table=alter[1],addColumn=new RegExp(String.raw`\bADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"gi");for(const m of alter[2].matchAll(addColumn))if(m[1])push({kind:"column",table,name:m[1]});const addConstraint=new RegExp(String.raw`\bADD\s+CONSTRAINT\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"gi");for(const m of alter[2].matchAll(addConstraint))if(m[1])push({kind:"constraint",table,name:m[1]});const addBare=new RegExp(String.raw`\bADD\s+(?!COLUMN\b|CONSTRAINT\b|INDEX\b|KEY\b|PRIMARY\b|UNIQUE\b|FOREIGN\b|FULLTEXT\b|SPATIAL\b|CHECK\b)${IDENT}\s+\w`,"gi");for(const m of alter[2].matchAll(addBare))if(m[1])push({kind:"column",table,name:m[1]})}if(renamedAway.size===0)return effects;return effects.filter((effect)=>{const owner=(effect.kind==="table"?effect.name:effect.table??"").toLowerCase();return!renamedAway.has(owner)})}function effectKey(effect){return`${effect.kind}:${(effect.table??"").toLowerCase()}.${effect.name.toLowerCase()}`}function removalKey(effect){if(effect.kind==="column"||effect.kind==="constraint")return effectKey(effect);return`${effect.kind}:${effect.name.toLowerCase()}`}export function verifiableEffects(effects,dialect){if(dialect==="postgres")return effects;if(dialect==="mysql")return effects.filter((e)=>e.kind!=="enum");return effects.filter((e)=>e.kind!=="constraint"&&e.kind!=="enum")}function emptySchema(){return{tables:new Set,columns:new Map,indexes:new Set,indexShapes:new Set,constraints:new Set,enums:new Set}}function rowsOf(result){return Array.isArray(result)?result:[]}async function defaultRunner(){const{db}=await import("./utils");return async(sql)=>rowsOf(await db.unsafe(sql).execute())}function pick(row,...keys){for(const key of keys){const value=row?.[key]??row?.[key.toLowerCase()]??row?.[key.toUpperCase()];if(typeof value==="string"&&value.length>0)return value}return""}export async function readLiveSchema(dialect,runner){const schema=emptySchema(),run=runner??await defaultRunner();if(dialect==="sqlite"){for(const row of await run("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")){const name=pick(row,"name");if(name)schema.tables.add(name.toLowerCase())}for(const row of await run("SELECT name, sql FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'")){const name=pick(row,"name");if(name)schema.indexes.add(name.toLowerCase());const key=comparableIndexKey(pick(row,"sql"));if(key)schema.indexShapes.add(key)}for(const table of schema.tables){if(!/^[a-z_]\w*$/i.test(table))continue;const cols=new Set;for(const row of await run(`PRAGMA table_info("${table}")`)){const name=pick(row,"name");if(name)cols.add(name.toLowerCase())}schema.columns.set(table,cols)}return schema}if(dialect==="mysql"){for(const row of await run("SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()")){const name=pick(row,"name","TABLE_NAME");if(name)schema.tables.add(name.toLowerCase())}for(const row of await run("SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()")){const table=pick(row,"TABLE_NAME").toLowerCase(),column=pick(row,"COLUMN_NAME").toLowerCase();if(!table||!column)continue;if(!schema.columns.has(table))schema.columns.set(table,new Set);schema.columns.get(table).add(column)}for(const row of await run("SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE()")){const name=pick(row,"name","INDEX_NAME");if(name)schema.indexes.add(name.toLowerCase())}for(const row of await run("SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE()")){const name=pick(row,"name","CONSTRAINT_NAME");if(name)schema.constraints.add(name.toLowerCase())}return schema}for(const row of await run("SELECT tablename AS name FROM pg_tables WHERE schemaname = 'public'")){const name=pick(row,"name","tablename");if(name)schema.tables.add(name.toLowerCase())}for(const row of await run("SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public'")){const table=pick(row,"table_name").toLowerCase(),column=pick(row,"column_name").toLowerCase();if(!table||!column)continue;if(!schema.columns.has(table))schema.columns.set(table,new Set);schema.columns.get(table).add(column)}for(const row of await run("SELECT indexname AS name, indexdef AS definition FROM pg_indexes WHERE schemaname = 'public'")){const name=pick(row,"name","indexname");if(name)schema.indexes.add(name.toLowerCase());const key=comparableIndexKey(postgresIndexStatement(pick(row,"definition","indexdef")));if(key)schema.indexShapes.add(key)}for(const row of await run("SELECT c.conname AS name FROM pg_constraint c JOIN pg_namespace n ON n.oid = c.connamespace WHERE n.nspname = 'public'")){const name=pick(row,"name","conname");if(name)schema.constraints.add(name.toLowerCase())}for(const row of await run("SELECT t.typname AS name FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE t.typtype = 'e' AND n.nspname = 'public'")){const name=pick(row,"name","typname");if(name)schema.enums.add(name.toLowerCase())}return schema}export function effectPresent(effect,schema){const name=effect.name.toLowerCase();switch(effect.kind){case"table":return schema.tables.has(name);case"column":return schema.columns.get((effect.table??"").toLowerCase())?.has(name)??!1;case"index":{if(schema.indexes.has(name))return!0;if(effect.shape&&schema.indexShapes.has(effect.shape))return!0;const table=(effect.table??"").toLowerCase();if(!table)return!1;return schema.indexes.has(`${table}_${name}`)}case"constraint":return schema.constraints.has(name);case"enum":return schema.enums.has(name)}}export function classifyMigration(recorded,present,absent){const verifiable=present.length+absent.length;if(recorded){if(verifiable===0||absent.length===0)return"applied";return"reverted"}if(verifiable===0)return"unverifiable";if(absent.length===0)return"stranded";if(present.length===0)return"pending";return"partial"}function migrationsDir(dir){if(dir)return dir;const driver=resolvedDatabaseDriver().toLowerCase();return resolveMigrationDirectory(driver==="singlestore"?"singlestore":driver)}function listMigrationFiles(dir){if(!existsSync(dir))return[];try{return readdirSync(dir).filter((f)=>f.toLowerCase().endsWith(".sql")).sort()}catch{return[]}}function currentDialect(){const driver=resolvedDatabaseDriver().toLowerCase();if(driver==="sqlite"||driver==="mysql"||driver==="postgres")return driver;if(driver==="vitess"||driver==="singlestore")return"mysql";if(driver==="turso"||driver==="libsql")return"sqlite";return"other"}export async function readLedger(runner){try{return(await(runner??await defaultRunner())("SELECT migration FROM migrations")).map((row)=>pick(row,"migration")).filter((name)=>name.length>0).sort()}catch{return[]}}export function planLedgerRemap(ledger,diskFiles){const onDisk=new Set(diskFiles),byLogical=new Map;for(const file of diskFiles){const key=logicalName(file);if(!byLogical.has(key))byLogical.set(key,[]);byLogical.get(key).push(file)}const claimed=new Set(ledger.filter((row)=>onDisk.has(row))),remap=[],ambiguous=[],dropped=[],superseded=[],targets=new Map;for(const row of ledger){if(onDisk.has(row))continue;const sameLogical=byLogical.get(logicalName(row))??[],candidates=sameLogical.filter((f)=>!claimed.has(f));if(candidates.length===0){if(sameLogical.length>0)superseded.push(row);else dropped.push(row);continue}if(candidates.length>1){ambiguous.push(row);continue}const to=candidates[0];if(!targets.has(to))targets.set(to,[]);targets.get(to).push(row);remap.push({from:row,to})}const contested=new Set([...targets.entries()].filter(([,rows])=>rows.length>1).flatMap(([,rows])=>rows));if(contested.size===0)return{remap,ambiguous,dropped,superseded};return{remap:remap.filter((r)=>!contested.has(r.from)),ambiguous:[...ambiguous,...contested].sort(),dropped,superseded}}export async function auditMigrationLedger(options={}){const dir=migrationsDir(options.dir),dialect=options.dialect??currentDialect(),files=listMigrationFiles(dir),counts={applied:0,stranded:0,pending:0,partial:0,unverifiable:0,reverted:0},emptyPlan={remap:[],ambiguous:[],dropped:[],superseded:[]};if(dialect==="other")return{supported:!1,dialect,dir,entries:[],orphans:[],gated:[],counts,recordedCount:0,remapPlan:emptyPlan,drift:!1};const run=options.run??await defaultRunner(),ledger=await readLedger(run),recorded=new Set(ledger),schema=await readLiveSchema(dialect,run),gate=await resolveGate(options,dir),sources=new Map,gated=files.filter((file)=>gate?.exclude.has(file));for(const file of files){if(gate?.exclude.has(file))continue;try{const sql=readFileSync(join(dir,file),"utf8");sources.set(file,gate?gate.rewrite(sql):sql)}catch{continue}}const readFiles=[...sources.keys()],removedLater=new Map,tablesDroppedLater=new Map;for(let i=0;i<readFiles.length;i++){const laterKeys=new Set,laterTables=new Set;for(let j=i+1;j<readFiles.length;j++)for(const removal of migrationRemovals(sources.get(readFiles[j]))){laterKeys.add(removalKey(removal));if(removal.kind==="table")laterTables.add(removal.name.toLowerCase())}removedLater.set(readFiles[i],laterKeys);tablesDroppedLater.set(readFiles[i],laterTables)}const entries=[];for(const file of readFiles){const sql=sources.get(file),effects=verifiableEffects(migrationEffects(sql),dialect),dropped=removedLater.get(file)??new Set,droppedTables=tablesDroppedLater.get(file)??new Set,survives=(effect)=>effectPresent(effect,schema)||dropped.has(removalKey(effect))||effect.table!==void 0&&droppedTables.has(effect.table.toLowerCase()),present=effects.filter(survives),absent=effects.filter((effect)=>!survives(effect)),isRecorded=recorded.has(file),status=classifyMigration(isRecorded,present,absent);counts[status]+=1;entries.push({file,logical:logicalName(file),recorded:isRecorded,status,effects,present,absent})}const readable=[...entries.map((entry)=>entry.file),...gated].sort(),remapPlan=planLedgerRemap(ledger,readable),renamedTo=new Map(remapPlan.remap.map((r)=>[r.from,r.to])),orphans=ledger.filter((row)=>!readable.includes(row)).map((row)=>({migration:row,renamedTo:renamedTo.get(row)})),drift=counts.stranded>0||counts.partial>0||counts.reverted>0||orphans.length>0;return{supported:!0,dialect,dir,entries,orphans,gated,counts,recordedCount:ledger.length,remapPlan,drift}}async function ensureLedgerTable(dialect,run){const id=dialect==="postgres"?"id SERIAL PRIMARY KEY":dialect==="mysql"?"id INT AUTO_INCREMENT PRIMARY KEY":"id INTEGER PRIMARY KEY AUTOINCREMENT",timestamp=dialect==="postgres"?"TIMESTAMP":"DATETIME",utcNow=dialect==="postgres"?"(now() AT TIME ZONE 'utc')":dialect==="mysql"?"(UTC_TIMESTAMP)":"CURRENT_TIMESTAMP";await run(`CREATE TABLE IF NOT EXISTS migrations (${id}, migration VARCHAR(255) NOT NULL UNIQUE, executed_at ${timestamp} DEFAULT ${utcNow})`);if(dialect!=="sqlite")try{await run(`ALTER TABLE migrations ALTER COLUMN executed_at SET DEFAULT ${utcNow}`)}catch{}}const Q=(pattern)=>new RegExp(pattern.replaceAll("{id}",IDENT),"i"),CREATE_TABLE=Q(String.raw`^CREATE\s+(?:TEMP(?:ORARY)?\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?{id}`),DROP_TABLE=Q(String.raw`^DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?{id}`),RENAME_TABLE=Q(String.raw`^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?{id}\s+RENAME\s+TO\s+{id}`),ALTER_TABLE=Q(String.raw`^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?{id}`),CREATE_INDEX=Q(String.raw`^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?{id}\s+ON\s+(?:ONLY\s+)?{id}`),DROP_INDEX=Q(String.raw`^DROP\s+INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+EXISTS\s+)?{id}(?:\s+ON\s+{id})?`),DML=Q(String.raw`^(?:INSERT\s+(?:OR\s+\w+\s+)?INTO|REPLACE\s+INTO|UPDATE|DELETE\s+FROM)\s+{id}`),NEUTRAL=/^(?:BEGIN|COMMIT|END|PRAGMA|SET|SELECT\s+1|COMMENT\s+ON|CREATE\s+TYPE\s+\S+\s+AS\s+ENUM|ALTER\s+TYPE\s+\S+\s+ADD\s+VALUE|STACKS_ENUM_GUARD)\b/i;function statementShape(statement,indexTables){const lower=(name)=>(name??"").toLowerCase();if(NEUTRAL.test(statement))return{modifies:[]};let m=RENAME_TABLE.exec(statement);if(m)return{modifies:[lower(m[1]),lower(m[2])],rename:{from:lower(m[1]),to:lower(m[2])}};m=CREATE_TABLE.exec(statement);if(m)return{modifies:[lower(m[1])],creates:lower(m[1])};m=DROP_TABLE.exec(statement);if(m)return{modifies:[lower(m[1])],drops:lower(m[1])};m=ALTER_TABLE.exec(statement);if(m)return{modifies:[lower(m[1])]};m=CREATE_INDEX.exec(statement);if(m)return{modifies:[lower(m[2])],index:{name:lower(m[1]),table:lower(m[2])}};m=DROP_INDEX.exec(statement);if(m){const table=m[2]?lower(m[2]):indexTables.get(lower(m[1]));return table?{modifies:[table]}:{modifies:[],unknown:!0}}m=DML.exec(statement);if(m)return{modifies:[lower(m[1])]};return{modifies:[],unknown:!0}}function requeueStatements(sql){return stripForEffects(sql).replace(/\bDO\s+\$(\w*)\$([\s\S]*?)\$\1\$/gi,(block,_tag,body)=>/^\s*BEGIN\s+CREATE\s+TYPE\b[\s\S]*\bEXCEPTION\s+WHEN\s+duplicate_object\s+THEN\s+null\s*;\s*END\s*$/i.test(body)?"STACKS_ENUM_GUARD":block).split(";").map((statement)=>statement.trim()).filter(Boolean)}async function resolveGate(options,dir){if(options.gate||options.dir)return options.gate;try{const{featureGateForLedger}=await import("./migrations");return await featureGateForLedger(dir)}catch{return}}export function planRequeue(files,liveTables){const ordered=[...files].sort((a,b)=>a.file.localeCompare(b.file)),indexTables=new Map,expected=new Set,lineageOf=new Map,lineage=(table)=>{let group=lineageOf.get(table);if(!group){group=new Set([table]);lineageOf.set(table,group)}return group},link=(a,b)=>{const merged=new Set([...lineage(a),...lineage(b)]);for(const name of merged)lineageOf.set(name,merged)},shapes=ordered.map(({file,sql,recorded})=>{const statements=requeueStatements(sql).map((statement)=>statementShape(statement,indexTables));for(const shape of statements){if(shape.index)indexTables.set(shape.index.name,shape.index.table);if(shape.creates)expected.add(shape.creates);if(shape.drops)expected.delete(shape.drops);if(shape.rename){expected.delete(shape.rename.from);expected.add(shape.rename.to);link(shape.rename.from,shape.rename.to)}}const created=new Set(statements.flatMap((shape)=>shape.creates?[shape.creates]:[])),removed=new Set(statements.flatMap((shape)=>[shape.drops,shape.rename?.from].filter((t)=>Boolean(t)))),scaffold=new Set([...created].filter((table)=>removed.has(table)||statements.some((shape)=>shape.rename?.from===table)));return{file,recorded,unknown:statements.some((shape)=>shape.unknown),modifies:new Set(statements.flatMap((shape)=>shape.modifies)),scaffold}}),missing=[...expected].filter((table)=>!liveTables.has(table)).sort(),rebuildable=new Set(missing.flatMap((table)=>[...lineage(table)]).filter((table)=>!liveTables.has(table))),chains=new Map(missing.map((table)=>{const names=lineage(table);return[table,shapes.filter((shape)=>[...shape.modifies].some((name)=>names.has(name)))]})),why=new Map;for(const[table,chain]of chains)for(const shape of chain){if(shape.unknown){why.set(table,`${shape.file} contains a statement the planner cannot classify`);break}const elsewhere=[...shape.modifies].filter((t)=>!rebuildable.has(t)&&!shape.scaffold.has(t));if(elsewhere.length>0){why.set(table,`${shape.file} also changes ${elsewhere.join(", ")}, which ${elsewhere.length===1?"is":"are"} not missing`);break}}let changed=!0;while(changed){changed=!1;for(const[table,chain]of chains){if(why.has(table))continue;const blocker=chain.flatMap((shape)=>[...shape.modifies]).find((other)=>other!==table&&missing.some((t)=>why.has(t)&&lineage(t).has(other)));if(blocker){why.set(table,`shares a migration with ${blocker}, which cannot be rebuilt`);changed=!0}}}const requeue=new Set;for(const[table,chain]of chains){if(why.has(table))continue;for(const shape of chain)if(shape.recorded)requeue.add(shape.file)}return{missing,requeue:ordered.map(({file})=>file).filter((file)=>requeue.has(file)),refused:[...why].map(([table,reason])=>({table,reason}))}}export async function auditMissingTables(options={}){const dialect=options.dialect??currentDialect();if(dialect==="other")return{supported:!1,missing:[],live:new Set};const dir=migrationsDir(options.dir),run=options.run??await defaultRunner(),recorded=new Set(await readLedger(run)),gate=await resolveGate(options,dir),files=listMigrationFiles(dir).filter((file)=>recorded.has(file)&&!gate?.exclude.has(file)).flatMap((file)=>{try{const sql=readFileSync(join(dir,file),"utf8");return[{file,sql:gate?gate.rewrite(sql):sql,recorded:!0}]}catch{return[]}}),live=await readLiveSchema(dialect,run);return{supported:!0,missing:planRequeue(files,live.tables).missing,live:live.tables}}function byLogicalOnDisk(row,diskFiles){const key=logicalName(row),matches=diskFiles.filter((f)=>logicalName(f)===key);return matches.length===1?matches[0]:void 0}export async function reconcileMigrationLedger(options={}){const run=options.run??await defaultRunner(),gate=await resolveGate(options,migrationsDir(options.dir)),audit=await auditMigrationLedger({dir:options.dir,dialect:options.dialect,run,gate}),result={remapped:[],recorded:[],pruned:[],requeued:[],retired:[],skipped:[]};if(!audit.supported){result.skipped.push({file:"*",reason:`dialect "${audit.dialect}" is not audited`});return result}const written=new Set(options.regenerated?.written??[]),removedByRegeneration=new Set(options.regenerated?.removed??[]),onDisk=new Set([...audit.entries.map((entry)=>entry.file),...audit.gated]),plan={...audit.remapPlan,remap:audit.remapPlan.remap.filter((r)=>!written.has(r.to))},retirable=[...audit.remapPlan.remap.filter((r)=>written.has(r.to)).map((r)=>r.from),...audit.remapPlan.dropped,...audit.remapPlan.superseded,...audit.remapPlan.ambiguous].filter((row)=>removedByRegeneration.has(row)&&!onDisk.has(row)&&SAFE_MIGRATION_FILE.test(row)),retiring=new Set(retirable);for(const row of plan.ambiguous.filter((row)=>!retiring.has(row)))result.skipped.push({file:row,reason:"ledger row matches more than one file by logical name"});for(const row of plan.dropped.filter((row)=>!retiring.has(row)))result.skipped.push({file:row,reason:"recorded migration no longer exists on disk"});for(const{from}of audit.remapPlan.remap.filter((r)=>written.has(r.to)&&!retiring.has(r.from)))result.skipped.push({file:from,reason:"its file was replaced by a regenerated one; not repointed, because the contents differ"});const prunable=plan.superseded.filter((row)=>SAFE_MIGRATION_FILE.test(row)&&!retiring.has(row));for(const row of plan.superseded.filter((row)=>!SAFE_MIGRATION_FILE.test(row)))result.skipped.push({file:row,reason:"ledger row is not safe to write to the ledger"});const toRecord=[],revertedEntries=[];for(const entry of audit.entries){if(entry.status==="stranded"){toRecord.push(entry.file);continue}if(entry.status==="partial"){if(options.includePartial){toRecord.push(entry.file);continue}result.skipped.push({file:entry.file,reason:written.has(entry.file)?`${entry.present.length}/${entry.effects.length} effects present - left pending; run \`buddy migrate:align\` first so the next migrate does not duplicate what exists under another name`:`${entry.present.length}/${entry.effects.length} effects present - resolve by hand, or pass --include-partial`});continue}if(entry.status==="reverted")revertedEntries.push(entry)}const toRequeue=[];if(options.requeueReverted){const live=await readLiveSchema(audit.dialect,run),recordedFiles=new Set(audit.entries.filter((entry)=>entry.recorded).map((entry)=>entry.file)),corpus=audit.entries.flatMap((entry)=>{try{const sql=readFileSync(join(audit.dir,entry.file),"utf8");return[{file:entry.file,sql:gate?gate.rewrite(sql):sql,recorded:recordedFiles.has(entry.file)}]}catch{return[]}}),plan=planRequeue(corpus,live.tables);toRequeue.push(...plan.requeue);for(const{table,reason}of plan.refused)result.skipped.push({file:table,reason:`missing table not rebuilt: ${reason}`})}for(const entry of revertedEntries.filter((entry)=>!toRequeue.includes(entry.file))){if(written.has(entry.file)){result.skipped.push({file:entry.file,reason:`recorded under the same name as the file it replaced, but ${entry.absent.length} effect(s) of the regenerated file are missing - \`buddy migrate:align\` creates them`});continue}result.skipped.push({file:entry.file,reason:options.requeueReverted?`recorded, but ${entry.absent.length} effect(s) are missing from the schema`:`recorded, but ${entry.absent.length} effect(s) are missing from the schema - --requeue-reverted rebuilds a table that is gone entirely`})}const unsafe=(file)=>!SAFE_MIGRATION_FILE.test(file);for(const{from,to}of plan.remap.filter((r)=>unsafe(r.from)||unsafe(r.to)))result.skipped.push({file:unsafe(from)?from:to,reason:"migration filename is not safe to write to the ledger"});for(const file of toRecord.filter(unsafe))result.skipped.push({file,reason:"migration filename is not safe to write to the ledger"});const remapped=plan.remap.filter((r)=>!unsafe(r.from)&&!unsafe(r.to)),recordable=toRecord.filter((file)=>!unsafe(file)&&!remapped.some((r)=>r.to===file)),requeueable=toRequeue.filter((file)=>!unsafe(file));for(const file of toRequeue.filter(unsafe))result.skipped.push({file,reason:"migration filename is not safe to write to the ledger"});if(options.dryRun){result.remapped=remapped;result.recorded=recordable;result.pruned=prunable;result.requeued=requeueable;result.retired=retirable;return result}await ensureLedgerTable(audit.dialect,run);for(const{from,to}of remapped){await run(`UPDATE migrations SET migration = '${to}' WHERE migration = '${from}'`);result.remapped.push({from,to})}for(const file of recordable){if((await run(`SELECT migration FROM migrations WHERE migration = '${file}'`)).length>0)continue;await run(`INSERT INTO migrations (migration) VALUES ('${file}')`);result.recorded.push(file)}for(const row of prunable){const survivor=byLogicalOnDisk(row,audit.entries.map((e)=>e.file));if(!survivor)continue;if((await run(`SELECT migration FROM migrations WHERE migration = '${survivor}'`)).length===0){result.skipped.push({file:row,reason:`would leave ${survivor} unrecorded`});continue}await run(`DELETE FROM migrations WHERE migration = '${row}'`);result.pruned.push(row)}for(const row of retirable){await run(`DELETE FROM migrations WHERE migration = '${row}'`);result.retired.push(row)}for(const file of requeueable){await run(`DELETE FROM migrations WHERE migration = '${file}'`);result.requeued.push(file)}return result}
1
+ import{existsSync,readdirSync,readFileSync}from"node:fs";import{join}from"node:path";import{resolvedDatabaseDriver}from"./dialect";import{comparableIndexKey,postgresIndexStatement}from"./index-shape";import{resolveMigrationDirectory}from"./migration-path";import{guardedBlockStatements,sqlStatementsOf}from"./sql-statements";const SAFE_MIGRATION_FILE=/^[\w.-]+\.sql$/,IDENT=String.raw`["\`\[]?([A-Za-z_]\w*)["\`\]]?`;export function stripForEffects(sql){let out="",i=0;const blank=(text)=>text.replace(/[^\n]/g," ");while(i<sql.length){const rest=sql.slice(i),line=rest.match(/^--[^\n]*/);if(line){out+=blank(line[0]);i+=line[0].length;continue}if(rest.startsWith("/*")){const end=rest.indexOf("*/"),chunk=end===-1?rest:rest.slice(0,end+2);out+=blank(chunk);i+=chunk.length;continue}if(rest[0]==="'"){let j=1;while(j<rest.length&&rest[j]!=="'")j++;const chunk=rest.slice(0,Math.min(j+1,rest.length));out+=blank(chunk);i+=chunk.length;continue}out+=sql[i];i+=1}return out}function withoutGuard(statement){const inner=guardedBlockStatements(statement);return inner?inner.flatMap(withoutGuard):[statement]}function rawStatementsOf(sql){return sqlStatementsOf(sql).flatMap(withoutGuard)}function statementsOf(sql){return rawStatementsOf(stripForEffects(sql)).map((s)=>s.trim()).filter((s)=>s.length>0)}export function logicalName(file){return file.replace(/^\d+[-_]/,"").replace(/\.sql$/i,"")}export function migrationRemovals(sql){const removals=[],seen=new Set,push=(effect)=>{const key=effectKey(effect);if(seen.has(key))return;seen.add(key);removals.push(effect)};for(const statement of statementsOf(sql)){const index=new RegExp(String.raw`^DROP\s+INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+EXISTS\s+)?${IDENT}`,"i").exec(statement);if(index?.[1]){push({kind:"index",name:index[1]});continue}const table=new RegExp(String.raw`^DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?${IDENT}`,"i").exec(statement);if(table?.[1]){push({kind:"table",name:table[1]});continue}const enumType=new RegExp(String.raw`^DROP\s+TYPE\s+(?:IF\s+EXISTS\s+)?${IDENT}`,"i").exec(statement);if(enumType?.[1]){push({kind:"enum",name:enumType[1]});continue}const alter=new RegExp(String.raw`^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?${IDENT}\s+(.*)$`,"is").exec(statement);if(!alter?.[1])continue;const owner=alter[1];for(const clause of(alter[2]??"").split(",")){const constraint=new RegExp(String.raw`^\s*DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?${IDENT}`,"i").exec(clause);if(constraint?.[1]){push({kind:"constraint",table:owner,name:constraint[1]});continue}const column=new RegExp(String.raw`^\s*DROP\s+(?:COLUMN\s+)?(?:IF\s+EXISTS\s+)?${IDENT}`,"i").exec(clause);if(column?.[1]&&!/^(?:constraint|index|key|primary|foreign|unique|check|default|partition)$/i.test(column[1]))push({kind:"column",table:owner,name:column[1]})}}return removals}export function migrationEffects(sql){const effects=[],seen=new Set,renamedAway=new Set,push=(effect)=>{const key=effectKey(effect);if(seen.has(key))return;seen.add(key);effects.push(effect)},shapes=new Map;for(const statement of rawStatementsOf(sql)){const match=/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?["`[]?([\w$]+)/i.exec(statement),key=match?.[1]?comparableIndexKey(statement):void 0;if(match?.[1]&&key)shapes.set(match[1].toLowerCase(),key)}for(const statement of statementsOf(sql)){const create=new RegExp(String.raw`^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"i").exec(statement);if(create?.[1]){push({kind:"table",name:create[1]});continue}const rename=new RegExp(String.raw`^ALTER\s+TABLE\s+${IDENT}\s+RENAME\s+TO\s+${IDENT}`,"i").exec(statement);if(rename?.[2]){if(rename[1])renamedAway.add(rename[1].toLowerCase());push({kind:"table",name:rename[2]});continue}const index=new RegExp(String.raw`^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}(?:\s+ON\s+${IDENT})?`,"i").exec(statement);if(index?.[1]){const shape=shapes.get(index[1].toLowerCase());push({kind:"index",name:index[1],...index[2]?{table:index[2]}:{},...shape?{shape}:{}});continue}const enumType=new RegExp(String.raw`^CREATE\s+TYPE\s+${IDENT}\s+AS\s+ENUM`,"i").exec(statement);if(enumType?.[1]){push({kind:"enum",name:enumType[1]});continue}const alter=new RegExp(String.raw`^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?${IDENT}\s+(.*)$`,"is").exec(statement);if(!alter?.[1]||!alter[2])continue;const table=alter[1],addColumn=new RegExp(String.raw`\bADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"gi");for(const m of alter[2].matchAll(addColumn))if(m[1])push({kind:"column",table,name:m[1]});const addConstraint=new RegExp(String.raw`\bADD\s+CONSTRAINT\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"gi");for(const m of alter[2].matchAll(addConstraint))if(m[1])push({kind:"constraint",table,name:m[1]});const addBare=new RegExp(String.raw`\bADD\s+(?!COLUMN\b|CONSTRAINT\b|INDEX\b|KEY\b|PRIMARY\b|UNIQUE\b|FOREIGN\b|FULLTEXT\b|SPATIAL\b|CHECK\b)${IDENT}\s+\w`,"gi");for(const m of alter[2].matchAll(addBare))if(m[1])push({kind:"column",table,name:m[1]})}if(renamedAway.size===0)return effects;return effects.filter((effect)=>{const owner=(effect.kind==="table"?effect.name:effect.table??"").toLowerCase();return!renamedAway.has(owner)})}function effectKey(effect){return`${effect.kind}:${(effect.table??"").toLowerCase()}.${effect.name.toLowerCase()}`}function removalKey(effect){if(effect.kind==="column"||effect.kind==="constraint")return effectKey(effect);return`${effect.kind}:${effect.name.toLowerCase()}`}export function verifiableEffects(effects,dialect){if(dialect==="postgres")return effects;if(dialect==="mysql")return effects.filter((e)=>e.kind!=="enum");return effects.filter((e)=>e.kind!=="constraint"&&e.kind!=="enum")}function emptySchema(){return{tables:new Set,columns:new Map,indexes:new Set,indexShapes:new Set,constraints:new Set,enums:new Set}}function rowsOf(result){return Array.isArray(result)?result:[]}async function defaultRunner(){const{db}=await import("./utils");return async(sql)=>rowsOf(await db.unsafe(sql).execute())}function pick(row,...keys){for(const key of keys){const value=row?.[key]??row?.[key.toLowerCase()]??row?.[key.toUpperCase()];if(typeof value==="string"&&value.length>0)return value}return""}export async function readLiveSchema(dialect,runner){const schema=emptySchema(),run=runner??await defaultRunner();if(dialect==="sqlite"){for(const row of await run("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")){const name=pick(row,"name");if(name)schema.tables.add(name.toLowerCase())}for(const row of await run("SELECT name, sql FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'")){const name=pick(row,"name");if(name)schema.indexes.add(name.toLowerCase());const key=comparableIndexKey(pick(row,"sql"));if(key)schema.indexShapes.add(key)}for(const table of schema.tables){if(!/^[a-z_]\w*$/i.test(table))continue;const cols=new Set;for(const row of await run(`PRAGMA table_info("${table}")`)){const name=pick(row,"name");if(name)cols.add(name.toLowerCase())}schema.columns.set(table,cols)}return schema}if(dialect==="mysql"){for(const row of await run("SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()")){const name=pick(row,"name","TABLE_NAME");if(name)schema.tables.add(name.toLowerCase())}for(const row of await run("SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()")){const table=pick(row,"TABLE_NAME").toLowerCase(),column=pick(row,"COLUMN_NAME").toLowerCase();if(!table||!column)continue;if(!schema.columns.has(table))schema.columns.set(table,new Set);schema.columns.get(table).add(column)}for(const row of await run("SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE()")){const name=pick(row,"name","INDEX_NAME");if(name)schema.indexes.add(name.toLowerCase())}for(const row of await run("SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE()")){const name=pick(row,"name","CONSTRAINT_NAME");if(name)schema.constraints.add(name.toLowerCase())}return schema}for(const row of await run("SELECT tablename AS name FROM pg_tables WHERE schemaname = 'public'")){const name=pick(row,"name","tablename");if(name)schema.tables.add(name.toLowerCase())}for(const row of await run("SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public'")){const table=pick(row,"table_name").toLowerCase(),column=pick(row,"column_name").toLowerCase();if(!table||!column)continue;if(!schema.columns.has(table))schema.columns.set(table,new Set);schema.columns.get(table).add(column)}for(const row of await run("SELECT indexname AS name, indexdef AS definition FROM pg_indexes WHERE schemaname = 'public'")){const name=pick(row,"name","indexname");if(name)schema.indexes.add(name.toLowerCase());const key=comparableIndexKey(postgresIndexStatement(pick(row,"definition","indexdef")));if(key)schema.indexShapes.add(key)}for(const row of await run("SELECT c.conname AS name FROM pg_constraint c JOIN pg_namespace n ON n.oid = c.connamespace WHERE n.nspname = 'public'")){const name=pick(row,"name","conname");if(name)schema.constraints.add(name.toLowerCase())}for(const row of await run("SELECT t.typname AS name FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE t.typtype = 'e' AND n.nspname = 'public'")){const name=pick(row,"name","typname");if(name)schema.enums.add(name.toLowerCase())}return schema}export function effectPresent(effect,schema){const name=effect.name.toLowerCase();switch(effect.kind){case"table":return schema.tables.has(name);case"column":return schema.columns.get((effect.table??"").toLowerCase())?.has(name)??!1;case"index":{if(schema.indexes.has(name))return!0;if(effect.shape&&schema.indexShapes.has(effect.shape))return!0;const table=(effect.table??"").toLowerCase();if(!table)return!1;return schema.indexes.has(`${table}_${name}`)}case"constraint":return schema.constraints.has(name);case"enum":return schema.enums.has(name)}}export function classifyMigration(recorded,present,absent){const verifiable=present.length+absent.length;if(recorded){if(verifiable===0||absent.length===0)return"applied";return"reverted"}if(verifiable===0)return"unverifiable";if(absent.length===0)return"stranded";if(present.length===0)return"pending";return"partial"}function migrationsDir(dir){if(dir)return dir;const driver=resolvedDatabaseDriver().toLowerCase();return resolveMigrationDirectory(driver==="singlestore"?"singlestore":driver)}function listMigrationFiles(dir){if(!existsSync(dir))return[];try{return readdirSync(dir).filter((f)=>f.toLowerCase().endsWith(".sql")).sort()}catch{return[]}}function currentDialect(){const driver=resolvedDatabaseDriver().toLowerCase();if(driver==="sqlite"||driver==="mysql"||driver==="postgres")return driver;if(driver==="vitess"||driver==="singlestore")return"mysql";if(driver==="turso"||driver==="libsql")return"sqlite";return"other"}export async function readLedger(runner){try{return(await(runner??await defaultRunner())("SELECT migration FROM migrations")).map((row)=>pick(row,"migration")).filter((name)=>name.length>0).sort()}catch{return[]}}export function planLedgerRemap(ledger,diskFiles){const onDisk=new Set(diskFiles),byLogical=new Map;for(const file of diskFiles){const key=logicalName(file);if(!byLogical.has(key))byLogical.set(key,[]);byLogical.get(key).push(file)}const claimed=new Set(ledger.filter((row)=>onDisk.has(row))),remap=[],ambiguous=[],dropped=[],superseded=[],targets=new Map;for(const row of ledger){if(onDisk.has(row))continue;const sameLogical=byLogical.get(logicalName(row))??[],candidates=sameLogical.filter((f)=>!claimed.has(f));if(candidates.length===0){if(sameLogical.length>0)superseded.push(row);else dropped.push(row);continue}if(candidates.length>1){ambiguous.push(row);continue}const to=candidates[0];if(!targets.has(to))targets.set(to,[]);targets.get(to).push(row);remap.push({from:row,to})}const contested=new Set([...targets.entries()].filter(([,rows])=>rows.length>1).flatMap(([,rows])=>rows));if(contested.size===0)return{remap,ambiguous,dropped,superseded};return{remap:remap.filter((r)=>!contested.has(r.from)),ambiguous:[...ambiguous,...contested].sort(),dropped,superseded}}export async function auditMigrationLedger(options={}){const dir=migrationsDir(options.dir),dialect=options.dialect??currentDialect(),files=listMigrationFiles(dir),counts={applied:0,stranded:0,pending:0,partial:0,unverifiable:0,reverted:0},emptyPlan={remap:[],ambiguous:[],dropped:[],superseded:[]};if(dialect==="other")return{supported:!1,dialect,dir,entries:[],orphans:[],gated:[],counts,recordedCount:0,remapPlan:emptyPlan,drift:!1};const run=options.run??await defaultRunner(),ledger=await readLedger(run),recorded=new Set(ledger),schema=await readLiveSchema(dialect,run),gate=await resolveGate(options,dir),sources=new Map,gated=files.filter((file)=>gate?.exclude.has(file));for(const file of files){if(gate?.exclude.has(file))continue;try{const sql=readFileSync(join(dir,file),"utf8");sources.set(file,gate?gate.rewrite(sql):sql)}catch{continue}}const readFiles=[...sources.keys()],removedLater=new Map,tablesDroppedLater=new Map;for(let i=0;i<readFiles.length;i++){const laterKeys=new Set,laterTables=new Set;for(let j=i+1;j<readFiles.length;j++)for(const removal of migrationRemovals(sources.get(readFiles[j]))){laterKeys.add(removalKey(removal));if(removal.kind==="table")laterTables.add(removal.name.toLowerCase())}removedLater.set(readFiles[i],laterKeys);tablesDroppedLater.set(readFiles[i],laterTables)}const entries=[];for(const file of readFiles){const sql=sources.get(file),effects=verifiableEffects(migrationEffects(sql),dialect),dropped=removedLater.get(file)??new Set,droppedTables=tablesDroppedLater.get(file)??new Set,survives=(effect)=>effectPresent(effect,schema)||dropped.has(removalKey(effect))||effect.table!==void 0&&droppedTables.has(effect.table.toLowerCase()),present=effects.filter(survives),absent=effects.filter((effect)=>!survives(effect)),isRecorded=recorded.has(file),status=classifyMigration(isRecorded,present,absent);counts[status]+=1;entries.push({file,logical:logicalName(file),recorded:isRecorded,status,effects,present,absent})}const readable=[...entries.map((entry)=>entry.file),...gated].sort(),remapPlan=planLedgerRemap(ledger,readable),renamedTo=new Map(remapPlan.remap.map((r)=>[r.from,r.to])),orphans=ledger.filter((row)=>!readable.includes(row)).map((row)=>({migration:row,renamedTo:renamedTo.get(row)})),drift=counts.stranded>0||counts.partial>0||counts.reverted>0||orphans.length>0;return{supported:!0,dialect,dir,entries,orphans,gated,counts,recordedCount:ledger.length,remapPlan,drift}}async function ensureLedgerTable(dialect,run){const id=dialect==="postgres"?"id SERIAL PRIMARY KEY":dialect==="mysql"?"id INT AUTO_INCREMENT PRIMARY KEY":"id INTEGER PRIMARY KEY AUTOINCREMENT",timestamp=dialect==="postgres"?"TIMESTAMP":"DATETIME",utcNow=dialect==="postgres"?"(now() AT TIME ZONE 'utc')":dialect==="mysql"?"(UTC_TIMESTAMP)":"CURRENT_TIMESTAMP";await run(`CREATE TABLE IF NOT EXISTS migrations (${id}, migration VARCHAR(255) NOT NULL UNIQUE, executed_at ${timestamp} DEFAULT ${utcNow})`);if(dialect!=="sqlite")try{await run(`ALTER TABLE migrations ALTER COLUMN executed_at SET DEFAULT ${utcNow}`)}catch{}}const Q=(pattern)=>new RegExp(pattern.replaceAll("{id}",IDENT),"i"),CREATE_TABLE=Q(String.raw`^CREATE\s+(?:TEMP(?:ORARY)?\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?{id}`),DROP_TABLE=Q(String.raw`^DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?{id}`),RENAME_TABLE=Q(String.raw`^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?{id}\s+RENAME\s+TO\s+{id}`),ALTER_TABLE=Q(String.raw`^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?{id}`),CREATE_INDEX=Q(String.raw`^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?{id}\s+ON\s+(?:ONLY\s+)?{id}`),DROP_INDEX=Q(String.raw`^DROP\s+INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+EXISTS\s+)?{id}(?:\s+ON\s+{id})?`),DML=Q(String.raw`^(?:INSERT\s+(?:OR\s+\w+\s+)?INTO|REPLACE\s+INTO|UPDATE|DELETE\s+FROM)\s+{id}`),NEUTRAL=/^(?:BEGIN|COMMIT|END|PRAGMA|SET|SELECT\s+1|COMMENT\s+ON|CREATE\s+TYPE\s+\S+\s+AS\s+ENUM|ALTER\s+TYPE\s+\S+\s+ADD\s+VALUE|STACKS_ENUM_GUARD)\b/i;function statementShape(statement,indexTables){const lower=(name)=>(name??"").toLowerCase();if(NEUTRAL.test(statement))return{modifies:[]};let m=RENAME_TABLE.exec(statement);if(m)return{modifies:[lower(m[1]),lower(m[2])],rename:{from:lower(m[1]),to:lower(m[2])}};m=CREATE_TABLE.exec(statement);if(m)return{modifies:[lower(m[1])],creates:lower(m[1])};m=DROP_TABLE.exec(statement);if(m)return{modifies:[lower(m[1])],drops:lower(m[1])};m=ALTER_TABLE.exec(statement);if(m)return{modifies:[lower(m[1])]};m=CREATE_INDEX.exec(statement);if(m)return{modifies:[lower(m[2])],index:{name:lower(m[1]),table:lower(m[2])}};m=DROP_INDEX.exec(statement);if(m){const table=m[2]?lower(m[2]):indexTables.get(lower(m[1]));return table?{modifies:[table]}:{modifies:[],unknown:!0}}m=DML.exec(statement);if(m)return{modifies:[lower(m[1])]};return{modifies:[],unknown:!0}}function requeueStatements(sql){return stripForEffects(sql).replace(/\bDO\s+\$(\w*)\$([\s\S]*?)\$\1\$/gi,(block,_tag,body)=>/^\s*BEGIN\s+CREATE\s+TYPE\b[\s\S]*\bEXCEPTION\s+WHEN\s+duplicate_object\s+THEN\s+null\s*;\s*END\s*$/i.test(body)?"STACKS_ENUM_GUARD":block).split(";").map((statement)=>statement.trim()).filter(Boolean)}async function resolveGate(options,dir){if(options.gate||options.dir)return options.gate;try{const{featureGateForLedger}=await import("./migrations");return await featureGateForLedger(dir)}catch{return}}export function planRequeue(files,liveTables){const ordered=[...files].sort((a,b)=>a.file.localeCompare(b.file)),indexTables=new Map,expected=new Set,lineageOf=new Map,lineage=(table)=>{let group=lineageOf.get(table);if(!group){group=new Set([table]);lineageOf.set(table,group)}return group},link=(a,b)=>{const merged=new Set([...lineage(a),...lineage(b)]);for(const name of merged)lineageOf.set(name,merged)},shapes=ordered.map(({file,sql,recorded})=>{const statements=requeueStatements(sql).map((statement)=>statementShape(statement,indexTables));for(const shape of statements){if(shape.index)indexTables.set(shape.index.name,shape.index.table);if(shape.creates)expected.add(shape.creates);if(shape.drops)expected.delete(shape.drops);if(shape.rename){expected.delete(shape.rename.from);expected.add(shape.rename.to);link(shape.rename.from,shape.rename.to)}}const created=new Set(statements.flatMap((shape)=>shape.creates?[shape.creates]:[])),removed=new Set(statements.flatMap((shape)=>[shape.drops,shape.rename?.from].filter((t)=>Boolean(t)))),scaffold=new Set([...created].filter((table)=>removed.has(table)||statements.some((shape)=>shape.rename?.from===table)));return{file,recorded,unknown:statements.some((shape)=>shape.unknown),modifies:new Set(statements.flatMap((shape)=>shape.modifies)),scaffold}}),missing=[...expected].filter((table)=>!liveTables.has(table)).sort(),rebuildable=new Set(missing.flatMap((table)=>[...lineage(table)]).filter((table)=>!liveTables.has(table))),chains=new Map(missing.map((table)=>{const names=lineage(table);return[table,shapes.filter((shape)=>[...shape.modifies].some((name)=>names.has(name)))]})),why=new Map;for(const[table,chain]of chains)for(const shape of chain){if(shape.unknown){why.set(table,`${shape.file} contains a statement the planner cannot classify`);break}const elsewhere=[...shape.modifies].filter((t)=>!rebuildable.has(t)&&!shape.scaffold.has(t));if(elsewhere.length>0){why.set(table,`${shape.file} also changes ${elsewhere.join(", ")}, which ${elsewhere.length===1?"is":"are"} not missing`);break}}let changed=!0;while(changed){changed=!1;for(const[table,chain]of chains){if(why.has(table))continue;const blocker=chain.flatMap((shape)=>[...shape.modifies]).find((other)=>other!==table&&missing.some((t)=>why.has(t)&&lineage(t).has(other)));if(blocker){why.set(table,`shares a migration with ${blocker}, which cannot be rebuilt`);changed=!0}}}const requeue=new Set;for(const[table,chain]of chains){if(why.has(table))continue;for(const shape of chain)if(shape.recorded)requeue.add(shape.file)}return{missing,requeue:ordered.map(({file})=>file).filter((file)=>requeue.has(file)),refused:[...why].map(([table,reason])=>({table,reason}))}}export async function auditMissingTables(options={}){const dialect=options.dialect??currentDialect();if(dialect==="other")return{supported:!1,missing:[],live:new Set};const dir=migrationsDir(options.dir),run=options.run??await defaultRunner(),recorded=new Set(await readLedger(run)),gate=await resolveGate(options,dir),files=listMigrationFiles(dir).filter((file)=>recorded.has(file)&&!gate?.exclude.has(file)).flatMap((file)=>{try{const sql=readFileSync(join(dir,file),"utf8");return[{file,sql:gate?gate.rewrite(sql):sql,recorded:!0}]}catch{return[]}}),live=await readLiveSchema(dialect,run);return{supported:!0,missing:planRequeue(files,live.tables).missing,live:live.tables}}function byLogicalOnDisk(row,diskFiles){const key=logicalName(row),matches=diskFiles.filter((f)=>logicalName(f)===key);return matches.length===1?matches[0]:void 0}export async function reconcileMigrationLedger(options={}){const run=options.run??await defaultRunner(),gate=await resolveGate(options,migrationsDir(options.dir)),audit=await auditMigrationLedger({dir:options.dir,dialect:options.dialect,run,gate}),result={remapped:[],recorded:[],pruned:[],requeued:[],retired:[],skipped:[]};if(!audit.supported){result.skipped.push({file:"*",reason:`dialect "${audit.dialect}" is not audited`});return result}const written=new Set(options.regenerated?.written??[]),removedByRegeneration=new Set(options.regenerated?.removed??[]),onDisk=new Set([...audit.entries.map((entry)=>entry.file),...audit.gated]),plan={...audit.remapPlan,remap:audit.remapPlan.remap.filter((r)=>!written.has(r.to))},retirable=[...audit.remapPlan.remap.filter((r)=>written.has(r.to)).map((r)=>r.from),...audit.remapPlan.dropped,...audit.remapPlan.superseded,...audit.remapPlan.ambiguous].filter((row)=>removedByRegeneration.has(row)&&!onDisk.has(row)&&SAFE_MIGRATION_FILE.test(row)),retiring=new Set(retirable);for(const row of plan.ambiguous.filter((row)=>!retiring.has(row)))result.skipped.push({file:row,reason:"ledger row matches more than one file by logical name"});for(const row of plan.dropped.filter((row)=>!retiring.has(row)))result.skipped.push({file:row,reason:"recorded migration no longer exists on disk"});for(const{from}of audit.remapPlan.remap.filter((r)=>written.has(r.to)&&!retiring.has(r.from)))result.skipped.push({file:from,reason:"its file was replaced by a regenerated one; not repointed, because the contents differ"});const prunable=plan.superseded.filter((row)=>SAFE_MIGRATION_FILE.test(row)&&!retiring.has(row));for(const row of plan.superseded.filter((row)=>!SAFE_MIGRATION_FILE.test(row)))result.skipped.push({file:row,reason:"ledger row is not safe to write to the ledger"});const toRecord=[],revertedEntries=[];for(const entry of audit.entries){if(entry.status==="stranded"){toRecord.push(entry.file);continue}if(entry.status==="partial"){if(options.includePartial){toRecord.push(entry.file);continue}result.skipped.push({file:entry.file,reason:written.has(entry.file)?`${entry.present.length}/${entry.effects.length} effects present - left pending; run \`buddy migrate:align\` first so the next migrate does not duplicate what exists under another name`:`${entry.present.length}/${entry.effects.length} effects present - resolve by hand, or pass --include-partial`});continue}if(entry.status==="reverted")revertedEntries.push(entry)}const toRequeue=[];if(options.requeueReverted){const live=await readLiveSchema(audit.dialect,run),recordedFiles=new Set(audit.entries.filter((entry)=>entry.recorded).map((entry)=>entry.file)),corpus=audit.entries.flatMap((entry)=>{try{const sql=readFileSync(join(audit.dir,entry.file),"utf8");return[{file:entry.file,sql:gate?gate.rewrite(sql):sql,recorded:recordedFiles.has(entry.file)}]}catch{return[]}}),plan=planRequeue(corpus,live.tables);toRequeue.push(...plan.requeue);for(const{table,reason}of plan.refused)result.skipped.push({file:table,reason:`missing table not rebuilt: ${reason}`})}for(const entry of revertedEntries.filter((entry)=>!toRequeue.includes(entry.file))){if(written.has(entry.file)){result.skipped.push({file:entry.file,reason:`recorded under the same name as the file it replaced, but ${entry.absent.length} effect(s) of the regenerated file are missing - \`buddy migrate:align\` creates them`});continue}result.skipped.push({file:entry.file,reason:options.requeueReverted?`recorded, but ${entry.absent.length} effect(s) are missing from the schema`:`recorded, but ${entry.absent.length} effect(s) are missing from the schema - --requeue-reverted rebuilds a table that is gone entirely`})}const unsafe=(file)=>!SAFE_MIGRATION_FILE.test(file);for(const{from,to}of plan.remap.filter((r)=>unsafe(r.from)||unsafe(r.to)))result.skipped.push({file:unsafe(from)?from:to,reason:"migration filename is not safe to write to the ledger"});for(const file of toRecord.filter(unsafe))result.skipped.push({file,reason:"migration filename is not safe to write to the ledger"});const remapped=plan.remap.filter((r)=>!unsafe(r.from)&&!unsafe(r.to)),recordable=toRecord.filter((file)=>!unsafe(file)&&!remapped.some((r)=>r.to===file)),requeueable=toRequeue.filter((file)=>!unsafe(file));for(const file of toRequeue.filter(unsafe))result.skipped.push({file,reason:"migration filename is not safe to write to the ledger"});if(options.dryRun){result.remapped=remapped;result.recorded=recordable;result.pruned=prunable;result.requeued=requeueable;result.retired=retirable;return result}await ensureLedgerTable(audit.dialect,run);for(const{from,to}of remapped){await run(`UPDATE migrations SET migration = '${to}' WHERE migration = '${from}'`);result.remapped.push({from,to})}for(const file of recordable){if((await run(`SELECT migration FROM migrations WHERE migration = '${file}'`)).length>0)continue;await run(`INSERT INTO migrations (migration) VALUES ('${file}')`);result.recorded.push(file)}for(const row of prunable){const survivor=byLogicalOnDisk(row,audit.entries.map((e)=>e.file));if(!survivor)continue;if((await run(`SELECT migration FROM migrations WHERE migration = '${survivor}'`)).length===0){result.skipped.push({file:row,reason:`would leave ${survivor} unrecorded`});continue}await run(`DELETE FROM migrations WHERE migration = '${row}'`);result.pruned.push(row)}for(const row of retirable){await run(`DELETE FROM migrations WHERE migration = '${row}'`);result.retired.push(row)}for(const file of requeueable){await run(`DELETE FROM migrations WHERE migration = '${file}'`);result.requeued.push(file)}return result}
@@ -1,7 +1,7 @@
1
+ import { enumTypesCreatedBy, sqlStatementsOf } from './sql-statements';
1
2
  import { GENERATED_MIGRATION_MARKER, isGeneratedMigration } from './generated-migration-marker';
2
3
  import { indexShape } from './index-shape';
3
4
  import { resolveModelSources } from './model-sources';
4
- import { sqlStatementsOf } from './sql-statements';
5
5
  import type { CorpusGate } from './migration-ledger';
6
6
  import type { DatabaseRenameResult, RenameSqlRunner } from './framework-renames';
7
7
  import type { IndexShape } from './index-shape';
@@ -397,8 +397,6 @@ export declare function inlineSqliteAddedColumnReferences(statements: string[],
397
397
  */
398
398
  export declare function tablesOperatedOn(sql: string): string[];
399
399
  export declare function describeMigrationContent(sql: string): MigrationContent;
400
- /** The enum types a set of generated statements creates, lowercased. */
401
- export declare function enumTypesCreatedBy(statements: readonly string[]): Set<string>;
402
400
  /**
403
401
  * Why a regeneration may replace a file that names no table, or undefined
404
402
  * when it may not. See {@link describeMigrationContent}.
@@ -729,7 +727,7 @@ export declare interface MigrationResult {
729
727
  direction: 'Up' | 'Down'
730
728
  status: 'Success' | 'Error' | 'NotExecuted'
731
729
  }
732
- export { sqlStatementsOf } from './sql-statements';
730
+ export { enumTypesCreatedBy, sqlStatementsOf } from './sql-statements';
733
731
  /**
734
732
  * Header stamped onto every file `regenerateMigrationCorpus` writes.
735
733
  *
@@ -1,4 +1,4 @@
1
- var {require}=import.meta;import{existsSync,mkdirSync,readdirSync,readFileSync,renameSync,unlinkSync,writeFileSync}from"node:fs";import{isPackageMigration,stagePackageMigrations}from"./package-migrations";import{basename,dirname,isAbsolute,join,resolve}from"node:path";import{log as _log}from"@stacksjs/logging";const log={info:(...args)=>typeof _log?.info==="function"?_log.info(...args):console.log(...args),success:(msg)=>typeof _log?.success==="function"?_log.success(msg):console.log(msg),warn:(msg)=>typeof _log?.warn==="function"?_log.warn(msg):console.warn(msg),error:(...args)=>typeof _log?.error==="function"?_log.error(...args):console.error(...args),debug:(...args)=>typeof _log?.debug==="function"?_log.debug(...args):console.debug(...args)};import{err,handleError,ok}from"@stacksjs/error-handling";import{path}from"@stacksjs/path";import{defaultModelsPath}from"./seeder";import{createQueryBuilder,executeMigrationWithReport as qbExecuteMigrationWithReport,generateDiffOperations,generateMigration as qbGenerateMigration,resetDatabase as qbResetDatabase,config as qbConfig,saveMigrationSnapshot,setConfig}from"@stacksjs/query-builder";import{db,ensureDatabaseConfigLoaded,qbSnapshotDir,resetDatabaseConnection,resolvedDatabaseConfig}from"./utils";import{classifyConnectionError,createDatabase,describeTarget,manualCreateHint,probeTargetDatabase,resolveConnectionTarget}from"./ensure-database";import{declaredModelTables,resolveModelSources}from"./model-sources";import{applicableFrameworkRenames,applyFrameworkRenamesToDatabase,applyFrameworkRenamesToPlan}from"./framework-renames";import{findShadowedColumnDrops,shadowDropsAllowed,shadowedDropMessage}from"./shadowed-models";import{frameworkManagedColumns,withoutManagedColumnDrops,withoutManagedColumnDropSql}from"./managed-columns";import{acquireLibsqlMigrationLock,acquireMigrationLock}from"./migration-lock";import{auditMissingTables}from"./migration-ledger";import{relativeMigrationDirectory,resolveMigrationDirectory}from"./migration-path";import{ensureNotificationForeignKeys,migrateNotificationTables,notificationTablePreflight}from"./notification-tables";import{traitTableNames}from"./trait-tables";import{AUTH_TABLES}from"./auth-tables";import{sqlStatementsOf}from"./sql-statements";import{indexShape}from"./index-shape";import{corpusUniqueIndexes,hasIndex,restorableMissingUniqueIndexes,restoreDroppedUniqueIndexes,uniqueIndexIsRestorable}from"./declared-unique-indexes";import{ensureUuidColumns}from"./uuid-columns";import{sqlHelpers}from"./sql-helpers";import{GENERATED_MIGRATION_MARKER,isGeneratedMigration}from"./generated-migration-marker";import{trackIgnoredGeneratedMigrations}from"./ignored-migrations";export{sqlStatementsOf}from"./sql-statements";import{isLibsqlDriver,isVitessSharded,toSqlIntrospectionDialect}from"./dialect";const dbConfig={get default(){return resolvedDatabaseConfig().driver},get connections(){const{connections}=resolvedDatabaseConfig();return{...connections,turso:connections.turso??{url:"",authToken:""}}}};function usesLibsql(){return isLibsqlDriver(getDriver())}function sqliteDatabasePath(){const configured=dbConfig.connections.sqlite.database||"stacks.db";return isAbsolute(configured)?configured:join(process.cwd(),configured)}function getDriver(){return dbConfig.default||"sqlite"}function getDialect(){const driver=getDriver();if(driver==="sqlite"||driver==="mysql"||driver==="vitess"||driver==="postgres")return driver;if(driver==="turso")return"sqlite";if(driver==="singlestore")return"mysql";if(driver==="dynamodb")throw Error("[database] DB_CONNECTION=dynamodb is not compatible with the SQL migration runner. DynamoDB has no schema-migration concept - use the entity-style `dynamo.entity(...)` API from @stacksjs/database directly. To run SQL migrations, set DB_CONNECTION to one of: sqlite, turso, mysql, singlestore, vitess, postgres.");throw Error(`[database] Unknown DB_CONNECTION "${driver}". Allowed values: sqlite, turso, mysql, singlestore, vitess, postgres, dynamodb.`)}function getQbDialect(){return getDriver()==="singlestore"?"singlestore":getDialect()}function migrationDirectory(dialect=getQbDialect()){return resolveMigrationDirectory(dialect,{configured:qbConfig.migrationDir,snapshotDir:qbSnapshotDir()})}export async function activeMigrationDirectory(){await ensureDatabaseConfigLoaded();return migrationDirectory(getDialect())}function configureQueryBuilder(targetDialect=getQbDialect(),vitessSharded){const connectionConfig=dbConfig.connections[targetDialect],libsql=targetDialect==="sqlite"&&usesLibsql()?{url:dbConfig.connections.turso.url||void 0,authToken:dbConfig.connections.turso.authToken||void 0}:{url:void 0,authToken:void 0};if(targetDialect==="sqlite"&&usesLibsql()&&!libsql.url)throw Error("[database] DB_CONNECTION=turso needs TURSO_DATABASE_URL (libsql://<db>-<org>.turso.io, or http://127.0.0.1:8080 for `turso dev`).");setConfig({dialect:targetDialect,vitess:{sharded:isVitessSharded(vitessSharded??connectionConfig?.sharded)},verbose:!1,snapshotDir:qbSnapshotDir(),migrationDir:relativeMigrationDirectory(migrationDirectory(targetDialect)),database:{database:connectionConfig?.name||connectionConfig?.database||"stacks",host:connectionConfig?.host||"localhost",port:connectionConfig?.port||(targetDialect==="postgres"?5432:targetDialect==="vitess"?15306:targetDialect==="mysql"||targetDialect==="singlestore"?3306:0),username:connectionConfig?.username||"",password:connectionConfig?.password||"",...libsql}});resetDatabaseConnection()}export function prepareMigrationModelsDir(){const sources=resolveModelSources(),excludedTables=sources?.excludedTables??[];return{modelsDir:sources?.dir??path.userModelsPath(),skip:!sources,excludedTables,protectedTables:[...new Set([...excludedTables,...traitTableNames()])]}}function generatedStatementTables(statement){return statement.split(";").map((part)=>part.trim()).filter(Boolean).flatMap((part)=>{const table=rebuiltTable(part)??statementTable(part),index=/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?\w+["`]?\s+ON\s+["`]?(\w+)["`]?/i.exec(part)?.[1],dropped=DROP_TABLE_RE.exec(part)?.[1];return[table,index,dropped].filter((name)=>Boolean(name)).map((name)=>name.toLowerCase())})}export function withoutRuntimeOwnedTableSql(statements,owned=AUTH_TABLES){const ownedSet=new Set(owned.map((table)=>table.toLowerCase())),removed=[];return{statements:statements.filter((statement)=>{if(!generatedStatementTables(statement).some((table)=>ownedSet.has(table)))return!0;removed.push(statement);return!1}),removed}}const DROP_TABLE_RE=/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i;export function withoutProtectedTableDropSql(statements,protectedTables,operations){if(protectedTables.length===0)return{statements,removed:[]};const excluded=new Set(protectedTables.map((table)=>table.toLowerCase())),normalize=(sql)=>sql.replace(/\s+/g," ").trim().replace(/;$/,""),dropped=new Set(operations.filter((op)=>op.kind==="drop_table"&&excluded.has(op.table.toLowerCase())).filter((op)=>Boolean(op.sql)).map((op)=>normalize(op.sql))),removed=[];return{statements:statements.filter((statement)=>{if(dropped.has(normalize(statement))){removed.push(statement);return!1}const match=statement.match(DROP_TABLE_RE);if(match?.[1]&&excluded.has(match[1].toLowerCase())){removed.push(statement);return!1}return!0}),removed}}export function orderPostgresColumnTypeChanges(sql){const lines=sql.split(`
1
+ var {require}=import.meta;import{existsSync,mkdirSync,readdirSync,readFileSync,renameSync,unlinkSync,writeFileSync}from"node:fs";import{isPackageMigration,stagePackageMigrations}from"./package-migrations";import{basename,dirname,isAbsolute,join,resolve}from"node:path";import{log as _log}from"@stacksjs/logging";const log={info:(...args)=>typeof _log?.info==="function"?_log.info(...args):console.log(...args),success:(msg)=>typeof _log?.success==="function"?_log.success(msg):console.log(msg),warn:(msg)=>typeof _log?.warn==="function"?_log.warn(msg):console.warn(msg),error:(...args)=>typeof _log?.error==="function"?_log.error(...args):console.error(...args),debug:(...args)=>typeof _log?.debug==="function"?_log.debug(...args):console.debug(...args)};import{err,handleError,ok}from"@stacksjs/error-handling";import{path}from"@stacksjs/path";import{defaultModelsPath}from"./seeder";import{createQueryBuilder,executeMigrationWithReport as qbExecuteMigrationWithReport,generateDiffOperations,generateMigration as qbGenerateMigration,resetDatabase as qbResetDatabase,config as qbConfig,saveMigrationSnapshot,setConfig}from"@stacksjs/query-builder";import{db,ensureDatabaseConfigLoaded,qbSnapshotDir,resetDatabaseConnection,resolvedDatabaseConfig}from"./utils";import{classifyConnectionError,createDatabase,describeTarget,manualCreateHint,probeTargetDatabase,resolveConnectionTarget}from"./ensure-database";import{declaredModelTables,resolveModelSources}from"./model-sources";import{applicableFrameworkRenames,applyFrameworkRenamesToDatabase,applyFrameworkRenamesToPlan}from"./framework-renames";import{findShadowedColumnDrops,shadowDropsAllowed,shadowedDropMessage}from"./shadowed-models";import{frameworkManagedColumns,withoutManagedColumnDrops,withoutManagedColumnDropSql}from"./managed-columns";import{acquireLibsqlMigrationLock,acquireMigrationLock}from"./migration-lock";import{auditMissingTables}from"./migration-ledger";import{relativeMigrationDirectory,resolveMigrationDirectory}from"./migration-path";import{ensureNotificationForeignKeys,migrateNotificationTables,notificationTablePreflight}from"./notification-tables";import{traitTableNames}from"./trait-tables";import{AUTH_TABLES}from"./auth-tables";import{enumTypesCreatedBy,guardedBlockStatements,SQL_IDENTIFIER,sqlStatementsOf}from"./sql-statements";import{indexShape}from"./index-shape";import{corpusUniqueIndexes,hasIndex,restorableMissingUniqueIndexes,restoreDroppedUniqueIndexes,uniqueIndexIsRestorable}from"./declared-unique-indexes";import{ensureUuidColumns}from"./uuid-columns";import{sqlHelpers}from"./sql-helpers";import{GENERATED_MIGRATION_MARKER,isGeneratedMigration}from"./generated-migration-marker";import{trackIgnoredGeneratedMigrations}from"./ignored-migrations";export{enumTypesCreatedBy,sqlStatementsOf}from"./sql-statements";import{isLibsqlDriver,isVitessSharded,toSqlIntrospectionDialect}from"./dialect";const dbConfig={get default(){return resolvedDatabaseConfig().driver},get connections(){const{connections}=resolvedDatabaseConfig();return{...connections,turso:connections.turso??{url:"",authToken:""}}}};function usesLibsql(){return isLibsqlDriver(getDriver())}function sqliteDatabasePath(){const configured=dbConfig.connections.sqlite.database||"stacks.db";return isAbsolute(configured)?configured:join(process.cwd(),configured)}function getDriver(){return dbConfig.default||"sqlite"}function getDialect(){const driver=getDriver();if(driver==="sqlite"||driver==="mysql"||driver==="vitess"||driver==="postgres")return driver;if(driver==="turso")return"sqlite";if(driver==="singlestore")return"mysql";if(driver==="dynamodb")throw Error("[database] DB_CONNECTION=dynamodb is not compatible with the SQL migration runner. DynamoDB has no schema-migration concept - use the entity-style `dynamo.entity(...)` API from @stacksjs/database directly. To run SQL migrations, set DB_CONNECTION to one of: sqlite, turso, mysql, singlestore, vitess, postgres.");throw Error(`[database] Unknown DB_CONNECTION "${driver}". Allowed values: sqlite, turso, mysql, singlestore, vitess, postgres, dynamodb.`)}function getQbDialect(){return getDriver()==="singlestore"?"singlestore":getDialect()}function migrationDirectory(dialect=getQbDialect()){return resolveMigrationDirectory(dialect,{configured:qbConfig.migrationDir,snapshotDir:qbSnapshotDir()})}export async function activeMigrationDirectory(){await ensureDatabaseConfigLoaded();return migrationDirectory(getDialect())}function configureQueryBuilder(targetDialect=getQbDialect(),vitessSharded){const connectionConfig=dbConfig.connections[targetDialect],libsql=targetDialect==="sqlite"&&usesLibsql()?{url:dbConfig.connections.turso.url||void 0,authToken:dbConfig.connections.turso.authToken||void 0}:{url:void 0,authToken:void 0};if(targetDialect==="sqlite"&&usesLibsql()&&!libsql.url)throw Error("[database] DB_CONNECTION=turso needs TURSO_DATABASE_URL (libsql://<db>-<org>.turso.io, or http://127.0.0.1:8080 for `turso dev`).");setConfig({dialect:targetDialect,vitess:{sharded:isVitessSharded(vitessSharded??connectionConfig?.sharded)},verbose:!1,snapshotDir:qbSnapshotDir(),migrationDir:relativeMigrationDirectory(migrationDirectory(targetDialect)),database:{database:connectionConfig?.name||connectionConfig?.database||"stacks",host:connectionConfig?.host||"localhost",port:connectionConfig?.port||(targetDialect==="postgres"?5432:targetDialect==="vitess"?15306:targetDialect==="mysql"||targetDialect==="singlestore"?3306:0),username:connectionConfig?.username||"",password:connectionConfig?.password||"",...libsql}});resetDatabaseConnection()}export function prepareMigrationModelsDir(){const sources=resolveModelSources(),excludedTables=sources?.excludedTables??[];return{modelsDir:sources?.dir??path.userModelsPath(),skip:!sources,excludedTables,protectedTables:[...new Set([...excludedTables,...traitTableNames()])]}}function generatedStatementTables(statement){return statement.split(";").map((part)=>part.trim()).filter(Boolean).flatMap((part)=>{const table=rebuiltTable(part)??statementTable(part),index=/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?\w+["`]?\s+ON\s+["`]?(\w+)["`]?/i.exec(part)?.[1],dropped=DROP_TABLE_RE.exec(part)?.[1];return[table,index,dropped].filter((name)=>Boolean(name)).map((name)=>name.toLowerCase())})}export function withoutRuntimeOwnedTableSql(statements,owned=AUTH_TABLES){const ownedSet=new Set(owned.map((table)=>table.toLowerCase())),removed=[];return{statements:statements.filter((statement)=>{if(!generatedStatementTables(statement).some((table)=>ownedSet.has(table)))return!0;removed.push(statement);return!1}),removed}}const DROP_TABLE_RE=/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i;export function withoutProtectedTableDropSql(statements,protectedTables,operations){if(protectedTables.length===0)return{statements,removed:[]};const excluded=new Set(protectedTables.map((table)=>table.toLowerCase())),normalize=(sql)=>sql.replace(/\s+/g," ").trim().replace(/;$/,""),dropped=new Set(operations.filter((op)=>op.kind==="drop_table"&&excluded.has(op.table.toLowerCase())).filter((op)=>Boolean(op.sql)).map((op)=>normalize(op.sql))),removed=[];return{statements:statements.filter((statement)=>{if(dropped.has(normalize(statement))){removed.push(statement);return!1}const match=statement.match(DROP_TABLE_RE);if(match?.[1]&&excluded.has(match[1].toLowerCase())){removed.push(statement);return!1}return!0}),removed}}export function orderPostgresColumnTypeChanges(sql){const lines=sql.split(`
2
2
  `),output=[];for(const line of lines){const match=/^(\s*)ALTER\s+TABLE\s+("?[\w.]+"?)\s+ALTER\s+COLUMN\s+("?[\w]+"?)\s+TYPE\s/i.exec(line);if(match){const[,indent,table,column]=match,drop=`${indent}ALTER TABLE ${table} ALTER COLUMN ${column} DROP DEFAULT;`;if((output.length>0?output[output.length-1].trim():"")!==drop.trim())output.push(drop)}output.push(line)}return output.join(`
3
3
  `)}export function guardPostgresEnumTypes(sql){return assertPostgresEnumMembers(wrapPostgresEnumTypes(sql))}function wrapPostgresEnumTypes(sql){return sql.replace(/CREATE\s+TYPE\s+("?[\w.]+"?)\s+AS\s+ENUM\s*\(([^)]*)\)/gi,(match,name,members,offset,whole)=>{if(/\bBEGIN\s*$/i.test(whole.slice(Math.max(0,offset-40),offset)))return match;return`DO $stacks$ BEGIN CREATE TYPE ${name} AS ENUM (${members}); EXCEPTION WHEN duplicate_object THEN null; END $stacks$`})}function assertPostgresEnumMembers(sql){return sql.replace(/DO \$stacks\$[\s\S]*?END \$stacks\$;?/g,(block)=>{const created=/CREATE\s+TYPE\s+("?[\w.]+"?)\s+AS\s+ENUM\s*\(([^)]*)\)/i.exec(block);if(!created)return block;const[,name,members]=created,missing=enumMembers(members).map((member)=>`ALTER TYPE ${name} ADD VALUE IF NOT EXISTS ${member};`).filter((statement)=>!sql.includes(statement));if(missing.length===0)return block;return`${block.endsWith(";")?block:`${block};`}
4
4
  ${missing.join(`
@@ -29,7 +29,7 @@ Create it yourself with: ${manualCreateHint(target)}`);if(result.created)log.su
29
29
  JOIN pg_class c ON c.oid = a.attrelid
30
30
  WHERE a.atttypid = t.oid AND c.relkind = 'r' AND NOT a.attisdropped
31
31
  )
32
- `).execute(),names=(Array.isArray(raw)?raw:raw?.rows??[]).map((row)=>String(row.name??row.typname??"")).filter(Boolean);for(const name of names)try{await db.unsafe(`DROP TYPE IF EXISTS "${name}" CASCADE`).execute();log.debug(`Dropped orphaned enum type: ${name}`)}catch(error){log.warn(`Could not drop enum type ${name}: ${error instanceof Error?error.message:String(error)}`)}}catch(error){log.warn(`Could not list enum types to drop: ${error instanceof Error?error.message:String(error)}`)}}async function dropFrameworkTables(dialect){if(dialect==="mysql"||dialect==="vitess")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 0").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = OFF").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}for(const tableName of FRAMEWORK_TABLES)try{let dropSql;if(dialect==="postgres")dropSql=`DROP TABLE IF EXISTS "${tableName}" CASCADE`;else if(dialect==="mysql"||dialect==="vitess")dropSql=`DROP TABLE IF EXISTS \`${tableName}\``;else dropSql=`DROP TABLE IF EXISTS "${tableName}"`;log.info(`Dropping framework table: ${tableName}`);await db.unsafe(dropSql).execute();log.info(`Dropped framework table: ${tableName}`)}catch(error){const kind=classifyConnectionError(error);if(kind==="missing-database"||kind==="missing-role"||kind==="auth-failed"||kind==="server-unreachable"||kind==="timeout")throw error;log.warn(`Could not drop table ${tableName}: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="mysql"||dialect==="vitess")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 1").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = ON").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}}function frameworkRenamesForThisApp(){return applicableFrameworkRenames(declaredModelTables())}function describeRenames(renames){return renames.map((rename)=>`${rename.id}, ${rename.reference}`).join("; ")}export function catchUpSnapshotFrameworkRenames(options){configureQueryBuilder();const dialect=getQbDialect(),stored=readStoredMigrationPlan(dialect);if(!stored)return{changes:[]};const renames=frameworkRenamesForThisApp();if(renames.length===0)return{changes:[]};const{plan,changes}=applyFrameworkRenamesToPlan(stored,renames);if(changes.length===0)return{changes};if(options.persist){saveMigrationSnapshot(plan,{dialect});log.info(`[migration] Updated the model snapshot for framework renames (${describeRenames(renames)}): ${changes.join(", ")}`)}else log.debug(`[migration] Previewing against the snapshot with framework renames applied: ${changes.join(", ")}`);return{changes,plan}}export async function catchUpFrameworkRenames(options={}){await ensureDatabaseConfigLoaded();const dialect=toSqlIntrospectionDialect(getDialect());if(dialect==="other")return{applied:[],skipped:[]};const renames=frameworkRenamesForThisApp();if(renames.length===0)return{applied:[],skipped:[]};const runner=options.runner??(async(sql)=>{const result=await db.unsafe(sql).execute();return Array.isArray(result)?result:[]}),result=await applyFrameworkRenamesToDatabase(runner,dialect,renames);if(result.applied.length>0)log.info(`[migration] Applied framework renames to the database (${describeRenames(renames)}): ${result.applied.join(", ")}`);for(const skipped of result.skipped)log.warn(`[migration] Framework rename not applied: ${skipped}`);return result}function resolveGenerateOptions(options){const applyRenames=options.applyRenames??(process.env.STACKS_MIGRATE_NO_RENAME==="1"?!1:void 0),fromDb=options.fromDb??(process.env.STACKS_MIGRATE_FROM_DB==="1"?!0:void 0);return{applyRenames,fromDb}}export async function previewPendingMigrations(options={}){try{return await pendingMigrationOperations(options)}catch(error){log.debug(`[migration] preview failed: ${error instanceof Error?error.message:String(error)}`);return[]}}export async function pendingMigrationOperations(options={}){await ensureDatabaseConfigLoaded();configureQueryBuilder();const dialect=getDialect(),{modelsDir,skip,protectedTables}=prepareMigrationModelsDir();if(skip)return[];const{applyRenames,fromDb}=resolveGenerateOptions(options),qbDialect=getQbDialect(),result=await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,dryRun:!0,applyRenames,fromDb}),corrected=fromDb?void 0:catchUpSnapshotFrameworkRenames({persist:!1}).plan;if(corrected&&result.plan)result.operations=generateDiffOperations(corrected,result.plan,{applyRenames,dryRun:!0}).operations;const authOwned=new Set(AUTH_TABLES);let operations=(result.operations??[]).filter((op)=>!authOwned.has(op.table.toLowerCase()));if(protectedTables.length>0){const excluded=new Set(protectedTables.map((table)=>table.toLowerCase()));operations=operations.filter((op)=>!(op.kind==="drop_table"&&excluded.has(op.table.toLowerCase())))}if(!operations.some((op)=>op.kind==="drop_column"))return operations;return withoutManagedColumnDrops(operations,await frameworkManagedColumns())}function snapshotDirLabel(){return qbConfig?.snapshotDir||qbSnapshotDir()}function resolveSnapshotDir(){const label=snapshotDirLabel();return isAbsolute(label)?label:resolve(process.cwd(),label)}function snapshotPathFor(dialect){return join(resolveSnapshotDir(),`model-snapshot.${dialect}.json`)}function readStoredMigrationPlan(dialect){const snapshotPath=snapshotPathFor(dialect);if(!existsSync(snapshotPath))return;let parsed;try{parsed=JSON.parse(readFileSync(snapshotPath,"utf8"))}catch(error){throw Error(`The migration snapshot is not valid JSON: ${snapshotPath}. Repair or regenerate it before creating migrations.`,{cause:error})}const candidate=parsed&&typeof parsed==="object"&&"plan"in parsed?parsed.plan:parsed;if(!candidate||typeof candidate!=="object"||!Array.isArray(candidate.tables))throw TypeError(`The migration snapshot has an invalid structure: ${snapshotPath}. Repair or regenerate it before creating migrations.`);return candidate}export function preserveMigrationPlanTableOrder(next,previous){const orderedByPrevious=(values,previousValues,keyFor)=>{if(!previousValues)return[...values];const previousOrder=new Map(previousValues.map((value,index)=>[keyFor(value),index])),nextOrder=new Map(values.map((value,index)=>[keyFor(value),index])),previousCount=previousValues.length;return[...values].sort((left,right)=>{const leftKey=keyFor(left),rightKey=keyFor(right),leftOrder=previousOrder.get(leftKey)??previousCount+nextOrder.get(leftKey),rightOrder=previousOrder.get(rightKey)??previousCount+nextOrder.get(rightKey);return leftOrder-rightOrder})},previousTables=new Map(previous?.tables.map((table)=>[table.table,table])??[]),tables=next.tables.map((table)=>{const previousTable=previousTables.get(table.table),previousColumns=new Map(previousTable?.columns.map((column)=>[column.name,column])??[]);return{...table,columns:orderedByPrevious(table.columns.map((column)=>{if(next.dialect!=="sqlite"||!column.enumTypeName||previousColumns.get(column.name)?.enumTypeName)return column;const portableColumn={...column};delete portableColumn.enumTypeName;return portableColumn}),previousTable?.columns,(column)=>column.name),indexes:orderedByPrevious(table.indexes,previousTable?.indexes,(index)=>index.name)}});return{...next,tables:orderedByPrevious(tables,previous?.tables,(table)=>table.table)}}function detectSnapshotDialectMismatch(dialect){const qbDir=resolveSnapshotDir();let files;try{files=readdirSync(qbDir)}catch{return null}const snapshotFor=(d)=>`model-snapshot.${d}.json`;if(files.includes(snapshotFor(dialect)))return null;for(const f of files){const m=/^model-snapshot\.(\w+)\.json$/.exec(f);if(m?.[1]&&m[1]!==dialect)return m[1]}return null}export async function generateMigrations(options={}){await ensureDatabaseConfigLoaded();try{log.debug("Generating migrations...");configureQueryBuilder();const dialect=getDialect(),mismatch=detectSnapshotDialectMismatch(dialect),flatMigrationDir=join(process.cwd(),"database","migrations");if(mismatch&&migrationDirectory(dialect)===flatMigrationDir){const snapshotDir=snapshotDirLabel();return err(Error(`Refusing to generate migrations: resolved dialect "${dialect}" has no snapshot in ${snapshotDir}/, but "${mismatch}" does. DB_CONNECTION is likely unset or wrong (missing .env?) - generating now would write a full duplicate migration set in the wrong dialect. Set DB_CONNECTION=${mismatch} (or your intended dialect) and retry. To intentionally start a new dialect from scratch, remove ${snapshotDir}/model-snapshot.${mismatch}.json first.`))}catchUpSnapshotFrameworkRenames({persist:!0});const storedPlan=readStoredMigrationPlan(getQbDialect()),{modelsDir,skip,excludedTables,protectedTables}=prepareMigrationModelsDir();if(skip){trackIgnoredGeneratedMigrations(migrationDirectory());log.debug("No app/Models directory found; using committed framework migrations");return ok("Migrations generated")}const{applyRenames,fromDb}=resolveGenerateOptions(options);log.debug(`[migration] Generating migrations for dialect: ${dialect}, models: ${modelsDir}`);if(excludedTables.length>0)log.debug(`[migration] ${excludedTables.length} framework default model(s) out of scope because app/Models defines this app's schema. Enable database.models.includeFrameworkDefaults (or STACKS_INCLUDE_FRAMEWORK_MODELS=1) to generate them too.`);const qbDialect=getQbDialect(),result=await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,dryRun:!0,applyRenames,fromDb});let sqlStatements=result.sqlStatements??[];if(dialect==="sqlite")sqlStatements=inlineSqliteAddedColumnReferences(sqlStatements,result.plan);{const runtimeOwned=withoutRuntimeOwnedTableSql(sqlStatements);if(runtimeOwned.removed.length>0)log.debug(`[migration] Left out ${runtimeOwned.removed.length} generated statement(s) for tables the auth layer creates itself`);sqlStatements=runtimeOwned.statements}if(result.hasChanges&&sqlStatements.length>0){const filtered=withoutManagedColumnDropSql(sqlStatements,await frameworkManagedColumns(),result.operations??[]);if(filtered.removed.length>0)log.debug(`[migration] Skipped ${filtered.removed.length} generated drop(s) of framework-managed column(s) (stacksjs/stacks#2075)`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0&&protectedTables.length>0){const filtered=withoutProtectedTableDropSql(sqlStatements,protectedTables,result.operations??[]);if(filtered.removed.length>0)log.info(`[migration] Left ${filtered.removed.length} framework-owned table(s) in place rather than dropping them. They are no longer generated because app/Models defines this app's schema; the tables and their data are untouched. Set database.models.includeFrameworkDefaults to keep generating them (stacksjs/stacks#2220).`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0){const dangling=findDanglingTypeReferences(sqlStatements);if(dangling.length>0){const created=createMissingEnumTypes(dangling,result.plan);if(created.statements.length>0){sqlStatements=[...created.statements,...sqlStatements];log.debug(`[migration] Created ${created.statements.length} enum type(s) an ALTER needed: ${created.defined.join(", ")}`)}const unresolved=dangling.filter((name)=>!created.defined.includes(name));if(unresolved.length>0){const before=sqlStatements.length;sqlStatements=sqlStatements.filter((statement)=>!referencesUndefinedType(statement,unresolved));log.warn(`[migration] Skipped ${before-sqlStatements.length} generated statement(s) referencing enum type(s) nothing creates and no model defines values for (${unresolved.slice(0,3).join(", ")}${unresolved.length>3?", \u2026":""}).`)}}}if(result.hasChanges&&sqlStatements.length>0&&!shadowDropsAllowed()){const shadowed=resolveModelSources()?.shadowed??[],drops=findShadowedColumnDrops(sqlStatements,shadowed);if(drops.length>0)return err(Error(shadowedDropMessage(drops)))}if(result.hasChanges){const written=persistGeneratedMigrations(sqlStatements,storedPlan);if(written>0)log.success(`Generated ${written} migration file${written===1?"":"s"}`);else log.debug("Migration generation produced no new files (already up to date)")}else log.debug("No changes detected");trackIgnoredGeneratedMigrations(migrationDirectory());const stablePlan=preserveMigrationPlanTableOrder(result.plan,storedPlan);if(!storedPlan||JSON.stringify(stablePlan)!==JSON.stringify(storedPlan))saveMigrationSnapshot(stablePlan,{dialect:getQbDialect()});else log.debug("Model snapshot unchanged");return ok("Migrations generated")}catch(error){const because=error instanceof Error?error.message:String(error);return err(handleError(`Migration generation failed: ${because}`,error))}}export function referencesUndefinedType(statement,dangling){return dangling.some((name)=>statement.includes(`"${name}"`))}export function findDanglingTypeReferences(statements){const defined=new Set,referenced=new Set;for(const statement of statements){for(const match of statement.matchAll(/CREATE\s+TYPE\s+"([^"]+)"/gi))defined.add(match[1]);for(const match of statement.matchAll(/\bTYPE\s+"([^"]+)"/gi))referenced.add(match[1])}return[...referenced].filter((name)=>!defined.has(name)).sort()}export function createMissingEnumTypes(dangling,plan){if(dangling.length===0)return{statements:[],defined:[]};const values=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){if(!column.name||!column.enumValues?.length)continue;values.set(`${table.table}_${column.name}_type`,column.enumValues)}}const statements=[],defined=[];for(const name of dangling){const members=values.get(name);if(!members?.length)continue;const literals=members.map((member)=>`'${String(member).replaceAll("'","''")}'`).join(", "),guarded=guardPostgresEnumTypes(`CREATE TYPE "${name}" AS ENUM (${literals})`);statements.push(guarded.endsWith(";")?guarded:`${guarded};`);defined.push(name)}return{statements,defined}}export function inlineSqliteAddedColumnReferences(statements,plan){const references=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){const reference=column.references;if(!column.name||!reference?.table||!reference.column)continue;references.set(`${table.table}.${column.name}`,{table:reference.table,column:reference.column})}}if(references.size===0)return statements;return statements.map((statement)=>{if(/\bREFERENCES\b/i.test(statement))return statement;const match=statement.match(/^(\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+COLUMN\s+["`]?(\w+)["`]?\s+)([\s\S]*?)(;?\s*)$/i),reference=match?.[2]&&match[3]?references.get(`${match[2]}.${match[3]}`):void 0;if(!match||!reference)return statement;return`${match[1]}${match[4].trimEnd()} REFERENCES "${reference.table}"("${reference.column}")${match[5]}`})}export{GENERATED_MIGRATION_MARKER,isGeneratedMigration}from"./generated-migration-marker";export function tablesOperatedOn(sql){const tables=new Set;for(const statement of sqlStatementsOf(sql)){const stmt=statement.trim(),direct=stmt.match(/^(?:CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?|ALTER\s+TABLE|DROP\s+TABLE(?:\s+IF\s+EXISTS)?|TRUNCATE\s+TABLE)\s+["'`]?(\w+)["'`]?/i);if(direct?.[1]){tables.add(direct[1].toLowerCase());continue}const index=stmt.match(/^CREATE\s+(?:UNIQUE\s+)?INDEX(?:\s+IF\s+NOT\s+EXISTS)?\s+\S+\s+ON\s+["'`]?(\w+)["'`]?/i);if(index?.[1])tables.add(index[1].toLowerCase())}return[...tables]}const IDENTIFIER=String.raw`(?:["'\`[]?[\w$]+["'\`\]]?\.)?["'\`[]?([\w$]+)["'\`\]]?`,NEUTRAL_STATEMENT=/^(?:BEGIN|COMMIT|END|PRAGMA|SET|SELECT\s+1\s*$|COMMENT\s+ON)\b/i;function guardedBlockStatements(statement){const block=/^\s*DO\s+\$(\w*)\$([\s\S]*)\$\1\$\s*;?\s*$/i.exec(statement);if(!block)return;const body=/^\s*BEGIN\b([\s\S]*?)(?:\bEXCEPTION\b[\s\S]*)?\bEND\s*;?\s*$/i.exec(block[2]??"");return body?sqlStatementsOf(body[1]??""):void 0}export function describeMigrationContent(sql){const content={types:[],droppedTypes:[],droppedIndexes:[],createdIndexes:[],dataTables:[],seedTables:[],createdTables:[],other:!1},add=(list,name)=>{const lower=name?.toLowerCase();if(lower&&!list.includes(lower))list.push(lower)},visit=(statement)=>{const stmt=statement.trim().replace(/;\s*$/,"");if(!stmt||NEUTRAL_STATEMENT.test(stmt))return;const guarded=guardedBlockStatements(stmt);if(guarded){for(const inner of guarded)visit(inner);return}let m=new RegExp(String.raw`^CREATE\s+TYPE\s+${IDENTIFIER}\s+AS\s+ENUM\b`,"i").exec(stmt)??new RegExp(String.raw`^ALTER\s+TYPE\s+${IDENTIFIER}\s+ADD\s+VALUE\b`,"i").exec(stmt);if(m){add(content.types,m[1]);return}m=new RegExp(String.raw`^DROP\s+TYPE\s+(?:IF\s+EXISTS\s+)?${IDENTIFIER}`,"i").exec(stmt);if(m){add(content.droppedTypes,m[1]);return}m=new RegExp(String.raw`^DROP\s+INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+EXISTS\s+)?${IDENTIFIER}`,"i").exec(stmt);if(m){add(content.droppedIndexes,m[1]);return}m=new RegExp(String.raw`^(?:DELETE\s+FROM|UPDATE|TRUNCATE(?:\s+TABLE)?)\s+(?:ONLY\s+)?${IDENTIFIER}`,"i").exec(stmt);if(m){add(content.dataTables,m[1]);return}m=new RegExp(String.raw`^COPY\s+${IDENTIFIER}[\s\S]*\bFROM\b`,"i").exec(stmt);if(m){add(content.seedTables,m[1]);return}m=new RegExp(String.raw`^INSERT\s+(?:OR\s+\w+\s+)?INTO\s+${IDENTIFIER}`,"i").exec(stmt);if(m){if(/\bSELECT\b[\s\S]*\bFROM\b/i.test(stmt)&&!/\bVALUES\b/i.test(stmt))content.other=!0;else add(content.seedTables,m[1]);return}const shape=indexShape(stmt);if(shape){add(content.createdIndexes,shape.name);content.other=!0;return}const created=createdTableName(stmt);if(created)add(content.createdTables,created);content.other=!0};for(const statement of sqlStatementsOf(sql))visit(statement);return content}export function enumTypesCreatedBy(statements){const types=new Set;for(const statement of statements)for(const match of statement.matchAll(new RegExp(String.raw`\bCREATE\s+TYPE\s+${IDENTIFIER}\s+AS\s+ENUM\b`,"gi")))if(match[1])types.add(match[1].toLowerCase());return types}export function obsoleteMigrationReason(content,context){if(content.other||content.seedTables.length>0)return;const hasTypes=content.types.length>0||content.droppedTypes.length>0,hasDrops=content.droppedIndexes.length>0,hasData=content.dataTables.length>0;if([hasTypes,hasDrops,hasData].filter(Boolean).length!==1)return;if(hasTypes)return content.types.every((type)=>context.emittedTypes.has(type))?"types":void 0;if(hasDrops)return content.droppedIndexes.every((index)=>!context.keptIndexes.has(index))?"drop-index":void 0;return content.dataTables.every((table)=>!context.keptSeedTables.has(table))?"data":void 0}export function columnsDefinedByCreate(statement){const body=statement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?\w+["'`]?\s*\(([\s\S]*)\)\s*;?\s*$/i)?.[1];if(!body)return[];const parts=[];let depth=0,current="";for(const char of body){if(char==="(")depth++;if(char===")")depth--;if(char===","&&depth===0){parts.push(current);current="";continue}current+=char}parts.push(current);const constraint=/^\s*(?:PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK|CONSTRAINT|KEY|INDEX)\b/i;return parts.map((part)=>part.trim()).filter((part)=>part&&!constraint.test(part)).flatMap((part)=>part.match(/^["'`]?(\w+)["'`]?/)?.[1]??[])}export function columnsProducedByMigrations(dir,files,table){const columns=new Set,target=table.toLowerCase();for(const file of[...files].sort()){let content;try{content=readFileSync(join(dir,file),"utf8")}catch{continue}for(const statement of sqlStatementsOf(content)){const stmt=statement.trim();if(stmt.match(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i)?.[1]?.toLowerCase()===target){for(const column of columnsDefinedByCreate(stmt))columns.add(column.toLowerCase());continue}const added=stmt.match(/^ALTER\s+TABLE\s+["'`]?(\w+)["'`]?\s+ADD\s+(?:COLUMN\s+)?["'`]?(\w+)["'`]?/i);if(added?.[1]?.toLowerCase()===target&&added[2])columns.add(added[2].toLowerCase());const dropped=stmt.match(/^ALTER\s+TABLE\s+["'`]?(\w+)["'`]?\s+DROP\s+(?:COLUMN\s+)?["'`]?(\w+)["'`]?/i);if(dropped?.[1]?.toLowerCase()===target&&dropped[2])columns.delete(dropped[2].toLowerCase());const rebuilt=stmt.match(/^ALTER\s+TABLE\s+["'`]?_qb_tmp_(\w+)["'`]?\s+RENAME\s+TO\s+["'`]?(\w+)["'`]?/i);if(rebuilt?.[2]?.toLowerCase()===target){const temp=sqlStatementsOf(content).find((s)=>new RegExp(`^CREATE\\s+TABLE\\s+["'\`]?_qb_tmp_${rebuilt[1]}["'\`]?`,"i").test(s.trim()));if(temp){columns.clear();for(const column of columnsDefinedByCreate(temp))columns.add(column.toLowerCase())}}}}return columns}export function rootedTableCatchUpStatements(createStatement,existingColumns){const table=createStatement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i)?.[1];if(!table)return[];const body=createStatement.match(/\(([\s\S]*)\)\s*;?\s*$/)?.[1];if(!body)return[];const definitions=new Map;let depth=0,current="";const parts=[];for(const char of body){if(char==="(")depth++;if(char===")")depth--;if(char===","&&depth===0){parts.push(current);current="";continue}current+=char}parts.push(current);const constraint=/^\s*(?:PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK|CONSTRAINT|KEY|INDEX)\b/i;for(const raw of parts){const part=raw.trim();if(!part||constraint.test(part))continue;const name=part.match(/^["'`]?(\w+)["'`]?/)?.[1];if(name)definitions.set(name.toLowerCase(),part)}const statements=[];for(const[name,definition]of definitions){if(existingColumns.has(name))continue;if(/\b(?:PRIMARY\s+KEY|UNIQUE|AUTOINCREMENT)\b/i.test(definition))continue;const nullable=/\bNOT\s+NULL\b/i.test(definition)&&!/\bDEFAULT\b/i.test(definition)?definition.replace(/\s*\bNOT\s+NULL\b/i,""):definition;statements.push(`ALTER TABLE "${table}" ADD COLUMN ${nullable.trim()}`)}return statements}export function createdTablesOf(statements){const tables=new Set;for(const statement of statements){const match=statement.trim().match(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i);if(match?.[1])tables.add(match[1].toLowerCase())}return[...tables]}export function historicallyRootedTables(dir,files){const tables=new Set;for(const file of files){if(isGeneratedMigration(dir,file))continue;try{for(const statement of sqlStatementsOf(readFileSync(join(dir,file),"utf8"))){const match=statement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i);if(match?.[1])tables.add(match[1].toLowerCase())}}catch{}}return[...tables]}export function tablesDefinedByCorpus(dir,files){const tables=new Set;for(const file of files)try{for(const statement of sqlStatementsOf(readFileSync(join(dir,file),"utf8"))){const match=statement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i);if(match?.[1])tables.add(match[1].toLowerCase())}}catch{}return[...tables]}export function migrationTouchesRootedTable(dir,file,rootedTables){try{return tablesOperatedOn(readFileSync(join(dir,file),"utf8")).some((table)=>rootedTables.has(table.toLowerCase()))}catch{return!1}}export function allocateMigrationOrdinals(count,startAt,reserved){const ordinals=[];let cursor=startAt;while(ordinals.length<count){if(!reserved.has(cursor))ordinals.push(cursor);cursor+=1}return ordinals}export function migrationsOutsideCorpus(dir,files,corpusTables){const rebuilt=new Set(corpusTables.map((table)=>table.toLowerCase()));return files.filter((file)=>{let contents;try{contents=readFileSync(join(dir,file),"utf8")}catch{return!0}const touched=tablesOperatedOn(contents);if(touched.length===0)return!0;return touched.some((table)=>!rebuilt.has(table))})}function migrationOrdinal(file){const match=file.match(/^(\d+)/);return match?Number(match[1]):0}export async function generateFullModelMigration(options={}){await ensureDatabaseConfigLoaded();try{const dialect=options.dialect??getQbDialect();let requestedVitessSharded;if(dialect==="vitess")try{const{config,overridesReady}=await import("@stacksjs/config");await overridesReady;requestedVitessSharded=isVitessSharded(config?.database?.connections?.vitess?.sharded)}catch{requestedVitessSharded=isVitessSharded(dbConfig.connections.vitess.sharded)}configureQueryBuilder(dialect,requestedVitessSharded);const sources=resolveModelSources({forceStage:!0,...options.onlyExistingTables?{includeFrameworkDefaults:!0}:{}});if(!sources)return err(Error("No models found. Define models in app/Models, or ensure the framework defaults at storage/framework/defaults/app/Models are present."));try{writeFileSync(join(sources.dir,`.qb-migrations.${dialect}.json`),JSON.stringify({plan:{dialect,tables:[]}}))}catch{}const snapshotPath=join(resolveSnapshotDir(),`model-snapshot.${dialect}.json`),parkedSnapshot=`${snapshotPath}.regenerating`;let snapshotParked=!1;if(existsSync(snapshotPath))try{renameSync(snapshotPath,parkedSnapshot);snapshotParked=!0}catch{}let result;try{result=await qbGenerateMigration(sources.dir,{dialect,vitessSharded:requestedVitessSharded,dryRun:!0,full:!0})}finally{if(snapshotParked)try{renameSync(parkedSnapshot,snapshotPath)}catch{}}const statements=result.sqlStatements??[];if(statements.length===0)return err(Error(`The generator produced no SQL for dialect "${dialect}".`));const dangling=findDanglingTypeReferences(statements);if(dangling.length>0)return err(Error(`The generator emitted ${dangling.length} reference(s) to enum type(s) it never creates: ${dangling.slice(0,5).join(", ")}${dangling.length>5?`, +${dangling.length-5} more`:""}. Writing this corpus would fail partway through a migration. This is a bug in the migration generator, not in your models.`));return ok({dialect,statements,plan:result.plan,sources})}catch(error){return err(handleError("Migration generation failed",error))}}export async function regenerateMigrationCorpus(options={}){try{const generated=await generateFullModelMigration({dialect:options.dialect,onlyExistingTables:options.onlyExistingTables});if(!generated.isOk)return err(generated.error);const{dialect,statements,plan,sources}=generated.value,dir=options.dir??migrationDirectory(dialect);let groups=groupGeneratedStatements(statements),existing=[];try{existing=readdirSync(dir).filter((f)=>f.endsWith(".sql")).sort()}catch{}let unrebuildable=[];if(options.onlyExistingTables){const corpusTables=new Set(tablesDefinedByCorpus(dir,existing));if(corpusTables.size===0)return err(Error(`No CREATE TABLE statements found in ${dir}, so there is nothing to regenerate in place. Run \`buddy migrate:regenerate <dialect>\` without --only-existing-tables to write a corpus from your models.`));const emitted=new Set(createdTablesOf(statements).map((table)=>table.toLowerCase()));unrebuildable=[...corpusTables].filter((table)=>!emitted.has(table)).sort();groups=groups.map((group)=>({...group,statements:group.statements.filter((statement)=>{const table=statementTable(statement);return table?corpusTables.has(table.toLowerCase()):!1})})).filter((group)=>group.statements.length>0);if(groups.length===0)return err(Error(`None of the ${corpusTables.size} table(s) in ${dir} have a model behind them, so none can be regenerated. Declare the models, or publish the framework ones with \`buddy publish model <Name>\`.`))}const contents=new Map,contentOf=(file)=>{let content=contents.get(file);if(!content){try{content=describeMigrationContent(readFileSync(join(dir,file),"utf8"))}catch{content={types:[],droppedTypes:[],droppedIndexes:[],createdIndexes:[],dataTables:[],seedTables:[],createdTables:[],other:!0}}contents.set(file,content)}return content},namesNoTable=new Set(existing.filter((file)=>{try{return tablesOperatedOn(readFileSync(join(dir,file),"utf8")).length===0}catch{return!1}})),seedFiles=new Set(existing.filter((file)=>contentOf(file).seedTables.length>0)),seedRootedTables=options.replaceUnmarked&&!options.onlyExistingTables?[...seedFiles].flatMap((file)=>contentOf(file).createdTables):[],rootedTables=new Set(options.replaceUnmarked||options.onlyExistingTables?seedRootedTables:historicallyRootedTables(dir,existing)),outOfScopeByTable=new Set(migrationsOutsideCorpus(dir,existing,createdTablesOf(statements))),markerDeletable=(options.replaceUnmarked||options.onlyExistingTables?existing:existing.filter((file)=>isGeneratedMigration(dir,file))).filter((file)=>!isPackageMigration(file)),certainlyKept=existing.filter((file)=>!markerDeletable.includes(file)||seedFiles.has(file)||outOfScopeByTable.has(file)&&!namesNoTable.has(file)),emittedTypes=enumTypesCreatedBy(statements),keptIndexes=new Set(certainlyKept.flatMap((file)=>contentOf(file).createdIndexes)),obsolete=new Map;for(const file of namesNoTable){const keptSeedTables=new Set(certainlyKept.filter((kept)=>migrationOrdinal(kept)<migrationOrdinal(file)).flatMap((kept)=>contentOf(kept).seedTables)),reason=obsoleteMigrationReason(contentOf(file),{emittedTypes,keptIndexes,keptSeedTables});if(reason)obsolete.set(file,reason)}const outOfScope=new Set([...outOfScopeByTable].filter((file)=>!obsolete.has(file)&&!seedFiles.has(file))),removed=markerDeletable.filter((file)=>!seedFiles.has(file)).filter((file)=>{return!outOfScope.has(file)&&!migrationTouchesRootedTable(dir,file,rootedTables)}),preserved=existing.filter((file)=>!removed.includes(file)),preservedOutOfScope=preserved.filter((file)=>outOfScope.has(file)),preservedData=preserved.filter((file)=>seedFiles.has(file)),removedObsolete=removed.filter((file)=>obsolete.get(file)==="types"||obsolete.get(file)==="drop-index"),removedDataOnly=removed.filter((file)=>obsolete.get(file)==="data"),catchUp=[];for(const table of rootedTables){const create=groups.flatMap((group)=>group.statements).find((statement)=>statementTable(statement)===table&&/^\s*CREATE\s+TABLE\b/i.test(statement));if(!create)continue;const produced=columnsProducedByMigrations(dir,preserved,table);if(produced.size===0)continue;catchUp.push(...rootedTableCatchUpStatements(create,produced))}const writableGroups=groups.map((group)=>({...group,statements:group.statements.filter((statement)=>{const table=statementTable(statement);return!table||!rootedTables.has(table)})})).filter((group)=>group.statements.length>0).concat(catchUp.length>0?[{label:"alter-rooted-tables-columns",statements:catchUp}]:[]),writtenTables=new Set(createdTablesOf(writableGroups.flatMap((group)=>group.statements))),relocating=preservedData.filter((file)=>{const content=contentOf(file);return content.seedTables.some((table)=>writtenTables.has(table)&&!content.createdTables.includes(table))}),staying=preserved.filter((file)=>!relocating.includes(file)),historicalBoundary=existing.filter((file)=>!isGeneratedMigration(dir,file)&&!isPackageMigration(file)&&!relocating.includes(file)).reduce((max,file)=>Math.max(max,migrationOrdinal(file)),0),startAt=rootedTables.size>0?historicalBoundary+1:staying.filter((file)=>!isPackageMigration(file)).reduce((max,file)=>Math.max(max,migrationOrdinal(file)),0)+1,reservedOrdinals=new Set(staying.map(migrationOrdinal)),ordinals=allocateMigrationOrdinals(writableGroups.length+relocating.length,startAt,reservedOrdinals),files=writableGroups.map((group,index)=>({name:`${String(ordinals[index]).padStart(10,"0")}-${group.label}.sql`,statements:group.statements.length})),relocated=relocating.map((file,index)=>({from:file,to:`${String(ordinals[writableGroups.length+index]).padStart(10,"0")}-${file.replace(/^\d+[-_]/,"")}`})),corpus={dialect,models:sources.models.length,modelRoots:sources.roots,files,removed,removedObsolete,removedDataOnly,preserved,preservedOutOfScope,preservedData,relocated,unrebuildable,dir};if(options.dryRun)return ok(corpus);mkdirSync(dir,{recursive:!0});for(const file of removed)unlinkSync(join(dir,file));writableGroups.forEach((group,index)=>{const body=`${group.statements.map((s)=>s.trim().replace(/;\s*$/,"")).join(`;
32
+ `).execute(),names=(Array.isArray(raw)?raw:raw?.rows??[]).map((row)=>String(row.name??row.typname??"")).filter(Boolean);for(const name of names)try{await db.unsafe(`DROP TYPE IF EXISTS "${name}" CASCADE`).execute();log.debug(`Dropped orphaned enum type: ${name}`)}catch(error){log.warn(`Could not drop enum type ${name}: ${error instanceof Error?error.message:String(error)}`)}}catch(error){log.warn(`Could not list enum types to drop: ${error instanceof Error?error.message:String(error)}`)}}async function dropFrameworkTables(dialect){if(dialect==="mysql"||dialect==="vitess")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 0").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = OFF").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}for(const tableName of FRAMEWORK_TABLES)try{let dropSql;if(dialect==="postgres")dropSql=`DROP TABLE IF EXISTS "${tableName}" CASCADE`;else if(dialect==="mysql"||dialect==="vitess")dropSql=`DROP TABLE IF EXISTS \`${tableName}\``;else dropSql=`DROP TABLE IF EXISTS "${tableName}"`;log.info(`Dropping framework table: ${tableName}`);await db.unsafe(dropSql).execute();log.info(`Dropped framework table: ${tableName}`)}catch(error){const kind=classifyConnectionError(error);if(kind==="missing-database"||kind==="missing-role"||kind==="auth-failed"||kind==="server-unreachable"||kind==="timeout")throw error;log.warn(`Could not drop table ${tableName}: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="mysql"||dialect==="vitess")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 1").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = ON").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}}function frameworkRenamesForThisApp(){return applicableFrameworkRenames(declaredModelTables())}function describeRenames(renames){return renames.map((rename)=>`${rename.id}, ${rename.reference}`).join("; ")}export function catchUpSnapshotFrameworkRenames(options){configureQueryBuilder();const dialect=getQbDialect(),stored=readStoredMigrationPlan(dialect);if(!stored)return{changes:[]};const renames=frameworkRenamesForThisApp();if(renames.length===0)return{changes:[]};const{plan,changes}=applyFrameworkRenamesToPlan(stored,renames);if(changes.length===0)return{changes};if(options.persist){saveMigrationSnapshot(plan,{dialect});log.info(`[migration] Updated the model snapshot for framework renames (${describeRenames(renames)}): ${changes.join(", ")}`)}else log.debug(`[migration] Previewing against the snapshot with framework renames applied: ${changes.join(", ")}`);return{changes,plan}}export async function catchUpFrameworkRenames(options={}){await ensureDatabaseConfigLoaded();const dialect=toSqlIntrospectionDialect(getDialect());if(dialect==="other")return{applied:[],skipped:[]};const renames=frameworkRenamesForThisApp();if(renames.length===0)return{applied:[],skipped:[]};const runner=options.runner??(async(sql)=>{const result=await db.unsafe(sql).execute();return Array.isArray(result)?result:[]}),result=await applyFrameworkRenamesToDatabase(runner,dialect,renames);if(result.applied.length>0)log.info(`[migration] Applied framework renames to the database (${describeRenames(renames)}): ${result.applied.join(", ")}`);for(const skipped of result.skipped)log.warn(`[migration] Framework rename not applied: ${skipped}`);return result}function resolveGenerateOptions(options){const applyRenames=options.applyRenames??(process.env.STACKS_MIGRATE_NO_RENAME==="1"?!1:void 0),fromDb=options.fromDb??(process.env.STACKS_MIGRATE_FROM_DB==="1"?!0:void 0);return{applyRenames,fromDb}}export async function previewPendingMigrations(options={}){try{return await pendingMigrationOperations(options)}catch(error){log.debug(`[migration] preview failed: ${error instanceof Error?error.message:String(error)}`);return[]}}export async function pendingMigrationOperations(options={}){await ensureDatabaseConfigLoaded();configureQueryBuilder();const dialect=getDialect(),{modelsDir,skip,protectedTables}=prepareMigrationModelsDir();if(skip)return[];const{applyRenames,fromDb}=resolveGenerateOptions(options),qbDialect=getQbDialect(),result=await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,dryRun:!0,applyRenames,fromDb}),corrected=fromDb?void 0:catchUpSnapshotFrameworkRenames({persist:!1}).plan;if(corrected&&result.plan)result.operations=generateDiffOperations(corrected,result.plan,{applyRenames,dryRun:!0}).operations;const authOwned=new Set(AUTH_TABLES);let operations=(result.operations??[]).filter((op)=>!authOwned.has(op.table.toLowerCase()));if(protectedTables.length>0){const excluded=new Set(protectedTables.map((table)=>table.toLowerCase()));operations=operations.filter((op)=>!(op.kind==="drop_table"&&excluded.has(op.table.toLowerCase())))}if(!operations.some((op)=>op.kind==="drop_column"))return operations;return withoutManagedColumnDrops(operations,await frameworkManagedColumns())}function snapshotDirLabel(){return qbConfig?.snapshotDir||qbSnapshotDir()}function resolveSnapshotDir(){const label=snapshotDirLabel();return isAbsolute(label)?label:resolve(process.cwd(),label)}function snapshotPathFor(dialect){return join(resolveSnapshotDir(),`model-snapshot.${dialect}.json`)}function readStoredMigrationPlan(dialect){const snapshotPath=snapshotPathFor(dialect);if(!existsSync(snapshotPath))return;let parsed;try{parsed=JSON.parse(readFileSync(snapshotPath,"utf8"))}catch(error){throw Error(`The migration snapshot is not valid JSON: ${snapshotPath}. Repair or regenerate it before creating migrations.`,{cause:error})}const candidate=parsed&&typeof parsed==="object"&&"plan"in parsed?parsed.plan:parsed;if(!candidate||typeof candidate!=="object"||!Array.isArray(candidate.tables))throw TypeError(`The migration snapshot has an invalid structure: ${snapshotPath}. Repair or regenerate it before creating migrations.`);return candidate}export function preserveMigrationPlanTableOrder(next,previous){const orderedByPrevious=(values,previousValues,keyFor)=>{if(!previousValues)return[...values];const previousOrder=new Map(previousValues.map((value,index)=>[keyFor(value),index])),nextOrder=new Map(values.map((value,index)=>[keyFor(value),index])),previousCount=previousValues.length;return[...values].sort((left,right)=>{const leftKey=keyFor(left),rightKey=keyFor(right),leftOrder=previousOrder.get(leftKey)??previousCount+nextOrder.get(leftKey),rightOrder=previousOrder.get(rightKey)??previousCount+nextOrder.get(rightKey);return leftOrder-rightOrder})},previousTables=new Map(previous?.tables.map((table)=>[table.table,table])??[]),tables=next.tables.map((table)=>{const previousTable=previousTables.get(table.table),previousColumns=new Map(previousTable?.columns.map((column)=>[column.name,column])??[]);return{...table,columns:orderedByPrevious(table.columns.map((column)=>{if(next.dialect!=="sqlite"||!column.enumTypeName||previousColumns.get(column.name)?.enumTypeName)return column;const portableColumn={...column};delete portableColumn.enumTypeName;return portableColumn}),previousTable?.columns,(column)=>column.name),indexes:orderedByPrevious(table.indexes,previousTable?.indexes,(index)=>index.name)}});return{...next,tables:orderedByPrevious(tables,previous?.tables,(table)=>table.table)}}function detectSnapshotDialectMismatch(dialect){const qbDir=resolveSnapshotDir();let files;try{files=readdirSync(qbDir)}catch{return null}const snapshotFor=(d)=>`model-snapshot.${d}.json`;if(files.includes(snapshotFor(dialect)))return null;for(const f of files){const m=/^model-snapshot\.(\w+)\.json$/.exec(f);if(m?.[1]&&m[1]!==dialect)return m[1]}return null}export async function generateMigrations(options={}){await ensureDatabaseConfigLoaded();try{log.debug("Generating migrations...");configureQueryBuilder();const dialect=getDialect(),mismatch=detectSnapshotDialectMismatch(dialect),flatMigrationDir=join(process.cwd(),"database","migrations");if(mismatch&&migrationDirectory(dialect)===flatMigrationDir){const snapshotDir=snapshotDirLabel();return err(Error(`Refusing to generate migrations: resolved dialect "${dialect}" has no snapshot in ${snapshotDir}/, but "${mismatch}" does. DB_CONNECTION is likely unset or wrong (missing .env?) - generating now would write a full duplicate migration set in the wrong dialect. Set DB_CONNECTION=${mismatch} (or your intended dialect) and retry. To intentionally start a new dialect from scratch, remove ${snapshotDir}/model-snapshot.${mismatch}.json first.`))}catchUpSnapshotFrameworkRenames({persist:!0});const storedPlan=readStoredMigrationPlan(getQbDialect()),{modelsDir,skip,excludedTables,protectedTables}=prepareMigrationModelsDir();if(skip){trackIgnoredGeneratedMigrations(migrationDirectory());log.debug("No app/Models directory found; using committed framework migrations");return ok("Migrations generated")}const{applyRenames,fromDb}=resolveGenerateOptions(options);log.debug(`[migration] Generating migrations for dialect: ${dialect}, models: ${modelsDir}`);if(excludedTables.length>0)log.debug(`[migration] ${excludedTables.length} framework default model(s) out of scope because app/Models defines this app's schema. Enable database.models.includeFrameworkDefaults (or STACKS_INCLUDE_FRAMEWORK_MODELS=1) to generate them too.`);const qbDialect=getQbDialect(),result=await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,dryRun:!0,applyRenames,fromDb});let sqlStatements=result.sqlStatements??[];if(dialect==="sqlite")sqlStatements=inlineSqliteAddedColumnReferences(sqlStatements,result.plan);{const runtimeOwned=withoutRuntimeOwnedTableSql(sqlStatements);if(runtimeOwned.removed.length>0)log.debug(`[migration] Left out ${runtimeOwned.removed.length} generated statement(s) for tables the auth layer creates itself`);sqlStatements=runtimeOwned.statements}if(result.hasChanges&&sqlStatements.length>0){const filtered=withoutManagedColumnDropSql(sqlStatements,await frameworkManagedColumns(),result.operations??[]);if(filtered.removed.length>0)log.debug(`[migration] Skipped ${filtered.removed.length} generated drop(s) of framework-managed column(s) (stacksjs/stacks#2075)`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0&&protectedTables.length>0){const filtered=withoutProtectedTableDropSql(sqlStatements,protectedTables,result.operations??[]);if(filtered.removed.length>0)log.info(`[migration] Left ${filtered.removed.length} framework-owned table(s) in place rather than dropping them. They are no longer generated because app/Models defines this app's schema; the tables and their data are untouched. Set database.models.includeFrameworkDefaults to keep generating them (stacksjs/stacks#2220).`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0){const dangling=findDanglingTypeReferences(sqlStatements);if(dangling.length>0){const created=createMissingEnumTypes(dangling,result.plan);if(created.statements.length>0){sqlStatements=[...created.statements,...sqlStatements];log.debug(`[migration] Created ${created.statements.length} enum type(s) an ALTER needed: ${created.defined.join(", ")}`)}const unresolved=dangling.filter((name)=>!created.defined.includes(name));if(unresolved.length>0){const before=sqlStatements.length;sqlStatements=sqlStatements.filter((statement)=>!referencesUndefinedType(statement,unresolved));log.warn(`[migration] Skipped ${before-sqlStatements.length} generated statement(s) referencing enum type(s) nothing creates and no model defines values for (${unresolved.slice(0,3).join(", ")}${unresolved.length>3?", \u2026":""}).`)}}}if(result.hasChanges&&sqlStatements.length>0&&!shadowDropsAllowed()){const shadowed=resolveModelSources()?.shadowed??[],drops=findShadowedColumnDrops(sqlStatements,shadowed);if(drops.length>0)return err(Error(shadowedDropMessage(drops)))}if(result.hasChanges){const written=persistGeneratedMigrations(sqlStatements,storedPlan);if(written>0)log.success(`Generated ${written} migration file${written===1?"":"s"}`);else log.debug("Migration generation produced no new files (already up to date)")}else log.debug("No changes detected");trackIgnoredGeneratedMigrations(migrationDirectory());const stablePlan=preserveMigrationPlanTableOrder(result.plan,storedPlan);if(!storedPlan||JSON.stringify(stablePlan)!==JSON.stringify(storedPlan))saveMigrationSnapshot(stablePlan,{dialect:getQbDialect()});else log.debug("Model snapshot unchanged");return ok("Migrations generated")}catch(error){const because=error instanceof Error?error.message:String(error);return err(handleError(`Migration generation failed: ${because}`,error))}}export function referencesUndefinedType(statement,dangling){return dangling.some((name)=>statement.includes(`"${name}"`))}export function findDanglingTypeReferences(statements){const defined=new Set,referenced=new Set;for(const statement of statements){for(const match of statement.matchAll(/CREATE\s+TYPE\s+"([^"]+)"/gi))defined.add(match[1]);for(const match of statement.matchAll(/\bTYPE\s+"([^"]+)"/gi))referenced.add(match[1])}return[...referenced].filter((name)=>!defined.has(name)).sort()}export function createMissingEnumTypes(dangling,plan){if(dangling.length===0)return{statements:[],defined:[]};const values=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){if(!column.name||!column.enumValues?.length)continue;values.set(`${table.table}_${column.name}_type`,column.enumValues)}}const statements=[],defined=[];for(const name of dangling){const members=values.get(name);if(!members?.length)continue;const literals=members.map((member)=>`'${String(member).replaceAll("'","''")}'`).join(", "),guarded=guardPostgresEnumTypes(`CREATE TYPE "${name}" AS ENUM (${literals})`);statements.push(guarded.endsWith(";")?guarded:`${guarded};`);defined.push(name)}return{statements,defined}}export function inlineSqliteAddedColumnReferences(statements,plan){const references=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){const reference=column.references;if(!column.name||!reference?.table||!reference.column)continue;references.set(`${table.table}.${column.name}`,{table:reference.table,column:reference.column})}}if(references.size===0)return statements;return statements.map((statement)=>{if(/\bREFERENCES\b/i.test(statement))return statement;const match=statement.match(/^(\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+COLUMN\s+["`]?(\w+)["`]?\s+)([\s\S]*?)(;?\s*)$/i),reference=match?.[2]&&match[3]?references.get(`${match[2]}.${match[3]}`):void 0;if(!match||!reference)return statement;return`${match[1]}${match[4].trimEnd()} REFERENCES "${reference.table}"("${reference.column}")${match[5]}`})}export{GENERATED_MIGRATION_MARKER,isGeneratedMigration}from"./generated-migration-marker";export function tablesOperatedOn(sql){const tables=new Set;for(const statement of sqlStatementsOf(sql)){const stmt=statement.trim(),direct=stmt.match(/^(?:CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?|ALTER\s+TABLE|DROP\s+TABLE(?:\s+IF\s+EXISTS)?|TRUNCATE\s+TABLE)\s+["'`]?(\w+)["'`]?/i);if(direct?.[1]){tables.add(direct[1].toLowerCase());continue}const index=stmt.match(/^CREATE\s+(?:UNIQUE\s+)?INDEX(?:\s+IF\s+NOT\s+EXISTS)?\s+\S+\s+ON\s+["'`]?(\w+)["'`]?/i);if(index?.[1])tables.add(index[1].toLowerCase())}return[...tables]}const IDENTIFIER=SQL_IDENTIFIER,NEUTRAL_STATEMENT=/^(?:BEGIN|COMMIT|END|PRAGMA|SET|SELECT\s+1\s*$|COMMENT\s+ON)\b/i;export function describeMigrationContent(sql){const content={types:[],droppedTypes:[],droppedIndexes:[],createdIndexes:[],dataTables:[],seedTables:[],createdTables:[],other:!1},add=(list,name)=>{const lower=name?.toLowerCase();if(lower&&!list.includes(lower))list.push(lower)},visit=(statement)=>{const stmt=statement.trim().replace(/;\s*$/,"");if(!stmt||NEUTRAL_STATEMENT.test(stmt))return;const guarded=guardedBlockStatements(stmt);if(guarded){for(const inner of guarded)visit(inner);return}let m=new RegExp(String.raw`^CREATE\s+TYPE\s+${IDENTIFIER}\s+AS\s+ENUM\b`,"i").exec(stmt)??new RegExp(String.raw`^ALTER\s+TYPE\s+${IDENTIFIER}\s+ADD\s+VALUE\b`,"i").exec(stmt);if(m){add(content.types,m[1]);return}m=new RegExp(String.raw`^DROP\s+TYPE\s+(?:IF\s+EXISTS\s+)?${IDENTIFIER}`,"i").exec(stmt);if(m){add(content.droppedTypes,m[1]);return}m=new RegExp(String.raw`^DROP\s+INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+EXISTS\s+)?${IDENTIFIER}`,"i").exec(stmt);if(m){add(content.droppedIndexes,m[1]);return}m=new RegExp(String.raw`^(?:DELETE\s+FROM|UPDATE|TRUNCATE(?:\s+TABLE)?)\s+(?:ONLY\s+)?${IDENTIFIER}`,"i").exec(stmt);if(m){add(content.dataTables,m[1]);return}m=new RegExp(String.raw`^COPY\s+${IDENTIFIER}[\s\S]*\bFROM\b`,"i").exec(stmt);if(m){add(content.seedTables,m[1]);return}m=new RegExp(String.raw`^INSERT\s+(?:OR\s+\w+\s+)?INTO\s+${IDENTIFIER}`,"i").exec(stmt);if(m){if(/\bSELECT\b[\s\S]*\bFROM\b/i.test(stmt)&&!/\bVALUES\b/i.test(stmt))content.other=!0;else add(content.seedTables,m[1]);return}const shape=indexShape(stmt);if(shape){add(content.createdIndexes,shape.name);content.other=!0;return}const created=createdTableName(stmt);if(created)add(content.createdTables,created);content.other=!0};for(const statement of sqlStatementsOf(sql))visit(statement);return content}export function obsoleteMigrationReason(content,context){if(content.other||content.seedTables.length>0)return;const hasTypes=content.types.length>0||content.droppedTypes.length>0,hasDrops=content.droppedIndexes.length>0,hasData=content.dataTables.length>0;if([hasTypes,hasDrops,hasData].filter(Boolean).length!==1)return;if(hasTypes)return content.types.every((type)=>context.emittedTypes.has(type))?"types":void 0;if(hasDrops)return content.droppedIndexes.every((index)=>!context.keptIndexes.has(index))?"drop-index":void 0;return content.dataTables.every((table)=>!context.keptSeedTables.has(table))?"data":void 0}export function columnsDefinedByCreate(statement){const body=statement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?\w+["'`]?\s*\(([\s\S]*)\)\s*;?\s*$/i)?.[1];if(!body)return[];const parts=[];let depth=0,current="";for(const char of body){if(char==="(")depth++;if(char===")")depth--;if(char===","&&depth===0){parts.push(current);current="";continue}current+=char}parts.push(current);const constraint=/^\s*(?:PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK|CONSTRAINT|KEY|INDEX)\b/i;return parts.map((part)=>part.trim()).filter((part)=>part&&!constraint.test(part)).flatMap((part)=>part.match(/^["'`]?(\w+)["'`]?/)?.[1]??[])}export function columnsProducedByMigrations(dir,files,table){const columns=new Set,target=table.toLowerCase();for(const file of[...files].sort()){let content;try{content=readFileSync(join(dir,file),"utf8")}catch{continue}for(const statement of sqlStatementsOf(content)){const stmt=statement.trim();if(stmt.match(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i)?.[1]?.toLowerCase()===target){for(const column of columnsDefinedByCreate(stmt))columns.add(column.toLowerCase());continue}const added=stmt.match(/^ALTER\s+TABLE\s+["'`]?(\w+)["'`]?\s+ADD\s+(?:COLUMN\s+)?["'`]?(\w+)["'`]?/i);if(added?.[1]?.toLowerCase()===target&&added[2])columns.add(added[2].toLowerCase());const dropped=stmt.match(/^ALTER\s+TABLE\s+["'`]?(\w+)["'`]?\s+DROP\s+(?:COLUMN\s+)?["'`]?(\w+)["'`]?/i);if(dropped?.[1]?.toLowerCase()===target&&dropped[2])columns.delete(dropped[2].toLowerCase());const rebuilt=stmt.match(/^ALTER\s+TABLE\s+["'`]?_qb_tmp_(\w+)["'`]?\s+RENAME\s+TO\s+["'`]?(\w+)["'`]?/i);if(rebuilt?.[2]?.toLowerCase()===target){const temp=sqlStatementsOf(content).find((s)=>new RegExp(`^CREATE\\s+TABLE\\s+["'\`]?_qb_tmp_${rebuilt[1]}["'\`]?`,"i").test(s.trim()));if(temp){columns.clear();for(const column of columnsDefinedByCreate(temp))columns.add(column.toLowerCase())}}}}return columns}export function rootedTableCatchUpStatements(createStatement,existingColumns){const table=createStatement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i)?.[1];if(!table)return[];const body=createStatement.match(/\(([\s\S]*)\)\s*;?\s*$/)?.[1];if(!body)return[];const definitions=new Map;let depth=0,current="";const parts=[];for(const char of body){if(char==="(")depth++;if(char===")")depth--;if(char===","&&depth===0){parts.push(current);current="";continue}current+=char}parts.push(current);const constraint=/^\s*(?:PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK|CONSTRAINT|KEY|INDEX)\b/i;for(const raw of parts){const part=raw.trim();if(!part||constraint.test(part))continue;const name=part.match(/^["'`]?(\w+)["'`]?/)?.[1];if(name)definitions.set(name.toLowerCase(),part)}const statements=[];for(const[name,definition]of definitions){if(existingColumns.has(name))continue;if(/\b(?:PRIMARY\s+KEY|UNIQUE|AUTOINCREMENT)\b/i.test(definition))continue;const nullable=/\bNOT\s+NULL\b/i.test(definition)&&!/\bDEFAULT\b/i.test(definition)?definition.replace(/\s*\bNOT\s+NULL\b/i,""):definition;statements.push(`ALTER TABLE "${table}" ADD COLUMN ${nullable.trim()}`)}return statements}export function createdTablesOf(statements){const tables=new Set;for(const statement of statements){const match=statement.trim().match(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i);if(match?.[1])tables.add(match[1].toLowerCase())}return[...tables]}export function historicallyRootedTables(dir,files){const tables=new Set;for(const file of files){if(isGeneratedMigration(dir,file))continue;try{for(const statement of sqlStatementsOf(readFileSync(join(dir,file),"utf8"))){const match=statement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i);if(match?.[1])tables.add(match[1].toLowerCase())}}catch{}}return[...tables]}export function tablesDefinedByCorpus(dir,files){const tables=new Set;for(const file of files)try{for(const statement of sqlStatementsOf(readFileSync(join(dir,file),"utf8"))){const match=statement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i);if(match?.[1])tables.add(match[1].toLowerCase())}}catch{}return[...tables]}export function migrationTouchesRootedTable(dir,file,rootedTables){try{return tablesOperatedOn(readFileSync(join(dir,file),"utf8")).some((table)=>rootedTables.has(table.toLowerCase()))}catch{return!1}}export function allocateMigrationOrdinals(count,startAt,reserved){const ordinals=[];let cursor=startAt;while(ordinals.length<count){if(!reserved.has(cursor))ordinals.push(cursor);cursor+=1}return ordinals}export function migrationsOutsideCorpus(dir,files,corpusTables){const rebuilt=new Set(corpusTables.map((table)=>table.toLowerCase()));return files.filter((file)=>{let contents;try{contents=readFileSync(join(dir,file),"utf8")}catch{return!0}const touched=tablesOperatedOn(contents);if(touched.length===0)return!0;return touched.some((table)=>!rebuilt.has(table))})}function migrationOrdinal(file){const match=file.match(/^(\d+)/);return match?Number(match[1]):0}export async function generateFullModelMigration(options={}){await ensureDatabaseConfigLoaded();try{const dialect=options.dialect??getQbDialect();let requestedVitessSharded;if(dialect==="vitess")try{const{config,overridesReady}=await import("@stacksjs/config");await overridesReady;requestedVitessSharded=isVitessSharded(config?.database?.connections?.vitess?.sharded)}catch{requestedVitessSharded=isVitessSharded(dbConfig.connections.vitess.sharded)}configureQueryBuilder(dialect,requestedVitessSharded);const sources=resolveModelSources({forceStage:!0,...options.onlyExistingTables?{includeFrameworkDefaults:!0}:{}});if(!sources)return err(Error("No models found. Define models in app/Models, or ensure the framework defaults at storage/framework/defaults/app/Models are present."));try{writeFileSync(join(sources.dir,`.qb-migrations.${dialect}.json`),JSON.stringify({plan:{dialect,tables:[]}}))}catch{}const snapshotPath=join(resolveSnapshotDir(),`model-snapshot.${dialect}.json`),parkedSnapshot=`${snapshotPath}.regenerating`;let snapshotParked=!1;if(existsSync(snapshotPath))try{renameSync(snapshotPath,parkedSnapshot);snapshotParked=!0}catch{}let result;try{result=await qbGenerateMigration(sources.dir,{dialect,vitessSharded:requestedVitessSharded,dryRun:!0,full:!0})}finally{if(snapshotParked)try{renameSync(parkedSnapshot,snapshotPath)}catch{}}const statements=result.sqlStatements??[];if(statements.length===0)return err(Error(`The generator produced no SQL for dialect "${dialect}".`));const dangling=findDanglingTypeReferences(statements);if(dangling.length>0)return err(Error(`The generator emitted ${dangling.length} reference(s) to enum type(s) it never creates: ${dangling.slice(0,5).join(", ")}${dangling.length>5?`, +${dangling.length-5} more`:""}. Writing this corpus would fail partway through a migration. This is a bug in the migration generator, not in your models.`));return ok({dialect,statements,plan:result.plan,sources})}catch(error){return err(handleError("Migration generation failed",error))}}export async function regenerateMigrationCorpus(options={}){try{const generated=await generateFullModelMigration({dialect:options.dialect,onlyExistingTables:options.onlyExistingTables});if(!generated.isOk)return err(generated.error);const{dialect,statements,plan,sources}=generated.value,dir=options.dir??migrationDirectory(dialect);let groups=groupGeneratedStatements(statements),existing=[];try{existing=readdirSync(dir).filter((f)=>f.endsWith(".sql")).sort()}catch{}let unrebuildable=[];if(options.onlyExistingTables){const corpusTables=new Set(tablesDefinedByCorpus(dir,existing));if(corpusTables.size===0)return err(Error(`No CREATE TABLE statements found in ${dir}, so there is nothing to regenerate in place. Run \`buddy migrate:regenerate <dialect>\` without --only-existing-tables to write a corpus from your models.`));const emitted=new Set(createdTablesOf(statements).map((table)=>table.toLowerCase()));unrebuildable=[...corpusTables].filter((table)=>!emitted.has(table)).sort();groups=groups.map((group)=>({...group,statements:group.statements.filter((statement)=>{const table=statementTable(statement);return table?corpusTables.has(table.toLowerCase()):!1})})).filter((group)=>group.statements.length>0);if(groups.length===0)return err(Error(`None of the ${corpusTables.size} table(s) in ${dir} have a model behind them, so none can be regenerated. Declare the models, or publish the framework ones with \`buddy publish model <Name>\`.`))}const contents=new Map,contentOf=(file)=>{let content=contents.get(file);if(!content){try{content=describeMigrationContent(readFileSync(join(dir,file),"utf8"))}catch{content={types:[],droppedTypes:[],droppedIndexes:[],createdIndexes:[],dataTables:[],seedTables:[],createdTables:[],other:!0}}contents.set(file,content)}return content},namesNoTable=new Set(existing.filter((file)=>{try{return tablesOperatedOn(readFileSync(join(dir,file),"utf8")).length===0}catch{return!1}})),seedFiles=new Set(existing.filter((file)=>contentOf(file).seedTables.length>0)),seedRootedTables=options.replaceUnmarked&&!options.onlyExistingTables?[...seedFiles].flatMap((file)=>contentOf(file).createdTables):[],rootedTables=new Set(options.replaceUnmarked||options.onlyExistingTables?seedRootedTables:historicallyRootedTables(dir,existing)),outOfScopeByTable=new Set(migrationsOutsideCorpus(dir,existing,createdTablesOf(statements))),markerDeletable=(options.replaceUnmarked||options.onlyExistingTables?existing:existing.filter((file)=>isGeneratedMigration(dir,file))).filter((file)=>!isPackageMigration(file)),certainlyKept=existing.filter((file)=>!markerDeletable.includes(file)||seedFiles.has(file)||outOfScopeByTable.has(file)&&!namesNoTable.has(file)),emittedTypes=enumTypesCreatedBy(statements),keptIndexes=new Set(certainlyKept.flatMap((file)=>contentOf(file).createdIndexes)),obsolete=new Map;for(const file of namesNoTable){const keptSeedTables=new Set(certainlyKept.filter((kept)=>migrationOrdinal(kept)<migrationOrdinal(file)).flatMap((kept)=>contentOf(kept).seedTables)),reason=obsoleteMigrationReason(contentOf(file),{emittedTypes,keptIndexes,keptSeedTables});if(reason)obsolete.set(file,reason)}const outOfScope=new Set([...outOfScopeByTable].filter((file)=>!obsolete.has(file)&&!seedFiles.has(file))),removed=markerDeletable.filter((file)=>!seedFiles.has(file)).filter((file)=>{return!outOfScope.has(file)&&!migrationTouchesRootedTable(dir,file,rootedTables)}),preserved=existing.filter((file)=>!removed.includes(file)),preservedOutOfScope=preserved.filter((file)=>outOfScope.has(file)),preservedData=preserved.filter((file)=>seedFiles.has(file)),removedObsolete=removed.filter((file)=>obsolete.get(file)==="types"||obsolete.get(file)==="drop-index"),removedDataOnly=removed.filter((file)=>obsolete.get(file)==="data"),catchUp=[];for(const table of rootedTables){const create=groups.flatMap((group)=>group.statements).find((statement)=>statementTable(statement)===table&&/^\s*CREATE\s+TABLE\b/i.test(statement));if(!create)continue;const produced=columnsProducedByMigrations(dir,preserved,table);if(produced.size===0)continue;catchUp.push(...rootedTableCatchUpStatements(create,produced))}const writableGroups=groups.map((group)=>({...group,statements:group.statements.filter((statement)=>{const table=statementTable(statement);return!table||!rootedTables.has(table)})})).filter((group)=>group.statements.length>0).concat(catchUp.length>0?[{label:"alter-rooted-tables-columns",statements:catchUp}]:[]),writtenTables=new Set(createdTablesOf(writableGroups.flatMap((group)=>group.statements))),relocating=preservedData.filter((file)=>{const content=contentOf(file);return content.seedTables.some((table)=>writtenTables.has(table)&&!content.createdTables.includes(table))}),staying=preserved.filter((file)=>!relocating.includes(file)),historicalBoundary=existing.filter((file)=>!isGeneratedMigration(dir,file)&&!isPackageMigration(file)&&!relocating.includes(file)).reduce((max,file)=>Math.max(max,migrationOrdinal(file)),0),startAt=rootedTables.size>0?historicalBoundary+1:staying.filter((file)=>!isPackageMigration(file)).reduce((max,file)=>Math.max(max,migrationOrdinal(file)),0)+1,reservedOrdinals=new Set(staying.map(migrationOrdinal)),ordinals=allocateMigrationOrdinals(writableGroups.length+relocating.length,startAt,reservedOrdinals),files=writableGroups.map((group,index)=>({name:`${String(ordinals[index]).padStart(10,"0")}-${group.label}.sql`,statements:group.statements.length})),relocated=relocating.map((file,index)=>({from:file,to:`${String(ordinals[writableGroups.length+index]).padStart(10,"0")}-${file.replace(/^\d+[-_]/,"")}`})),corpus={dialect,models:sources.models.length,modelRoots:sources.roots,files,removed,removedObsolete,removedDataOnly,preserved,preservedOutOfScope,preservedData,relocated,unrebuildable,dir};if(options.dryRun)return ok(corpus);mkdirSync(dir,{recursive:!0});for(const file of removed)unlinkSync(join(dir,file));writableGroups.forEach((group,index)=>{const body=`${group.statements.map((s)=>s.trim().replace(/;\s*$/,"")).join(`;
33
33
  `)};
34
34
  `,content=`${GENERATED_MIGRATION_MARKER}
35
35
  ${body}`;writeFileSync(join(dir,files[index].name),(options.dialect??getDialect())==="postgres"?normalizePostgresMigrationSql(content):content)});for(const{from,to}of relocated)renameSync(join(dir,from),join(dir,to));trackIgnoredGeneratedMigrations(dir);saveMigrationSnapshot(plan,{dialect});return ok(corpus)}catch(error){return err(handleError("Migration regeneration failed",error))}}export function createdTableName(statement){return/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`[]?([\w$]+)["'`\]]?/i.exec(statement)?.[1]?.toLowerCase()}export{indexShape};export function droppedIndexName(statement){return/^\s*DROP\s+INDEX\s+(?:IF\s+EXISTS\s+)?(?:["'`[]?[\w$]+["'`\]]?\.)?["'`[]?([\w$]+)["'`\]]?/i.exec(statement)?.[1]?.toLowerCase()}export function indexCommittedMigrations(fileContents){const createdTables=new Set,indexes=new Map;for(const contents of fileContents)for(const statement of sqlStatementsOf(contents)){const table=createdTableName(statement);if(table){createdTables.add(table);continue}const shape=indexShape(statement);if(shape){if(!indexes.has(shape.name))indexes.set(shape.name,shape.key);continue}const dropped=droppedIndexName(statement);if(dropped)indexes.delete(dropped)}return{sql:normalizeSqlForComparison(fileContents.join(`
package/dist/seeder.d.ts CHANGED
@@ -36,6 +36,16 @@ export declare function parentTable(parent: string): string;
36
36
  * foreign keys agree with one another.
37
37
  */
38
38
  export declare function chooseRelations(pools: RelationPool[], count: number): Record<string, unknown>[];
39
+ /**
40
+ * Make `record` distinct from what the earlier rows already used.
41
+ *
42
+ * Regenerating first, and only then disambiguating, so seeded data keeps
43
+ * looking like the factory meant it to: a retry usually finds a free value, and
44
+ * the suffix is what stops an exhausted pool from failing the whole model.
45
+ * A null is never disambiguated - every engine here allows repeated nulls in a
46
+ * unique index, which is what makes a nullable unique column usable at all.
47
+ */
48
+ export declare function claimUniqueValues(record: Record<string, unknown>, columns: readonly string[], used: Map<string, Set<unknown>>, suffix: number): boolean;
39
49
  /**
40
50
  * The column sets a model declares unique together, in their database spelling.
41
51
  *
package/dist/seeder.js CHANGED
@@ -1 +1 @@
1
- import{existsSync,readdirSync}from"node:fs";import{extname,relative}from"node:path";import{pathToFileURL}from"node:url";import{log}from"@stacksjs/logging";import{db,ensureDatabaseConfigLoaded}from"./utils";import{faker}from"@stacksjs/faker";import{path}from"@stacksjs/path";import{hashMake}from"@stacksjs/security";import{fs}from"@stacksjs/storage";export class Seeder{static order=0;static tags=[]}const SEEDER_EXTENSIONS=new Set([".js",".mjs",".ts"]);function applicationSeederFiles(directory){if(!existsSync(directory))return[];const files=[],visit=(current)=>{const entries=readdirSync(current,{withFileTypes:!0}).sort((a,b)=>a.name.localeCompare(b.name));for(const entry of entries){if(entry.name.startsWith("."))continue;const file=`${current}/${entry.name}`;if(entry.isDirectory()){visit(file);continue}if(!entry.isFile()||entry.name.endsWith(".d.ts")||!SEEDER_EXTENSIONS.has(extname(entry.name)))continue;files.push(file)}};visit(directory);return files}export async function runApplicationSeeders(config={}){const startTime=Date.now(),directory=config.directory||path.userDatabasePath("seeders"),verbose=config.verbose??!0,results=[],loaded=[];for(const file of applicationSeederFiles(directory)){const displayFile=relative(directory,file),fallbackName=displayFile.replace(/\.(?:m?js|ts)$/,"");try{const SeederClass=(await import(pathToFileURL(file).href)).default,order=typeof SeederClass?.order==="number"?SeederClass.order:0,tags=Array.isArray(SeederClass?.tags)?SeederClass.tags.map(String):[];loaded.push({file,displayFile,name:SeederClass?.name||fallbackName,order,tags,SeederClass})}catch(error){loaded.push({file,displayFile,name:fallbackName,order:0,tags:[],loadError:error})}}const only=config.only?new Set(config.only):void 0,except=config.except?new Set(config.except):void 0,tags=config.tags?.length?new Set(config.tags):void 0,selected=loaded.filter((entry)=>{if(only&&!only.has(entry.name))return!1;if(tags&&!entry.tags.some((tag)=>tags.has(tag)))return!1;return!except?.has(entry.name)});selected.sort((a,b)=>a.order-b.order);for(const entry of selected){const startedAt=Date.now(),{displayFile}=entry;let seeder=entry.name;try{if(entry.loadError)throw entry.loadError;const SeederClass=entry.SeederClass;if(typeof SeederClass!=="function")throw TypeError("The default export must be a Seeder class.");const instance=new SeederClass;if(!(instance instanceof Seeder))throw TypeError("The default export must extend Seeder from @stacksjs/database.");seeder=SeederClass.name||seeder;if(verbose)log.info(`Running application seeder ${seeder}...`);await instance.run();results.push({seeder,file:displayFile,success:!0,duration:Date.now()-startedAt})}catch(error){const message=error instanceof Error?error.message:String(error);if(verbose)log.error(`Application seeder ${seeder} failed: ${message}`);results.push({seeder,file:displayFile,success:!1,error:message,duration:Date.now()-startedAt})}}return{total:results.length,successful:results.filter((result)=>result.success).length,failed:results.filter((result)=>!result.success).length,results,duration:Date.now()-startTime}}export function defaultModelsPath(subpath){return path.frameworkPath(`defaults/app/Models/${subpath||""}`)}export const PROTECTED_MODELS=Object.freeze(["OauthClient","OauthAccessToken","OauthRefreshToken","PersonalAccessToken"]),ACCOUNT_MODELS=Object.freeze(["User","Team","Customer"]);export function isAccountModel(name){return ACCOUNT_MODELS.includes(name)}export function isProtectedModel(name){return PROTECTED_MODELS.includes(name)}function snakeCase(str){return str.replace(/([A-Z]+)([A-Z][a-z])/g,"$1_$2").replace(/([a-z\d])([A-Z])/g,"$1_$2").toLowerCase()}async function loadModelsFromDir(modelsDir,recursive=!1,includeNonSeeding=!1){const models=[];if(!fs.existsSync(modelsDir))return models;const entries=fs.readdirSync(modelsDir,{withFileTypes:!0});for(const entry of entries){const fullPath=path.join(modelsDir,entry.name);if(entry.isDirectory()&&recursive){const subModels=await loadModelsFromDir(fullPath,!0,includeNonSeeding);models.push(...subModels);continue}if(!entry.name.endsWith(".ts")||entry.name.startsWith("index")||entry.name.startsWith("_"))continue;try{const module=await import(fullPath),modelDef=module.default||module;if(!modelDef)continue;const useSeeder=modelDef.traits?.useSeeder??modelDef.traits?.seedable;if(!useSeeder&&!includeNonSeeding)continue;const seedable=Boolean(useSeeder);let count=10,fixtures=[];if(typeof useSeeder==="object"&&"count"in useSeeder){const opts=useSeeder;count=opts.count;fixtures=opts.fixtures??[]}const modelName=modelDef.name||entry.name.replace(".ts",""),tableName=modelDef.table||snakeCase(modelName)+"s";models.push({name:modelName,table:tableName,count:Math.max(count,fixtures.length),fixtures,attributes:modelDef.attributes||{},model:modelDef,filePath:fullPath,seedable})}catch(err){log.error(`Failed to load model ${entry.name}:`,err)}}return models}async function loadAllModels(userModelsDir,verbose=!1,includeDefaults=!1){const defaultDir=defaultModelsPath(),userModels=await loadModelsFromDir(userModelsDir,!1,!0);if(userModels.length>0&&!includeDefaults)return userModels.filter((model)=>model.seedable);const defaultModels=await loadModelsFromDir(defaultDir,!0),modelMap=new Map;for(const model of defaultModels)modelMap.set(model.name,model);for(const model of userModels){if(modelMap.has(model.name)&&verbose)log.info(model.seedable?` User model "${model.name}" overrides default`:` User model "${model.name}" overrides default and opts out of the model pass`);modelMap.set(model.name,model)}return Array.from(modelMap.values()).filter((model)=>model.seedable)}async function loadModels(modelsDir){return loadModelsFromDir(modelsDir,!1)}function isPasswordField(fieldName,attr){const lowerName=fieldName.toLowerCase();if(lowerName==="password"||lowerName.endsWith("_password")||lowerName.endsWith("password"))return!0;if(attr.hidden===!0&&lowerName.includes("pass"))return!0;return!1}async function generateRecord(attributes,modelName,report=!1){const record={},generated={};for(const[fieldName,attr]of Object.entries(attributes)){const columnName=snakeCase(fieldName);let value;if(attr.factory&&typeof attr.factory==="function")try{value=attr.factory(faker,generated)}catch(err){const errorMsg=err instanceof Error?err.message:String(err);if(report)log.warn(` Factory failed for ${modelName}.${fieldName}: ${errorMsg} - seeding the default instead.`);if(attr.default!==void 0)value=attr.default;else value=inferDefaultValue(fieldName)}else if(attr.default!==void 0)value=attr.default;else continue;generated[fieldName]=value;if(isPasswordField(fieldName,attr)&&typeof value==="string")try{value=await hashMake(value,{algorithm:"bcrypt"})}catch(err){const errorMsg=err instanceof Error?err.message:String(err);log.warn(` Failed to hash password for ${modelName}.${fieldName}: ${errorMsg}`)}record[columnName]=value}return record}function inferDefaultValue(fieldName){const lowerName=fieldName.toLowerCase();if(lowerName.startsWith("is")||lowerName.startsWith("has")||lowerName.endsWith("able"))return!1;if(lowerName.includes("count")||lowerName.includes("amount")||lowerName.includes("quantity"))return 0;if(lowerName.includes("url")||lowerName.includes("link"))return"https://example.com";if(lowerName.includes("email"))return faker.internet.email();if(lowerName.includes("name"))return faker.person.fullName();return null}function fixtureToColumns(fixture){const out={};for(const[key,value]of Object.entries(fixture))out[snakeCase(key)]=value;return out}async function existingRows(table){try{return await db.selectFrom(table).selectAll().limit(500).execute()}catch{return[]}}const modelTables=new Map;export function registerModelTables(models){modelTables.clear();for(const model of models)modelTables.set(model.name,model.table)}export function parentTable(parent){return modelTables.get(parent)??`${snakeCase(parent)}s`}async function relationPools(model,options={}){const parents=parentRelations(model);if(parents.length===0)return[];const pools=[];for(const relation of parents){const{model:parent,column}=relation;if(model.attributes[parent])continue;const rows=isAccountModel(parent)&&!options.attachAccounts?seededAccountRows.get(parent)??[]:await existingRows(parentTable(parent));if(rows.length>0){pools.push({column,rows});continue}if(isAccountModel(parent)&&!options.attachAccounts){const existing=(await existingRows(parentTable(parent))).length;log.info(existing>0?` ${model.name}.${column} left empty: no ${parent} rows were seeded in this run, and the ones already in ${parentTable(parent)} may be real accounts. Pass --attach-accounts if this database is a scratch copy.`:` ${model.name}.${column} left empty: there are no ${parent} rows to point it at.`)}}return pools}const seededAccountRows=new Map;async function rememberSeededAccounts(model,highestIdBefore,inserted){if(inserted===0)return;if(highestIdBefore===void 0){log.info(` Could not tell which ${model.name} rows this run seeded (no readable integer id), so no seeded row will point at them.`);return}try{let query=db.selectFrom(model.table).selectAll();if(highestIdBefore!==null)query=query.where("id",">",highestIdBefore);const rows=await query.orderBy("id","asc").limit(inserted).execute();seededAccountRows.set(model.name,[...seededAccountRows.get(model.name)??[],...rows])}catch(error){log.info(` Could not read back the ${model.name} rows just seeded: ${error instanceof Error?error.message:String(error)}`)}}async function highestId(table){const row=await db.selectFrom(table).select(["id"]).orderBy("id","desc").limit(1).executeTakeFirst(),id=Number(row?.id);return row&&Number.isFinite(id)?id:null}export function chooseRelations(pools,count){if(pools.length===0)return[];const wanted=new Set(pools.map((pool)=>pool.column)),specificity=(pool)=>{const sample=pool.rows[0]??{};return[...wanted].filter((column)=>column!==pool.column&&(column in sample)).length},ordered=[...pools].sort((a,b)=>specificity(b)-specificity(a));return Array.from({length:count},()=>{const row={};for(const pool of ordered){if(row[pool.column]!=null)continue;const agrees=(candidate)=>[...wanted].every((column)=>row[column]==null||candidate[column]==null||candidate[column]===row[column]),candidates=pool.rows.filter(agrees),from=candidates.length>0?candidates:pool.rows,chosen=from[Math.floor(Math.random()*from.length)];row[pool.column]=chosen.id;for(const column of wanted)if(column!==pool.column&&row[column]==null&&chosen[column]!=null)row[column]=chosen[column]}return row})}function uniqueColumns(attributes){return Object.entries(attributes).filter(([,attr])=>attr.unique===!0).map(([field])=>snakeCase(field))}function claimUniqueValues(record,columns,used,suffix){let distinct=!0;for(const column of columns){const value=record[column];if(value==null)continue;const seen=used.get(column)??new Set;used.set(column,seen);if(!seen.has(value)){seen.add(value);continue}distinct=!1;if(suffix>0){const disambiguated=typeof value==="number"?value+suffix:`${String(value)} ${suffix}`;record[column]=disambiguated;seen.add(disambiguated)}}return distinct}export function uniqueColumnSets(model){const sets=[],indexes=model.model.indexes??[];for(const index of indexes)if(index?.unique===!0&&Array.isArray(index.columns)&&index.columns.length>0)sets.push(index.columns.map((column)=>snakeCase(column)));const relationColumns=new Set(parentRelations(model).map((relation)=>relation.column));for(const column of uniqueColumns(model.attributes))if(relationColumns.has(column))sets.push([column]);return sets}function uniqueKey(record,columns){const values=columns.map((column)=>record[column]);return values.some((value)=>value==null)?null:JSON.stringify(values)}const RELATION_ATTEMPTS=25;async function generateRecords(model,options={}){const records=[],pools=await relationPools(model,options),relations=chooseRelations(pools,model.count),unique=uniqueColumns(model.attributes),used=new Map,sets=uniqueColumnSets(model),claimed=sets.map(()=>new Set);let dropped=0;const collides=(row)=>sets.some((columns,i)=>{const key=uniqueKey(row,columns);return key!==null&&claimed[i].has(key)});for(let i=0;i<model.count;i++){let record=await generateRecord(model.attributes,model.name,i===0);if(unique.length>0){for(let attempt=0;attempt<3&&!claimUniqueValues(record,unique,used,0);attempt++)record=await generateRecord(model.attributes,model.name,!1);claimUniqueValues(record,unique,used,i+1)}const fixture=model.fixtures[i],attach=(relation)=>{const withRelations={...record};for(const[column,value]of Object.entries(relation))if(withRelations[column]==null)withRelations[column]=value;return fixture?{...withRelations,...fixtureToColumns(fixture)}:withRelations};let row=attach(relations[i]??{});if(!fixture&&pools.length>0){for(let attempt=0;attempt<RELATION_ATTEMPTS&&collides(row);attempt++)row=attach(chooseRelations(pools,1)[0]??{});if(collides(row)){dropped++;continue}}sets.forEach((columns,s)=>{const key=uniqueKey(row,columns);if(key!==null)claimed[s].add(key)});records.push(row)}if(dropped>0)log.info(` ${model.name}: seeding ${records.length} of ${model.count} rows - the parent rows allow no more distinct ${sets.map((columns)=>`(${columns.join(", ")})`).join(" / ")} combinations.`);return records}async function seedModel(model,options){const startTime=Date.now();try{try{await db.selectFrom(model.table).limit(0).execute()}catch(tableErr){const msg=tableErr?.message||"";if(msg.includes("does not exist")||msg.includes("no such table")||msg.includes("doesn't exist")){log.info(` Skipping ${model.name}: table "${model.table}" does not exist`);return{model:model.name,table:model.table,count:0,success:!0,duration:Date.now()-startTime}}throw tableErr}if(!options.fresh&&!options.append){if(await db.selectFrom(model.table).selectAll().limit(1).executeTakeFirst()){if(options.verbose)log.info(` ${model.name}: table already has rows - skipping (--append to add more, --fresh to replace)`);return{model:model.name,table:model.table,count:0,success:!0,duration:Date.now()-startTime}}}const records=await generateRecords(model,options);if(records.length===0)return{model:model.name,table:model.table,count:0,success:!0,duration:Date.now()-startTime};const account=isAccountModel(model.name),highestIdBefore=account?await highestId(model.table).catch(()=>{return}):void 0,batchSize=100;let inserted=0;for(let i=0;i<records.length;i+=batchSize){const batch=records.slice(i,i+batchSize);await db.insertInto(model.table).values(batch).execute();inserted+=batch.length}if(account)await rememberSeededAccounts(model,highestIdBefore,inserted);if(options.verbose)log.success(` Seeded ${model.name}: ${inserted} records`);return{model:model.name,table:model.table,count:inserted,success:!0,duration:Date.now()-startTime}}catch(err){const errorMessage=err instanceof Error?err.message:String(err);if(options.verbose)log.error(` Failed to seed ${model.name}: ${errorMessage}`);return{model:model.name,table:model.table,count:0,success:!1,error:errorMessage,duration:Date.now()-startTime}}}export function parentRelations(model){const belongsTo=model.model.belongsTo,read=(entry)=>{if(typeof entry==="string")return entry?{model:entry,column:`${snakeCase(entry)}_id`}:null;if(entry&&typeof entry==="object"){const name=String(entry.model??"");if(!name)return null;const key=entry.foreignKey;return{model:name,column:key||`${snakeCase(name)}_id`}}return null};return(Array.isArray(belongsTo)?belongsTo:belongsTo&&typeof belongsTo==="object"?Object.values(belongsTo):[]).map(read).filter((relation)=>relation!==null)}function parentModels(model){return parentRelations(model).map((relation)=>relation.model)}async function clearTables(models,verbose){for(const model of[...models].reverse())try{await db.deleteFrom(model.table).execute();if(verbose)log.info(` Truncated table: ${model.table}`)}catch(error){const message=error instanceof Error?error.message:String(error);if(/no such table|doesn't exist|does not exist/i.test(message))continue;throw Error(`Could not empty ${model.table} before seeding: ${message}`)}}function sortModelsByDependencies(models){const byName=new Map(models.map((model)=>[model.name,model])),ordered=[],state=new Map,visit=(model)=>{const status=state.get(model.name);if(status==="done"||status==="visiting")return;state.set(model.name,"visiting");for(const parentName of parentModels(model)){const parent=byName.get(parentName);if(parent&&parent!==model)visit(parent)}state.set(model.name,"done");ordered.push(model)};for(const model of models)visit(model);return ordered}export async function seed(config={}){const startTime=Date.now();await ensureDatabaseConfigLoaded();const modelsDir=config.modelsDir||path.userModelsPath(),verbose=config.verbose??!0;if(verbose){log.info("Seeding database using model factories...");log.info(`User models directory: ${modelsDir}`);log.info(`Default models directory: ${defaultModelsPath()}`)}let models=await loadAllModels(modelsDir,verbose,config.includeDefaults??!1);registerModelTables(models);seededAccountRows.clear();if(models.length===0){log.warn("No seedable models found in defaults or user directories");return{total:0,successful:0,failed:0,results:[],duration:Date.now()-startTime}}if(config.only&&config.only.length>0)models=models.filter((m)=>config.only.includes(m.name));if(config.except&&config.except.length>0)models=models.filter((m)=>!config.except.includes(m.name));if(!config.fresh&&!config.allowProtected){const skipped=[];models=models.filter((m)=>{if(isProtectedModel(m.name)){skipped.push(m);return!1}return!0});if(skipped.length>0){log.info(`Skipped ${skipped.length} protected auth model(s) to avoid invalidating live sessions: ${skipped.map((m)=>m.name).join(", ")}`);log.info(" Re-run with --fresh (truncates tables first) or --allow-protected to include them.")}}models=sortModelsByDependencies(models);if(verbose)log.info(`Found ${models.length} seedable model(s)`);if(config.fresh)await clearTables(models,verbose);const results=[];for(const model of models){if(verbose)log.info(`Seeding ${model.name} (${model.count} records)...`);try{const result=await seedModel(model,config);results.push(result)}catch(err){const errorMessage=err instanceof Error?err.message:String(err);if(verbose)log.error(` Unexpected error seeding ${model.name}: ${errorMessage}`);results.push({model:model.name,table:model.table,count:0,success:!1,error:errorMessage,duration:0})}}const successful=results.filter((r)=>r.success).length,failed=results.filter((r)=>!r.success).length,totalRecords=results.reduce((sum,r)=>sum+r.count,0);if(verbose){log.info("");if(failed===0){log.success("Database seeded successfully!");log.info(` Total records: ${totalRecords}`);log.info(` Models seeded: ${successful}`)}else{log.warn(`Seeding completed with ${failed} failure(s)`);log.info(` Successful: ${successful}`);log.info(` Failed: ${failed}`)}}return{total:results.length,successful,failed,results,duration:Date.now()-startTime}}export async function makeModelRecords(modelName,count=1,overrides={},options={}){if(!Number.isInteger(count)||count<1)throw TypeError(`makeModelRecords(${modelName}) needs a positive integer count; got ${count}`);const models=await loadAllModels(path.userModelsPath(),options.verbose??!1,!0),model=models.find((m)=>m.name===modelName);if(!model)throw Error(`Model not found: ${modelName}. Known models: ${models.map((m)=>m.name).sort().join(", ")||"(none loaded)"}`);return(await generateRecords({...model,count},{verbose:options.verbose??!1})).map((record)=>({...record,...snakeCaseKeys(overrides)}))}function snakeCaseKeys(input){return Object.fromEntries(Object.entries(input).map(([key,value])=>[snakeCase(key),value]))}export async function seedModel$(modelName,options={}){const modelsDir=path.userModelsPath(),model=(await loadAllModels(modelsDir,options.verbose)).find((m)=>m.name===modelName);if(!model)throw Error(`Model not found: ${modelName}`);if(options.count)model.count=options.count;seededAccountRows.clear();return seedModel(model,{fresh:options.fresh,verbose:options.verbose??!0})}export async function freshSeed(config={}){return seed({...config,fresh:!0})}export async function listSeedableModels(){const modelsDir=path.userModelsPath(),defaultDir=defaultModelsPath(),defaultModels=await loadModelsFromDir(defaultDir,!0),userModels=await loadModelsFromDir(modelsDir,!1),result=[],seen=new Set;for(const m of userModels){result.push({name:m.name,table:m.table,count:m.count,source:"user"});seen.add(m.name)}for(const m of defaultModels)if(!seen.has(m.name))result.push({name:m.name,table:m.table,count:m.count,source:"default"});return result}export{seed as runSeeders};export{freshSeed as freshWithSeed};
1
+ import{existsSync,readdirSync}from"node:fs";import{extname,relative}from"node:path";import{pathToFileURL}from"node:url";import{log}from"@stacksjs/logging";import{db,ensureDatabaseConfigLoaded}from"./utils";import{faker}from"@stacksjs/faker";import{path}from"@stacksjs/path";import{hashMake}from"@stacksjs/security";import{fs}from"@stacksjs/storage";export class Seeder{static order=0;static tags=[]}const SEEDER_EXTENSIONS=new Set([".js",".mjs",".ts"]);function applicationSeederFiles(directory){if(!existsSync(directory))return[];const files=[],visit=(current)=>{const entries=readdirSync(current,{withFileTypes:!0}).sort((a,b)=>a.name.localeCompare(b.name));for(const entry of entries){if(entry.name.startsWith("."))continue;const file=`${current}/${entry.name}`;if(entry.isDirectory()){visit(file);continue}if(!entry.isFile()||entry.name.endsWith(".d.ts")||!SEEDER_EXTENSIONS.has(extname(entry.name)))continue;files.push(file)}};visit(directory);return files}export async function runApplicationSeeders(config={}){const startTime=Date.now(),directory=config.directory||path.userDatabasePath("seeders"),verbose=config.verbose??!0,results=[],loaded=[];for(const file of applicationSeederFiles(directory)){const displayFile=relative(directory,file),fallbackName=displayFile.replace(/\.(?:m?js|ts)$/,"");try{const SeederClass=(await import(pathToFileURL(file).href)).default,order=typeof SeederClass?.order==="number"?SeederClass.order:0,tags=Array.isArray(SeederClass?.tags)?SeederClass.tags.map(String):[];loaded.push({file,displayFile,name:SeederClass?.name||fallbackName,order,tags,SeederClass})}catch(error){loaded.push({file,displayFile,name:fallbackName,order:0,tags:[],loadError:error})}}const only=config.only?new Set(config.only):void 0,except=config.except?new Set(config.except):void 0,tags=config.tags?.length?new Set(config.tags):void 0,selected=loaded.filter((entry)=>{if(only&&!only.has(entry.name))return!1;if(tags&&!entry.tags.some((tag)=>tags.has(tag)))return!1;return!except?.has(entry.name)});selected.sort((a,b)=>a.order-b.order);for(const entry of selected){const startedAt=Date.now(),{displayFile}=entry;let seeder=entry.name;try{if(entry.loadError)throw entry.loadError;const SeederClass=entry.SeederClass;if(typeof SeederClass!=="function")throw TypeError("The default export must be a Seeder class.");const instance=new SeederClass;if(!(instance instanceof Seeder))throw TypeError("The default export must extend Seeder from @stacksjs/database.");seeder=SeederClass.name||seeder;if(verbose)log.info(`Running application seeder ${seeder}...`);await instance.run();results.push({seeder,file:displayFile,success:!0,duration:Date.now()-startedAt})}catch(error){const message=error instanceof Error?error.message:String(error);if(verbose)log.error(`Application seeder ${seeder} failed: ${message}`);results.push({seeder,file:displayFile,success:!1,error:message,duration:Date.now()-startedAt})}}return{total:results.length,successful:results.filter((result)=>result.success).length,failed:results.filter((result)=>!result.success).length,results,duration:Date.now()-startTime}}export function defaultModelsPath(subpath){return path.frameworkPath(`defaults/app/Models/${subpath||""}`)}export const PROTECTED_MODELS=Object.freeze(["OauthClient","OauthAccessToken","OauthRefreshToken","PersonalAccessToken"]),ACCOUNT_MODELS=Object.freeze(["User","Team","Customer"]);export function isAccountModel(name){return ACCOUNT_MODELS.includes(name)}export function isProtectedModel(name){return PROTECTED_MODELS.includes(name)}function snakeCase(str){return str.replace(/([A-Z]+)([A-Z][a-z])/g,"$1_$2").replace(/([a-z\d])([A-Z])/g,"$1_$2").toLowerCase()}async function loadModelsFromDir(modelsDir,recursive=!1,includeNonSeeding=!1){const models=[];if(!fs.existsSync(modelsDir))return models;const entries=fs.readdirSync(modelsDir,{withFileTypes:!0});for(const entry of entries){const fullPath=path.join(modelsDir,entry.name);if(entry.isDirectory()&&recursive){const subModels=await loadModelsFromDir(fullPath,!0,includeNonSeeding);models.push(...subModels);continue}if(!entry.name.endsWith(".ts")||entry.name.startsWith("index")||entry.name.startsWith("_"))continue;try{const module=await import(fullPath),modelDef=module.default||module;if(!modelDef)continue;const useSeeder=modelDef.traits?.useSeeder??modelDef.traits?.seedable;if(!useSeeder&&!includeNonSeeding)continue;const seedable=Boolean(useSeeder);let count=10,fixtures=[];if(typeof useSeeder==="object"&&"count"in useSeeder){const opts=useSeeder;count=opts.count;fixtures=opts.fixtures??[]}const modelName=modelDef.name||entry.name.replace(".ts",""),tableName=modelDef.table||snakeCase(modelName)+"s";models.push({name:modelName,table:tableName,count:Math.max(count,fixtures.length),fixtures,attributes:modelDef.attributes||{},model:modelDef,filePath:fullPath,seedable})}catch(err){log.error(`Failed to load model ${entry.name}:`,err)}}return models}async function loadAllModels(userModelsDir,verbose=!1,includeDefaults=!1){const defaultDir=defaultModelsPath(),userModels=await loadModelsFromDir(userModelsDir,!1,!0);if(userModels.length>0&&!includeDefaults)return userModels.filter((model)=>model.seedable);const defaultModels=await loadModelsFromDir(defaultDir,!0),modelMap=new Map;for(const model of defaultModels)modelMap.set(model.name,model);for(const model of userModels){if(modelMap.has(model.name)&&verbose)log.info(model.seedable?` User model "${model.name}" overrides default`:` User model "${model.name}" overrides default and opts out of the model pass`);modelMap.set(model.name,model)}return Array.from(modelMap.values()).filter((model)=>model.seedable)}async function loadModels(modelsDir){return loadModelsFromDir(modelsDir,!1)}function isPasswordField(fieldName,attr){const lowerName=fieldName.toLowerCase();if(lowerName==="password"||lowerName.endsWith("_password")||lowerName.endsWith("password"))return!0;if(attr.hidden===!0&&lowerName.includes("pass"))return!0;return!1}async function generateRecord(attributes,modelName,report=!1){const record={},generated={};for(const[fieldName,attr]of Object.entries(attributes)){const columnName=snakeCase(fieldName);let value;if(attr.factory&&typeof attr.factory==="function")try{value=attr.factory(faker,generated)}catch(err){const errorMsg=err instanceof Error?err.message:String(err);if(report)log.warn(` Factory failed for ${modelName}.${fieldName}: ${errorMsg} - seeding the default instead.`);if(attr.default!==void 0)value=attr.default;else value=inferDefaultValue(fieldName)}else if(attr.default!==void 0)value=attr.default;else continue;generated[fieldName]=value;if(isPasswordField(fieldName,attr)&&typeof value==="string")try{value=await hashMake(value,{algorithm:"bcrypt"})}catch(err){const errorMsg=err instanceof Error?err.message:String(err);log.warn(` Failed to hash password for ${modelName}.${fieldName}: ${errorMsg}`)}record[columnName]=value}return record}function inferDefaultValue(fieldName){const lowerName=fieldName.toLowerCase();if(lowerName.startsWith("is")||lowerName.startsWith("has")||lowerName.endsWith("able"))return!1;if(lowerName.includes("count")||lowerName.includes("amount")||lowerName.includes("quantity"))return 0;if(lowerName.includes("url")||lowerName.includes("link"))return"https://example.com";if(lowerName.includes("email"))return faker.internet.email();if(lowerName.includes("name"))return faker.person.fullName();return null}function fixtureToColumns(fixture){const out={};for(const[key,value]of Object.entries(fixture))out[snakeCase(key)]=value;return out}async function existingRows(table){try{return await db.selectFrom(table).selectAll().limit(500).execute()}catch{return[]}}const modelTables=new Map;export function registerModelTables(models){modelTables.clear();for(const model of models)modelTables.set(model.name,model.table)}export function parentTable(parent){return modelTables.get(parent)??`${snakeCase(parent)}s`}async function relationPools(model,options={}){const parents=parentRelations(model);if(parents.length===0)return[];const pools=[];for(const relation of parents){const{model:parent,column}=relation;if(model.attributes[parent])continue;const rows=isAccountModel(parent)&&!options.attachAccounts?seededAccountRows.get(parent)??[]:await existingRows(parentTable(parent));if(rows.length>0){pools.push({column,rows});continue}if(isAccountModel(parent)&&!options.attachAccounts){const existing=(await existingRows(parentTable(parent))).length;log.info(existing>0?` ${model.name}.${column} left empty: no ${parent} rows were seeded in this run, and the ones already in ${parentTable(parent)} may be real accounts. Pass --attach-accounts if this database is a scratch copy.`:` ${model.name}.${column} left empty: there are no ${parent} rows to point it at.`)}}return pools}const seededAccountRows=new Map;async function rememberSeededAccounts(model,highestIdBefore,inserted){if(inserted===0)return;if(highestIdBefore===void 0){log.info(` Could not tell which ${model.name} rows this run seeded (no readable integer id), so no seeded row will point at them.`);return}try{let query=db.selectFrom(model.table).selectAll();if(highestIdBefore!==null)query=query.where("id",">",highestIdBefore);const rows=await query.orderBy("id","asc").limit(inserted).execute();seededAccountRows.set(model.name,[...seededAccountRows.get(model.name)??[],...rows])}catch(error){log.info(` Could not read back the ${model.name} rows just seeded: ${error instanceof Error?error.message:String(error)}`)}}async function highestId(table){const row=await db.selectFrom(table).select(["id"]).orderBy("id","desc").limit(1).executeTakeFirst(),id=Number(row?.id);return row&&Number.isFinite(id)?id:null}export function chooseRelations(pools,count){if(pools.length===0)return[];const wanted=new Set(pools.map((pool)=>pool.column)),specificity=(pool)=>{const sample=pool.rows[0]??{};return[...wanted].filter((column)=>column!==pool.column&&(column in sample)).length},ordered=[...pools].sort((a,b)=>specificity(b)-specificity(a));return Array.from({length:count},()=>{const row={};for(const pool of ordered){if(row[pool.column]!=null)continue;const agrees=(candidate)=>[...wanted].every((column)=>row[column]==null||candidate[column]==null||candidate[column]===row[column]),candidates=pool.rows.filter(agrees),from=candidates.length>0?candidates:pool.rows,chosen=from[Math.floor(Math.random()*from.length)];row[pool.column]=chosen.id;for(const column of wanted)if(column!==pool.column&&row[column]==null&&chosen[column]!=null)row[column]=chosen[column]}return row})}function uniqueColumns(attributes){return Object.entries(attributes).filter(([,attr])=>attr.unique===!0).map(([field])=>snakeCase(field))}function withSuffix(value,suffix){return/\s/.test(value)?`${value} ${suffix}`:`${value}-${suffix}`}export function claimUniqueValues(record,columns,used,suffix){let distinct=!0;for(const column of columns){const value=record[column];if(value==null)continue;const seen=used.get(column)??new Set;used.set(column,seen);if(!seen.has(value)){seen.add(value);continue}distinct=!1;if(suffix>0){const disambiguated=typeof value==="number"?value+suffix:withSuffix(String(value),suffix);record[column]=disambiguated;seen.add(disambiguated)}}return distinct}export function uniqueColumnSets(model){const sets=[],indexes=model.model.indexes??[];for(const index of indexes)if(index?.unique===!0&&Array.isArray(index.columns)&&index.columns.length>0)sets.push(index.columns.map((column)=>snakeCase(column)));const relationColumns=new Set(parentRelations(model).map((relation)=>relation.column));for(const column of uniqueColumns(model.attributes))if(relationColumns.has(column))sets.push([column]);return sets}function uniqueKey(record,columns){const values=columns.map((column)=>record[column]);return values.some((value)=>value==null)?null:JSON.stringify(values)}const RELATION_ATTEMPTS=25;async function generateRecords(model,options={}){const records=[],pools=await relationPools(model,options),relations=chooseRelations(pools,model.count),unique=uniqueColumns(model.attributes),used=new Map,sets=uniqueColumnSets(model),claimed=sets.map(()=>new Set);let dropped=0;const collides=(row)=>sets.some((columns,i)=>{const key=uniqueKey(row,columns);return key!==null&&claimed[i].has(key)});for(let i=0;i<model.count;i++){let record=await generateRecord(model.attributes,model.name,i===0);if(unique.length>0){for(let attempt=0;attempt<3&&!claimUniqueValues(record,unique,used,0);attempt++)record=await generateRecord(model.attributes,model.name,!1);claimUniqueValues(record,unique,used,i+1)}const fixture=model.fixtures[i],attach=(relation)=>{const withRelations={...record};for(const[column,value]of Object.entries(relation))if(withRelations[column]==null)withRelations[column]=value;return fixture?{...withRelations,...fixtureToColumns(fixture)}:withRelations};let row=attach(relations[i]??{});if(!fixture&&pools.length>0){for(let attempt=0;attempt<RELATION_ATTEMPTS&&collides(row);attempt++)row=attach(chooseRelations(pools,1)[0]??{});if(collides(row)){dropped++;continue}}sets.forEach((columns,s)=>{const key=uniqueKey(row,columns);if(key!==null)claimed[s].add(key)});records.push(row)}if(dropped>0)log.info(` ${model.name}: seeding ${records.length} of ${model.count} rows - the parent rows allow no more distinct ${sets.map((columns)=>`(${columns.join(", ")})`).join(" / ")} combinations.`);return records}async function seedModel(model,options){const startTime=Date.now();try{try{await db.selectFrom(model.table).limit(0).execute()}catch(tableErr){const msg=tableErr?.message||"";if(msg.includes("does not exist")||msg.includes("no such table")||msg.includes("doesn't exist")){log.info(` Skipping ${model.name}: table "${model.table}" does not exist`);return{model:model.name,table:model.table,count:0,success:!0,duration:Date.now()-startTime}}throw tableErr}if(!options.fresh&&!options.append){if(await db.selectFrom(model.table).selectAll().limit(1).executeTakeFirst()){if(options.verbose)log.info(` ${model.name}: table already has rows - skipping (--append to add more, --fresh to replace)`);return{model:model.name,table:model.table,count:0,success:!0,duration:Date.now()-startTime}}}const records=await generateRecords(model,options);if(records.length===0)return{model:model.name,table:model.table,count:0,success:!0,duration:Date.now()-startTime};const account=isAccountModel(model.name),highestIdBefore=account?await highestId(model.table).catch(()=>{return}):void 0,batchSize=100;let inserted=0;for(let i=0;i<records.length;i+=batchSize){const batch=records.slice(i,i+batchSize);await db.insertInto(model.table).values(batch).execute();inserted+=batch.length}if(account)await rememberSeededAccounts(model,highestIdBefore,inserted);if(options.verbose)log.success(` Seeded ${model.name}: ${inserted} records`);return{model:model.name,table:model.table,count:inserted,success:!0,duration:Date.now()-startTime}}catch(err){const errorMessage=err instanceof Error?err.message:String(err);if(options.verbose)log.error(` Failed to seed ${model.name}: ${errorMessage}`);return{model:model.name,table:model.table,count:0,success:!1,error:errorMessage,duration:Date.now()-startTime}}}export function parentRelations(model){const belongsTo=model.model.belongsTo,read=(entry)=>{if(typeof entry==="string")return entry?{model:entry,column:`${snakeCase(entry)}_id`}:null;if(entry&&typeof entry==="object"){const name=String(entry.model??"");if(!name)return null;const key=entry.foreignKey;return{model:name,column:key||`${snakeCase(name)}_id`}}return null};return(Array.isArray(belongsTo)?belongsTo:belongsTo&&typeof belongsTo==="object"?Object.values(belongsTo):[]).map(read).filter((relation)=>relation!==null)}function parentModels(model){return parentRelations(model).map((relation)=>relation.model)}async function clearTables(models,verbose){for(const model of[...models].reverse())try{await db.deleteFrom(model.table).execute();if(verbose)log.info(` Truncated table: ${model.table}`)}catch(error){const message=error instanceof Error?error.message:String(error);if(/no such table|doesn't exist|does not exist/i.test(message))continue;throw Error(`Could not empty ${model.table} before seeding: ${message}`)}}function sortModelsByDependencies(models){const byName=new Map(models.map((model)=>[model.name,model])),ordered=[],state=new Map,visit=(model)=>{const status=state.get(model.name);if(status==="done"||status==="visiting")return;state.set(model.name,"visiting");for(const parentName of parentModels(model)){const parent=byName.get(parentName);if(parent&&parent!==model)visit(parent)}state.set(model.name,"done");ordered.push(model)};for(const model of models)visit(model);return ordered}export async function seed(config={}){const startTime=Date.now();await ensureDatabaseConfigLoaded();const modelsDir=config.modelsDir||path.userModelsPath(),verbose=config.verbose??!0;if(verbose){log.info("Seeding database using model factories...");log.info(`User models directory: ${modelsDir}`);log.info(`Default models directory: ${defaultModelsPath()}`)}let models=await loadAllModels(modelsDir,verbose,config.includeDefaults??!1);registerModelTables(models);seededAccountRows.clear();if(models.length===0){log.warn("No seedable models found in defaults or user directories");return{total:0,successful:0,failed:0,results:[],duration:Date.now()-startTime}}if(config.only&&config.only.length>0)models=models.filter((m)=>config.only.includes(m.name));if(config.except&&config.except.length>0)models=models.filter((m)=>!config.except.includes(m.name));if(!config.fresh&&!config.allowProtected){const skipped=[];models=models.filter((m)=>{if(isProtectedModel(m.name)){skipped.push(m);return!1}return!0});if(skipped.length>0){log.info(`Skipped ${skipped.length} protected auth model(s) to avoid invalidating live sessions: ${skipped.map((m)=>m.name).join(", ")}`);log.info(" Re-run with --fresh (truncates tables first) or --allow-protected to include them.")}}models=sortModelsByDependencies(models);if(verbose)log.info(`Found ${models.length} seedable model(s)`);if(config.fresh)await clearTables(models,verbose);const results=[];for(const model of models){if(verbose)log.info(`Seeding ${model.name} (${model.count} records)...`);try{const result=await seedModel(model,config);results.push(result)}catch(err){const errorMessage=err instanceof Error?err.message:String(err);if(verbose)log.error(` Unexpected error seeding ${model.name}: ${errorMessage}`);results.push({model:model.name,table:model.table,count:0,success:!1,error:errorMessage,duration:0})}}const successful=results.filter((r)=>r.success).length,failed=results.filter((r)=>!r.success).length,totalRecords=results.reduce((sum,r)=>sum+r.count,0);if(verbose){log.info("");if(failed===0){log.success("Database seeded successfully!");log.info(` Total records: ${totalRecords}`);log.info(` Models seeded: ${successful}`)}else{log.warn(`Seeding completed with ${failed} failure(s)`);log.info(` Successful: ${successful}`);log.info(` Failed: ${failed}`)}}return{total:results.length,successful,failed,results,duration:Date.now()-startTime}}export async function makeModelRecords(modelName,count=1,overrides={},options={}){if(!Number.isInteger(count)||count<1)throw TypeError(`makeModelRecords(${modelName}) needs a positive integer count; got ${count}`);const models=await loadAllModels(path.userModelsPath(),options.verbose??!1,!0),model=models.find((m)=>m.name===modelName);if(!model)throw Error(`Model not found: ${modelName}. Known models: ${models.map((m)=>m.name).sort().join(", ")||"(none loaded)"}`);return(await generateRecords({...model,count},{verbose:options.verbose??!1})).map((record)=>({...record,...snakeCaseKeys(overrides)}))}function snakeCaseKeys(input){return Object.fromEntries(Object.entries(input).map(([key,value])=>[snakeCase(key),value]))}export async function seedModel$(modelName,options={}){const modelsDir=path.userModelsPath(),model=(await loadAllModels(modelsDir,options.verbose)).find((m)=>m.name===modelName);if(!model)throw Error(`Model not found: ${modelName}`);if(options.count)model.count=options.count;seededAccountRows.clear();return seedModel(model,{fresh:options.fresh,verbose:options.verbose??!0})}export async function freshSeed(config={}){return seed({...config,fresh:!0})}export async function listSeedableModels(){const modelsDir=path.userModelsPath(),defaultDir=defaultModelsPath(),defaultModels=await loadModelsFromDir(defaultDir,!0),userModels=await loadModelsFromDir(modelsDir,!1),result=[],seen=new Set;for(const m of userModels){result.push({name:m.name,table:m.table,count:m.count,source:"user"});seen.add(m.name)}for(const m of defaultModels)if(!seen.has(m.name))result.push({name:m.name,table:m.table,count:m.count,source:"default"});return result}export{seed as runSeeders};export{freshSeed as freshWithSeed};
@@ -11,3 +11,41 @@
11
11
  * applied while its first column had never been created.
12
12
  */
13
13
  export declare function sqlStatementsOf(content: string): string[];
14
+ /**
15
+ * The enum types a set of statements creates, lowercased.
16
+ *
17
+ * Unanchored on purpose. The generator wraps the create in a guard, so the
18
+ * statement begins with `DO $stacks$ BEGIN` and the `CREATE TYPE` is in the
19
+ * middle of it:
20
+ *
21
+ * ```sql
22
+ * DO $stacks$ BEGIN CREATE TYPE "teams_status_type" AS ENUM ('active');
23
+ * EXCEPTION WHEN duplicate_object THEN null; END $stacks$;
24
+ * ```
25
+ *
26
+ * A pattern anchored at the start of the statement sees nothing there, which
27
+ * is how the migration ledger came to treat a regenerated types file as
28
+ * having no verifiable effects: unclassifiable, so neither recorded as already
29
+ * applied nor reported as pending (stacksjs/stacks#2900).
30
+ */
31
+ export declare function enumTypesCreatedBy(statements: readonly string[]): Set<string>;
32
+ /**
33
+ * The statements inside a `DO $tag$ BEGIN ... EXCEPTION ... END $tag$` guard,
34
+ * or undefined when the statement is not one.
35
+ *
36
+ * The guard is how both the generator and a hand-written Postgres migration
37
+ * make a `CREATE TYPE` or a `CREATE INDEX` idempotent, and the statements that
38
+ * matter are inside it. Anything reading a corpus has to look in there, or a
39
+ * pattern anchored at the start of a statement sees only `DO $tag$ BEGIN`.
40
+ *
41
+ * Requires the body to open with `BEGIN`, so a block with a `DECLARE` section
42
+ * is left whole: that shape has never been generated, and treating an
43
+ * unrecognised block as one opaque statement is the safe direction.
44
+ */
45
+ export declare function guardedBlockStatements(statement: string): string[] | undefined;
46
+ /**
47
+ * An optionally schema-qualified, optionally quoted identifier, capturing the
48
+ * name itself. `$` is legal in a Postgres identifier after the first
49
+ * character, and a generated enum type can carry one.
50
+ */
51
+ export declare const SQL_IDENTIFIER: string;
@@ -1,2 +1,2 @@
1
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}
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 const SQL_IDENTIFIER=String.raw`(?:["'\`[]?[\w$]+["'\`\]]?\.)?["'\`[]?([\w$]+)["'\`\]]?`;export function enumTypesCreatedBy(statements){const types=new Set;for(const statement of statements)for(const match of statement.matchAll(new RegExp(String.raw`\bCREATE\s+TYPE\s+${SQL_IDENTIFIER}\s+AS\s+ENUM\b`,"gi")))if(match[1])types.add(match[1].toLowerCase());return types}export function guardedBlockStatements(statement){const block=/^\s*DO\s+\$(\w*)\$([\s\S]*)\$\1\$\s*;?\s*$/i.exec(statement);if(!block)return;const body=/^\s*BEGIN\b([\s\S]*?)(?:\bEXCEPTION\b[\s\S]*)?\bEND\s*;?\s*$/i.exec(block[2]??"");return body?sqlStatementsOf(body[1]??""):void 0}
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.159",
5
+ "version": "0.75.160",
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.159",
70
- "@stacksjs/env": "0.75.159",
71
- "@stacksjs/error-handling": "0.75.159",
72
- "@stacksjs/faker": "0.75.159",
73
- "@stacksjs/features": "0.75.159",
74
- "@stacksjs/logging": "0.75.159",
75
- "@stacksjs/model-meta": "0.75.159",
76
- "@stacksjs/path": "0.75.159",
77
- "@stacksjs/query-builder": "0.75.159",
78
- "@stacksjs/security": "0.75.159",
79
- "@stacksjs/storage": "0.75.159",
80
- "@stacksjs/strings": "0.75.159",
69
+ "@stacksjs/config": "0.75.160",
70
+ "@stacksjs/env": "0.75.160",
71
+ "@stacksjs/error-handling": "0.75.160",
72
+ "@stacksjs/faker": "0.75.160",
73
+ "@stacksjs/features": "0.75.160",
74
+ "@stacksjs/logging": "0.75.160",
75
+ "@stacksjs/model-meta": "0.75.160",
76
+ "@stacksjs/path": "0.75.160",
77
+ "@stacksjs/query-builder": "0.75.160",
78
+ "@stacksjs/security": "0.75.160",
79
+ "@stacksjs/storage": "0.75.160",
80
+ "@stacksjs/strings": "0.75.160",
81
81
  "@stacksjs/ts-validation": "^0.5.6",
82
82
  "bun-query-builder": "^0.3.8",
83
83
  "dynamodb-tooling": "^0.3.6"
84
84
  },
85
85
  "devDependencies": {
86
- "@stacksjs/cli": "0.75.159",
87
- "@stacksjs/router": "0.75.159",
88
- "@stacksjs/utils": "0.75.159",
86
+ "@stacksjs/cli": "0.75.160",
87
+ "@stacksjs/router": "0.75.160",
88
+ "@stacksjs/utils": "0.75.160",
89
89
  "better-dx": "^0.2.24"
90
90
  }
91
91
  }