@stacksjs/database 0.75.143 → 0.75.145

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.
@@ -246,6 +246,23 @@ export declare function countAppliedMigrations(): Promise<number>;
246
246
  * ALTER statements; CREATE TABLE/INDEX already use IF NOT EXISTS.
247
247
  */
248
248
  export declare function idempotentSql(sql: string): string;
249
+ /**
250
+ * Rewrite every ALTER migration on disk to idempotent form (Postgres only) before
251
+ * they run. Mirrors the `preprocessSqliteMigrations` file-rewrite pattern; runs
252
+ * under the migration lock so concurrent runners can't race the disk. Skips files
253
+ * with no ADD COLUMN/CONSTRAINT and files that are already idempotent.
254
+ */
255
+ /**
256
+ * The form a Postgres migration file is kept in: ALTERs idempotent, enum types
257
+ * guarded and their members asserted, type changes ordered.
258
+ *
259
+ * `buddy migrate` puts every file through this before running it and writes
260
+ * the result back, so a file not already in this form is rewritten - a
261
+ * working-tree change in a tracked file. The writers (`generate:migrations`,
262
+ * `migrate:regenerate`) therefore write through it too, and the transform is a
263
+ * fixed point: a committed file stays exactly as it was committed.
264
+ */
265
+ export declare function normalizePostgresMigrationSql(sql: string): string;
249
266
  export declare function runDatabaseMigration(): Promise<Result<string, Error>>;
250
267
  /**
251
268
  * The committed migration files the next `buddy migrate` will run: in the
@@ -11,9 +11,9 @@ ${missing.join(`
11
11
  )`);const insert=writeDb.prepare("INSERT OR IGNORE INTO migrations (migration) VALUES (?)");for(const migration of droppedMigrations)insert.run(migration);const unrecord=writeDb.prepare("DELETE FROM migrations WHERE migration = ?");for(const migration of replayMigrations)unrecord.run(migration)}finally{writeDb.close()}}catch(e){log.debug(`[migration] Could not record dropped migrations as executed: ${e}`)}return quarantined}function mayCreateMissingDatabase(){const signal=process.env.STACKS_CREATE_DATABASE;if(signal==="1")return!0;if(signal==="0")return!1;const policy=String(process.env.DB_CREATE_DATABASE||"").toLowerCase();return!(policy==="never"||policy==="false"||policy==="0")}function describeProbeFailure(target,kind,error){const where=describeTarget(target),detail=error instanceof Error?error.message:String(error??"");switch(kind){case"missing-role":return`The user "${target.username}" does not exist on ${where}. Set DB_USERNAME to a role that exists, or create it with: createuser -s ${target.username}`;case"auth-failed":return`Authentication failed for user "${target.username}" on ${where}. Check DB_USERNAME and DB_PASSWORD.`;case"server-unreachable":return`Could not reach the database server at ${target.host}:${target.port}. Check that it is running and that DB_HOST and DB_PORT are correct.`;case"timeout":return`Timed out connecting to the database server at ${target.host}:${target.port}. ${detail}`;case"permission-denied":return`The user "${target.username}" is not allowed to connect to "${target.database}" on ${where}.`;default:return`Could not connect to the database "${target.database}" on ${where}. ${detail}`}}async function ensureDatabaseExists(){const target=resolveConnectionTarget();if(!target)return;const probe=await probeTargetDatabase(target);if(probe.ok)return;if(probe.kind!=="missing-database")throw Error(describeProbeFailure(target,probe.kind,probe.error));if(!mayCreateMissingDatabase())throw Error(`The database "${target.database}" does not exist on ${describeTarget(target)}. Create it with: ${manualCreateHint(target)}`);const result=await createDatabase(target);if(!result.created&&result.error)throw Error(`The database "${target.database}" does not exist on ${describeTarget(target)} and it could not be created automatically. ${describeProbeFailure(target,result.kind,result.error)}
12
12
  Create it yourself with: ${manualCreateHint(target)}`);if(result.created)log.success(`Created database "${target.database}" on ${target.host}:${target.port}`)}let databaseBootstrapped=!1;export async function ensureDatabaseReady(){await ensureDatabaseConfigLoaded();if(databaseBootstrapped)return;await ensureDatabaseExists();databaseBootstrapped=!0}export function resetDatabaseBootstrapCache(){databaseBootstrapped=!1}export async function featureGatedMigrationFiles(dir=migrationDirectory()){const gated=[];try{const{appModelClaimsTable,FEATURE_NAMES,migrationFeature,migrationTable}=await import("@stacksjs/features"),{feature:isFeatureEnabled}=await import("@stacksjs/config");if(!existsSync(dir))return gated;const disabledFeatures=new Set(FEATURE_NAMES.filter((f)=>!isFeatureEnabled(f)));if(disabledFeatures.size===0)return gated;for(const file of readdirSync(dir).filter((f)=>f.endsWith(".sql"))){const owner=migrationFeature(file);if(!owner||!disabledFeatures.has(owner))continue;const table=migrationTable(file);if(table&&appModelClaimsTable(table))continue;gated.push({file,feature:owner,table})}}catch{}return gated}export async function featureGateForLedger(dir=migrationDirectory(getDialect())){const gated=await featureGatedMigrationFiles(dir),tables=new Set(gated.flatMap((entry)=>entry.table?[entry.table.toLowerCase()]:[]));return{exclude:new Set(gated.map((entry)=>entry.file)),rewrite:(sql)=>tables.size>0?withoutGatedStatements(sql,tables):sql}}async function hideDisabledFeatureMigrations(){const hidden=[];try{const fs=await import("node:fs/promises"),migrationsDir=migrationDirectory();if(!existsSync(migrationsDir))return hidden;const gated=await featureGatedMigrationFiles(migrationsDir);if(gated.length===0)return hidden;const files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql")),gatedTables=new Set;for(const{file,feature,table}of gated){if(table)gatedTables.add(table.toLowerCase());const original=join(migrationsDir,file),hiddenPath=`${original}.disabled`;await fs.rename(original,hiddenPath);hidden.push({original,hidden:hiddenPath,feature})}for(const file of files){const filePath=join(migrationsDir,file);if(!existsSync(filePath))continue;const sql=readFileSync(filePath,"utf8"),filtered=withoutGatedStatements(sql,gatedTables);if(filtered===sql)continue;const backup=`${filePath}.ungated`;await fs.rename(filePath,backup);writeFileSync(filePath,filtered);hidden.push({original:filePath,hidden:backup,feature:"mixed"})}if(hidden.length>0){const summary=Object.entries(hidden.reduce((acc,h)=>{acc[h.feature]=(acc[h.feature]??0)+1;return acc},{})).map(([f,n])=>`${f}: ${n}`).join(", ");log.info(`[migration] Skipping ${hidden.length} migration(s) for disabled features (${summary}). Run \`./buddy <feature>:install\` to enable.`)}}catch{}return hidden}function afterCommonTableExpressions(statement){if(!/^\s*WITH\b/i.test(statement))return statement;let depth=0;for(let index=0;index<statement.length;index++){const char=statement[index];if(char==="("){depth++;continue}if(char!==")")continue;depth--;if(depth!==0)continue;const rest=statement.slice(index+1);if(/^\s*,/.test(rest))continue;return rest}return statement}export function statementTable(statement){const body=afterCommonTableExpressions(statement),patterns=[/^\s*ALTER\s+TABLE\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?([a-z0-9_]+)["`]?/i,/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["`]?([a-z0-9_]+)["`]?/i,/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?[a-z0-9_]+["`]?\s+ON\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*UPDATE\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*INSERT\s+(?:OR\s+\w+\s+)?INTO\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*DELETE\s+FROM\s+["`]?([a-z0-9_]+)["`]?/i];for(const pattern of patterns){const match=pattern.exec(body);if(match)return match[1].toLowerCase()}return null}export function statementReferencesTable(statement,table){const escaped=table.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return new RegExp(`\\b(?:FROM|JOIN|UPDATE|INTO)\\s+["\`\\[]?${escaped}["\`\\]]?\\b`,"i").test(statement)}function rebuiltTable(statement){const rename=/^\s*ALTER\s+TABLE\s+["`]?([a-z0-9_]+)["`]?\s+RENAME\s+TO\s+["`]?([a-z0-9_]+)["`]?/i.exec(statement);if(rename?.[2])return rename[2].toLowerCase();const table=statementTable(statement);if(table?.startsWith("_qb_tmp_"))return table.slice(8);return null}export function withoutGatedStatements(sql,gated){if(gated.size===0)return sql;const statements=sql.split(";").map((s)=>s.trim()).filter(Boolean),kept=statements.filter((statement)=>{const table=rebuiltTable(statement)??statementTable(statement);if(table&&gated.has(table))return!1;for(const candidate of gated)if(statementReferencesTable(statement,candidate))return!1;return!0});if(kept.length===statements.length)return sql;return kept.length===0?"":`${kept.join(`;
13
13
  `)};
14
- `}async function restoreHiddenMigrations(hidden){const fs=await import("node:fs/promises");for(const{original,hidden:h}of hidden)try{if(h.endsWith(".ungated"))await fs.rm(original,{force:!0});await fs.rename(h,original)}catch{}}export async function countAppliedMigrations(){await ensureDatabaseConfigLoaded();try{const rows=await db.unsafe("SELECT COUNT(*) AS n FROM migrations").execute(),row=Array.isArray(rows)?rows[0]:void 0;if(!row)return 0;const n=Number(row.n??row.N??0);return Number.isFinite(n)?n:0}catch{return 0}}async function writeMigrateMarker(appliedCount){try{const fs=await import("node:fs/promises"),dir=path.frameworkRuntimePath();await fs.mkdir(dir,{recursive:!0});const file=`${dir}/last-migrate-result.json`,body=JSON.stringify({appliedCount,completedAt:new Date().toISOString()});await fs.writeFile(file,body,"utf8")}catch{}}export function idempotentSql(sql){const header=/^(?:[^\S\n]*--[^\n]*\n)+/.exec(sql)?.[0]??"",stmts=sql.slice(header.length).split(";").map((s)=>s.trim()).filter(Boolean);if(stmts.length===0)return sql;const out=[];for(const raw of stmts){const stmt=raw.replace(/\bADD\s+COLUMN\s+(?!IF\s+NOT\s+EXISTS\b)/gi,"ADD COLUMN IF NOT EXISTS "),m=/^ALTER\s+TABLE\s+("?\w+"?)\s+ADD\s+CONSTRAINT\s+("?\w+"?)/i.exec(stmt);if(m){const drops=[`ALTER TABLE ${m[1]} DROP CONSTRAINT IF EXISTS ${m[2]}`],fk=/\bFOREIGN\s+KEY\s*\(\s*"?(\w+)"?\s*\)/i.exec(stmt);if(fk){const table=m[1].replace(/"/g,"");drops.push(`ALTER TABLE ${m[1]} DROP CONSTRAINT IF EXISTS "${table}_${fk[1]}_fkey"`)}const already=new Set;for(let i=out.length-1;i>=0;i--){const previous=out[i];if(!/^ALTER\s+TABLE\s+"?\w+"?\s+DROP\s+CONSTRAINT\b/i.test(previous))break;already.add(previous.toUpperCase())}for(const drop of drops)if(!already.has(drop.toUpperCase()))out.push(drop)}out.push(stmt)}return`${header}${out.join(`;
14
+ `}async function restoreHiddenMigrations(hidden){const fs=await import("node:fs/promises");for(const{original,hidden:h}of hidden)try{if(h.endsWith(".ungated"))await fs.rm(original,{force:!0});await fs.rename(h,original)}catch{}}export async function countAppliedMigrations(){await ensureDatabaseConfigLoaded();try{const rows=await db.unsafe("SELECT COUNT(*) AS n FROM migrations").execute(),row=Array.isArray(rows)?rows[0]:void 0;if(!row)return 0;const n=Number(row.n??row.N??0);return Number.isFinite(n)?n:0}catch{return 0}}async function writeMigrateMarker(appliedCount){try{const fs=await import("node:fs/promises"),dir=path.frameworkRuntimePath();await fs.mkdir(dir,{recursive:!0});const file=`${dir}/last-migrate-result.json`,body=JSON.stringify({appliedCount,completedAt:new Date().toISOString()});await fs.writeFile(file,body,"utf8")}catch{}}export function idempotentSql(sql){const header=/^(?:[^\S\n]*--[^\n]*\n)+/.exec(sql)?.[0]??"",bodies=[],stmts=sql.slice(header.length).replace(/\$([A-Za-z_]*)\$[\s\S]*?\$\1\$/g,(body)=>`\x00${bodies.push(body)-1}\x00`).split(";").map((s)=>s.trim().replace(/\u0000(\d+)\u0000/g,(_m,index)=>bodies[Number(index)])).filter(Boolean);if(stmts.length===0)return sql;const out=[];for(const raw of stmts){const stmt=raw.replace(/\bADD\s+COLUMN\s+(?!IF\s+NOT\s+EXISTS\b)/gi,"ADD COLUMN IF NOT EXISTS "),m=/^ALTER\s+TABLE\s+("?\w+"?)\s+ADD\s+CONSTRAINT\s+("?\w+"?)/i.exec(stmt);if(m){const drops=[`ALTER TABLE ${m[1]} DROP CONSTRAINT IF EXISTS ${m[2]}`],fk=/\bFOREIGN\s+KEY\s*\(\s*"?(\w+)"?\s*\)/i.exec(stmt);if(fk){const table=m[1].replace(/"/g,"");drops.push(`ALTER TABLE ${m[1]} DROP CONSTRAINT IF EXISTS "${table}_${fk[1]}_fkey"`)}const already=new Set;for(let i=out.length-1;i>=0;i--){const previous=out[i];if(!/^ALTER\s+TABLE\s+"?\w+"?\s+DROP\s+CONSTRAINT\b/i.test(previous))break;already.add(previous.toUpperCase())}for(const drop of drops)if(!already.has(drop.toUpperCase()))out.push(drop)}out.push(stmt)}return`${header}${out.join(`;
15
15
  `)};
16
- `}function makeMigrationsIdempotent(){const rewritten=[],migrationsDir=migrationDirectory("postgres");let files;try{files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql"))}catch{return}for(const f of files){const p=join(migrationsDir,f);let sql;try{sql=readFileSync(p,"utf8")}catch{continue}if(!(/\bADD\s+(?:COLUMN|CONSTRAINT)\b/i.test(sql)||/\bCREATE\s+TYPE\b/i.test(sql)||/\bALTER\s+COLUMN\b[^\n]*\bTYPE\b/i.test(sql)))continue;const next=orderPostgresColumnTypeChanges(guardPostgresEnumTypes(idempotentSql(sql)));if(next!==sql)try{writeFileSync(p,next);rewritten.push(f)}catch{}}if(rewritten.length>0)log.warn(`[migration] Rewrote ${rewritten.length} migration file(s) on disk to be idempotent: ${rewritten.slice(0,3).join(", ")}${rewritten.length>3?`, +${rewritten.length-3} more`:""}. These files are tracked in git, so this shows up as a working-tree change.`)}async function loadLibsqlSchemaMirror(){const{Database}=require("bun:sqlite"),mirror=new Database(":memory:");mirror.exec("PRAGMA foreign_keys = OFF");const objects=await db.unsafe("SELECT type, name, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 WHEN 'view' THEN 2 ELSE 3 END, rowid").execute();for(const object of objects)try{mirror.exec(object.sql)}catch(error){log.debug(`[migration] libSQL schema mirror skipped ${object.type} ${object.name}: ${error instanceof Error?error.message:String(error)}`)}if(objects.some((object)=>object.type==="table"&&object.name==="migrations")){const recorded=await db.unsafe("SELECT migration FROM migrations").execute(),insert=mirror.prepare("INSERT OR IGNORE INTO migrations (migration) VALUES (?)");for(const row of recorded)try{insert.run(row.migration)}catch{}}return mirror}async function recordLibsqlMigrations(record,replay){if(record.length===0&&replay.length===0)return;try{await db.unsafe(`CREATE TABLE IF NOT EXISTS migrations (
16
+ `}export function normalizePostgresMigrationSql(sql){if(!(/\bADD\s+(?:COLUMN|CONSTRAINT)\b/i.test(sql)||/\bCREATE\s+TYPE\b/i.test(sql)||/\bALTER\s+COLUMN\b[^\n]*\bTYPE\b/i.test(sql)))return sql;return orderPostgresColumnTypeChanges(guardPostgresEnumTypes(idempotentSql(sql)))}function makeMigrationsIdempotent(){const rewritten=[],migrationsDir=migrationDirectory("postgres");let files;try{files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql"))}catch{return}for(const f of files){const p=join(migrationsDir,f);let sql;try{sql=readFileSync(p,"utf8")}catch{continue}const next=normalizePostgresMigrationSql(sql);if(next!==sql)try{writeFileSync(p,next);rewritten.push(f)}catch{}}if(rewritten.length>0)log.warn(`[migration] Rewrote ${rewritten.length} migration file(s) on disk to be idempotent: ${rewritten.slice(0,3).join(", ")}${rewritten.length>3?`, +${rewritten.length-3} more`:""}. These files are tracked in git, so this shows up as a working-tree change.`)}async function loadLibsqlSchemaMirror(){const{Database}=require("bun:sqlite"),mirror=new Database(":memory:");mirror.exec("PRAGMA foreign_keys = OFF");const objects=await db.unsafe("SELECT type, name, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 WHEN 'view' THEN 2 ELSE 3 END, rowid").execute();for(const object of objects)try{mirror.exec(object.sql)}catch(error){log.debug(`[migration] libSQL schema mirror skipped ${object.type} ${object.name}: ${error instanceof Error?error.message:String(error)}`)}if(objects.some((object)=>object.type==="table"&&object.name==="migrations")){const recorded=await db.unsafe("SELECT migration FROM migrations").execute(),insert=mirror.prepare("INSERT OR IGNORE INTO migrations (migration) VALUES (?)");for(const row of recorded)try{insert.run(row.migration)}catch{}}return mirror}async function recordLibsqlMigrations(record,replay){if(record.length===0&&replay.length===0)return;try{await db.unsafe(`CREATE TABLE IF NOT EXISTS migrations (
17
17
  id INTEGER PRIMARY KEY AUTOINCREMENT,
18
18
  migration TEXT NOT NULL UNIQUE,
19
19
  executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
@@ -31,12 +31,12 @@ Create it yourself with: ${manualCreateHint(target)}`);if(result.created)log.su
31
31
  )
32
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(`;
33
33
  `)};
34
- `;writeFileSync(join(dir,files[index].name),`${GENERATED_MIGRATION_MARKER}
35
- ${body}`)});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(`
34
+ `,content=`${GENERATED_MIGRATION_MARKER}
35
+ ${body}`;writeFileSync(join(dir,files[index].name),(options.dialect??getDialect())==="postgres"?normalizePostgresMigrationSql(content):content)});for(const{from,to}of relocated)renameSync(join(dir,from),join(dir,to));trackIgnoredGeneratedMigrations(dir);saveMigrationSnapshot(plan,{dialect});return ok(corpus)}catch(error){return err(handleError("Migration regeneration failed",error))}}export function createdTableName(statement){return/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`[]?([\w$]+)["'`\]]?/i.exec(statement)?.[1]?.toLowerCase()}export{indexShape};export function droppedIndexName(statement){return/^\s*DROP\s+INDEX\s+(?:IF\s+EXISTS\s+)?(?:["'`[]?[\w$]+["'`\]]?\.)?["'`[]?([\w$]+)["'`\]]?/i.exec(statement)?.[1]?.toLowerCase()}export function indexCommittedMigrations(fileContents){const createdTables=new Set,indexes=new Map;for(const contents of fileContents)for(const statement of sqlStatementsOf(contents)){const table=createdTableName(statement);if(table){createdTables.add(table);continue}const shape=indexShape(statement);if(shape){if(!indexes.has(shape.name))indexes.set(shape.name,shape.key);continue}const dropped=droppedIndexName(statement);if(dropped)indexes.delete(dropped)}return{sql:normalizeSqlForComparison(fileContents.join(`
36
36
  `)),createdTables,indexShapes:new Set(indexes.values())}}function normalizeSqlForComparison(sql){return sql.replace(/\s+/g," ").trim()}export function generatedStatementIsRedundant(statement,index){const table=createdTableName(statement);if(table)return index.createdTables.has(table);const shape=indexShape(statement);if(shape&&index.indexShapes.has(shape.key))return!0;return index.sql.includes(normalizeSqlForComparison(statement))}export function persistedGroupLabel(label,statements){const table=/^create-(\w+)-table$/.exec(label)?.[1];if(!table||statements.some((statement)=>createdTableName(statement)))return label;const only=statements.length===1?indexShape(statements[0]):void 0;return only?`create-${only.name}-index-in-${table}`:`create-${table}-indexes`}function persistGeneratedMigrations(sqlStatements,previousPlan){if(!sqlStatements?.length)return 0;const migrationsDir=migrationDirectory();try{require("node:fs").mkdirSync(migrationsDir,{recursive:!0})}catch{}const committed=[];try{for(const f of readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql")).sort())committed.push(readFileSync(join(migrationsDir,f),"utf8"))}catch{}const index=indexCommittedMigrations(committed),groups=groupGeneratedStatements(sqlStatements,previousPlan);let written=0,cursor=nextMigrationNumber(migrationsDir);for(const group of groups){const fresh=group.statements.filter((stmt)=>{if(!generatedStatementIsRedundant(stmt,index))return!0;const table=createdTableName(stmt);if(table)log.debug(`[migration] Skipping generated CREATE for "${table}" - the committed corpus already creates it.`);const shape=indexShape(stmt);if(shape)log.debug(`[migration] Skipping generated index "${shape.name}" - the committed corpus already builds the same index on "${shape.table}".`);return!1});if(fresh.length===0)continue;const filename=`${String(cursor).padStart(10,"0")}-${persistedGroupLabel(group.label,fresh)}.sql`,filePath=join(migrationsDir,filename),body=`${fresh.map((s)=>s.trim().replace(/;\s*$/,"")).join(`;
37
37
  `)};
38
- `;writeFileSync(filePath,`${GENERATED_MIGRATION_MARKER}
39
- ${body}`);log.debug(`[migration] Wrote ${filename} (${fresh.length} stmt${fresh.length===1?"":"s"})`);written+=1;cursor+=1}return written}function normalizeCreateStatements(sqlStatements){const creates=[],constraints=[],passthrough=[];for(const raw of sqlStatements){const statement=raw.trim();if(!statement)continue;const create=statement.match(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/i);if(create?.[1]){creates.push({statement,table:create[1]});continue}const constraint=statement.match(/^ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+CONSTRAINT\s+([\s\S]+?);?$/i);if(constraint?.[1]&&constraint[2]){const references=[...constraint[2].matchAll(/REFERENCES\s+["`]?(\w+)["`]?/gi)].flatMap((match)=>match[1]?[match[1]]:[]);constraints.push({body:`CONSTRAINT ${constraint[2].replace(/;\s*$/,"")}`,references,statement,table:constraint[1]});continue}passthrough.push(statement)}if(creates.length===0)return sqlStatements.map((statement)=>statement.trim()).filter(Boolean);const createdTables=new Set(creates.map((create)=>create.table)),createOrder=new Map(creates.map((create,index)=>[create.table,index])),relevantConstraints=constraints.filter((constraint)=>createdTables.has(constraint.table)),unrelatedConstraints=constraints.filter((constraint)=>!createdTables.has(constraint.table)),dependencies=new Map(creates.map((create)=>[create.table,new Set(relevantConstraints.filter((constraint)=>constraint.table===create.table).flatMap((constraint)=>constraint.references).filter((reference)=>reference!==create.table&&createdTables.has(reference)))])),sortTables=(ignoredEdges=new Set)=>{const remaining=new Set(createdTables),sorted=[];while(remaining.size>0){const ready=[...remaining].filter((table)=>[...dependencies.get(table)??[]].every((dependency)=>{return!remaining.has(dependency)||ignoredEdges.has(`${table}->${dependency}`)})).sort((a,b)=>(createOrder.get(a)??0)-(createOrder.get(b)??0));if(ready.length===0)break;for(const table of ready){remaining.delete(table);sorted.push(table)}}return sorted},initiallySorted=sortTables(),cyclicTables=new Set([...createdTables].filter((table)=>!initiallySorted.includes(table))),deferred=relevantConstraints.filter((constraint)=>constraint.references.some((reference)=>{return reference!==constraint.table&&cyclicTables.has(constraint.table)&&cyclicTables.has(reference)})),deferredStatements=new Set(deferred.map((constraint)=>constraint.statement)),ignoredEdges=new Set(deferred.flatMap((constraint)=>constraint.references.map((reference)=>`${constraint.table}->${reference}`))),orderedTables=sortTables(ignoredEdges),byTable=new Map(creates.map((create)=>[create.table,create]));return[...orderedTables.map((table)=>{const create=byTable.get(table),inline=relevantConstraints.filter((constraint)=>constraint.table===table&&!deferredStatements.has(constraint.statement));if(inline.length===0)return create.statement;const closing=create.statement.lastIndexOf(")");if(closing<0)return create.statement;const before=create.statement.slice(0,closing).trimEnd(),after=create.statement.slice(closing);return`${before},
38
+ `,content=`${GENERATED_MIGRATION_MARKER}
39
+ ${body}`;writeFileSync(filePath,getDialect()==="postgres"?normalizePostgresMigrationSql(content):content);log.debug(`[migration] Wrote ${filename} (${fresh.length} stmt${fresh.length===1?"":"s"})`);written+=1;cursor+=1}return written}function normalizeCreateStatements(sqlStatements){const creates=[],constraints=[],passthrough=[];for(const raw of sqlStatements){const statement=raw.trim();if(!statement)continue;const create=statement.match(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/i);if(create?.[1]){creates.push({statement,table:create[1]});continue}const constraint=statement.match(/^ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+CONSTRAINT\s+([\s\S]+?);?$/i);if(constraint?.[1]&&constraint[2]){const references=[...constraint[2].matchAll(/REFERENCES\s+["`]?(\w+)["`]?/gi)].flatMap((match)=>match[1]?[match[1]]:[]);constraints.push({body:`CONSTRAINT ${constraint[2].replace(/;\s*$/,"")}`,references,statement,table:constraint[1]});continue}passthrough.push(statement)}if(creates.length===0)return sqlStatements.map((statement)=>statement.trim()).filter(Boolean);const createdTables=new Set(creates.map((create)=>create.table)),createOrder=new Map(creates.map((create,index)=>[create.table,index])),relevantConstraints=constraints.filter((constraint)=>createdTables.has(constraint.table)),unrelatedConstraints=constraints.filter((constraint)=>!createdTables.has(constraint.table)),dependencies=new Map(creates.map((create)=>[create.table,new Set(relevantConstraints.filter((constraint)=>constraint.table===create.table).flatMap((constraint)=>constraint.references).filter((reference)=>reference!==create.table&&createdTables.has(reference)))])),sortTables=(ignoredEdges=new Set)=>{const remaining=new Set(createdTables),sorted=[];while(remaining.size>0){const ready=[...remaining].filter((table)=>[...dependencies.get(table)??[]].every((dependency)=>{return!remaining.has(dependency)||ignoredEdges.has(`${table}->${dependency}`)})).sort((a,b)=>(createOrder.get(a)??0)-(createOrder.get(b)??0));if(ready.length===0)break;for(const table of ready){remaining.delete(table);sorted.push(table)}}return sorted},initiallySorted=sortTables(),cyclicTables=new Set([...createdTables].filter((table)=>!initiallySorted.includes(table))),deferred=relevantConstraints.filter((constraint)=>constraint.references.some((reference)=>{return reference!==constraint.table&&cyclicTables.has(constraint.table)&&cyclicTables.has(reference)})),deferredStatements=new Set(deferred.map((constraint)=>constraint.statement)),ignoredEdges=new Set(deferred.flatMap((constraint)=>constraint.references.map((reference)=>`${constraint.table}->${reference}`))),orderedTables=sortTables(ignoredEdges),byTable=new Map(creates.map((create)=>[create.table,create]));return[...orderedTables.map((table)=>{const create=byTable.get(table),inline=relevantConstraints.filter((constraint)=>constraint.table===table&&!deferredStatements.has(constraint.statement));if(inline.length===0)return create.statement;const closing=create.statement.lastIndexOf(")");if(closing<0)return create.statement;const before=create.statement.slice(0,closing).trimEnd(),after=create.statement.slice(closing);return`${before},
40
40
  ${inline.map((constraint)=>constraint.body).join(`,
41
41
  `)}
42
42
  ${after}`}),...passthrough,...unrelatedConstraints.map((constraint)=>constraint.statement),...deferred.map((constraint)=>constraint.statement)]}export function groupGeneratedStatements(sqlStatements,previousPlan){const normalizedStatements=normalizeCreateStatements(sqlStatements),groups=new Map,push=(label,stmt)=>{const list=groups.get(label)??[];list.push(stmt);groups.set(label,list)},createdTables=new Set(normalizedStatements.flatMap((raw)=>{const match=raw.trim().match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/i);return match?.[1]?[match[1]]:[]}));for(const raw of normalizedStatements){const stmt=raw.trim();if(!stmt)continue;const create=stmt.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/i);if(create){push(`create-${create[1]}-table`,stmt);continue}if(stmt.match(/^\s*CREATE\s+TYPE\s+/i)){push("create-database-types",stmt);continue}if(/^\s*ALTER\s+TYPE\s+\S+\s+ADD\s+VALUE\b/i.test(stmt)){push("add-enum-values",stmt);continue}const alter=stmt.match(/^\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+(?:ADD\s+COLUMN\s+["`]?(\w+)["`]?|DROP\s+COLUMN\s+["`]?(\w+)["`]?|ADD\s+CONSTRAINT)/i),alterTable=alter?.[1];if(alter&&alterTable){const isCreateTimeConstraint=createdTables.has(alterTable)&&!alter[2]&&!alter[3];push(isCreateTimeConstraint?"create-foreign-key-constraints":`alter-${alterTable}-columns`,stmt);continue}const idx=stmt.match(/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?\s+ON\s+["`]?(\w+)["`]?/i),idxName=idx?.[1],idxTable=idx?.[2];if(idxName&&idxTable){push(createdTables.has(idxTable)?`create-${idxTable}-table`:`create-${idxName}-index-in-${idxTable}`,stmt);continue}const drop=stmt.match(/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["`]?(\w+)["`]?/i);if(drop){push(`drop-${drop[1]}-table`,stmt);continue}push("auto-misc",stmt)}const priority=(label)=>label==="create-database-types"?0:label==="add-enum-values"?1:2,ordered=[...groups.entries()].map(([label,statements])=>({label,statements})).sort((a,b)=>priority(a.label)-priority(b.label));return withTableDropsLast(ordered,previousPlan)}const DROP_TABLE_GROUP_RE=/^drop-(\w+)-table$/;export function withTableDropsLast(groups,previousPlan){const dropOf=(group)=>group.label.match(DROP_TABLE_GROUP_RE)?.[1],recreated=new Set(groups.flatMap((group)=>group.statements.flatMap((statement)=>{const table=createdTableName(statement);return table?[table]:[]}))),movable=groups.filter((group)=>{const table=dropOf(group);return table!==void 0&&!recreated.has(table.toLowerCase())});if(movable.length===0)return groups;const rest=groups.filter((group)=>!movable.includes(group)),drops=[...movable].reverse(),references=new Map;for(const table of previousPlan?.tables??[])references.set(table.table,new Set(table.columns.flatMap((column)=>column.references?.table&&column.references.table!==table.table?[column.references.table]:[])));const sorted=[],visiting=new Set,visit=(group)=>{if(sorted.includes(group)||visiting.has(group))return;visiting.add(group);for(const other of drops)if(other!==group&&references.get(dropOf(other))?.has(dropOf(group)))visit(other);visiting.delete(group);sorted.push(group)};for(const group of drops)visit(group);return[...rest,...sorted]}function nextMigrationNumber(migrationsDir){let max=0;try{for(const f of readdirSync(migrationsDir)){if(isPackageMigration(f))continue;const m=f.match(/^(\d+)-/);if(m?.[1])max=Math.max(max,Number.parseInt(m[1],10))}}catch{}return max+1}export async function generateMigrations2(){await ensureDatabaseConfigLoaded();try{log.info("Generating fresh migrations...");configureQueryBuilder();const dialect=getDialect(),{modelsDir,skip}=prepareMigrationModelsDir();if(skip){log.info("No app/Models directory found; using committed framework migrations");return ok("Migrations generated")}const qbDialect=getQbDialect();await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,full:!0,dryRun:!0});log.success("Migrations generated");return ok("Migrations generated")}catch(error){return err(handleError("Fresh migration generation failed",error))}}
@@ -1,4 +1,4 @@
1
- import{comparableIndexKey,indexShape,postgresIndexStatement}from"./index-shape";import{sqlStatementsOf}from"./sql-statements";const quote=(name)=>`"${name.replace(/"/g,'""')}"`,sqlString=(value)=>`'${value.replace(/'/g,"''")}'`;export function postgresIdentifier(name,max=63){if(name.length<=max)return name;let hash=2166136261;for(let i=0;i<name.length;i++){hash^=name.charCodeAt(i);hash=Math.imul(hash,16777619)>>>0}const suffix=`_${hash.toString(36).padStart(7,"0").slice(-7)}`;return name.slice(0,max-suffix.length)+suffix}function topLevelParts(body){const parts=[];let depth=0,current="",quoted=null;for(const char of body){if(quoted){current+=char;if(char===quoted)quoted=null;continue}if(char==="'"||char==='"'){quoted=char;current+=char;continue}if(char==="(")depth++;if(char===")")depth--;if(char===","&&depth===0){parts.push(current);current="";continue}current+=char}parts.push(current);return parts.map((part)=>part.trim()).filter(Boolean)}export function columnDefinitionsOf(createStatement){const definitions=new Map,body=createStatement.match(/\(([\s\S]*)\)\s*;?\s*$/)?.[1];if(!body)return definitions;const constraint=/^\s*(?:PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK|CONSTRAINT|KEY|INDEX)\b/i;for(const part of topLevelParts(body)){if(constraint.test(part))continue;const name=part.match(/^["'`]?(\w+)["'`]?/)?.[1];if(name)definitions.set(name.toLowerCase(),part)}return definitions}function indexColumnsAndPredicate(statement){const open=/\bON\s+\S+\s*\(/i.exec(statement);if(!open)return;const start=open.index+open[0].length;let depth=1,end=start;for(;end<statement.length&&depth>0;end++)if(statement[end]==="(")depth++;else if(statement[end]===")")depth--;if(depth>0)return;return[statement,statement.slice(start,end-1),statement.slice(end).trim()]}export function desiredSchemaFrom(plan,statements){const creates=new Map,indexes=[],enums=new Map,visit=(statement)=>{const table=/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i.exec(statement)?.[1];if(table){creates.set(table.toLowerCase(),statement);return}const enumType=/^\s*CREATE\s+TYPE\s+["]?([\w$]+)["]?\s+AS\s+ENUM\s*\(([\s\S]*)\)\s*;?\s*$/i.exec(statement);if(enumType?.[1]){const labels=[...(enumType[2]??"").matchAll(/'((?:[^']|'')*)'/g)].map((m)=>(m[1]??"").replace(/''/g,"'"));enums.set(enumType[1].toLowerCase(),labels);return}const guarded=/^\s*DO\s+\$(\w*)\$\s*BEGIN\s+([\s\S]*?);\s*EXCEPTION\b/i.exec(statement);if(guarded?.[2]){visit(guarded[2]);return}const shape=indexShape(statement),key=comparableIndexKey(statement);if(shape&&key){const head=indexColumnsAndPredicate(statement),parts=topLevelParts(head?.[1]??""),plain=parts.every((part)=>/^["`]?\w+["`]?$/.test(part));indexes.push({name:shape.name,table:shape.table,unique:/^\s*CREATE\s+UNIQUE\b/i.test(statement),...plain?{columns:parts.map((part)=>part.replace(/["`]/g,"").toLowerCase())}:{},key,where:(head?.[2]??"").replace(/;\s*$/,"").trim(),statement:statement.trim().replace(/;\s*$/,"")})}};for(const statement of statements)for(const part of sqlStatementsOf(statement.trim().endsWith(";")?statement:`${statement};`))visit(part);const tables=new Map;for(const table of plan.tables){const name=table.table.toLowerCase(),definitions=creates.has(name)?columnDefinitionsOf(creates.get(name)):new Map;tables.set(name,table.columns.map((column)=>({name:column.name.toLowerCase(),type:column.type,...column.maxLength!==void 0?{maxLength:column.maxLength}:{},isPrimaryKey:column.isPrimaryKey,isNullable:column.isNullable,hasDefault:column.hasDefault,...column.defaultValue!==void 0?{defaultValue:column.defaultValue}:{},...definitions.get(column.name.toLowerCase())?{definition:definitions.get(column.name.toLowerCase())}:{},...column.references?{references:{...column.references}}:{}})))}return{tables,indexes,enums}}const INTEGER_RANK={smallint:1,integer:2,bigint:3};function declaredIntegerRank(type){if(type==="integer")return INTEGER_RANK.integer;if(type==="bigint")return INTEGER_RANK.bigint;return}function liveIntegerRank(column){return INTEGER_RANK[column.dataType]}export function comparableDefault(value){if(value===void 0||value===null)return;let text=String(value).trim();if(/^nextval\(/i.test(text))return;if(/\bnow\(\)|\b(?:current_timestamp|utc_timestamp|localtimestamp)\b/i.test(text))return"clock";text=text.replace(/::\s*(?:"[^"]+"|[a-z_]\w*)(?:\s+varying|\s+precision|\s+with(?:out)?\s+time\s+zone)?(?:\[\])?/gi,"");while(/^\(.*\)$/s.test(text))text=text.slice(1,-1).trim();text=text.replace(/^'(.*)'$/s,"$1").replace(/''/g,"'");return text.toLowerCase()}function declaredDefault(column){if(!column.hasDefault||column.defaultValue===void 0||column.defaultValue===null)return;const value=column.defaultValue;if(value instanceof Date)return comparableDefault(value.toISOString());return comparableDefault(typeof value==="string"?value:String(value))}function foreignKeyAction(action){return(action??"no action").toUpperCase()}const PG_ACTION={a:"NO ACTION",r:"RESTRICT",c:"CASCADE",n:"SET NULL",d:"SET DEFAULT"};export function planSchemaAlignment(desired,live,options={}){const actions=[],reports=[],added=new Set;for(const[type,labels]of desired.enums){const existing=live.enums.get(type);if(!existing)continue;for(const label of labels.filter((label)=>!existing.includes(label)))actions.push({kind:"add-enum-value",table:type,name:label,detail:`add '${label}' to enum type ${type}`,sql:`ALTER TYPE ${quote(type)} ADD VALUE IF NOT EXISTS ${sqlString(label)}`});const extra=existing.filter((label)=>!labels.includes(label));if(extra.length>0)reports.push({kind:"enum",table:type,detail:`enum type ${type} has label(s) the model does not declare: ${extra.join(", ")}`})}for(const[table,columns]of desired.tables){const liveColumns=live.tables.get(table);if(!liveColumns){reports.push({kind:"missing-table",table,detail:`table ${table} does not exist - \`buddy migrate\` creates it`});continue}const declared=new Set(columns.map((column)=>column.name));for(const name of liveColumns.keys())if(!declared.has(name))reports.push({kind:"extra-column",table,name,detail:`${table}.${name} exists but no model declares it`});for(const column of columns){const current=liveColumns.get(column.name);if(!current){if(!column.definition||/\bPRIMARY\s+KEY\b|\bUNIQUE\b/i.test(column.definition)){reports.push({kind:"missing-column",table,name:column.name,detail:`${table}.${column.name} is missing and cannot be added in place`});continue}const strict=/\bNOT\s+NULL\b/i.test(column.definition)&&!/\bDEFAULT\b/i.test(column.definition),populated=live.populated?.has(table)??!0,definition=strict&&populated?column.definition.replace(/\s*\bNOT\s+NULL\b/i,""):column.definition;actions.push({kind:"add-column",table,name:column.name,detail:`add ${table}.${column.name}${definition!==column.definition?" (nullable: the table has rows and the column has no default)":""}`,sql:`ALTER TABLE ${quote(table)} ADD COLUMN IF NOT EXISTS ${definition}`});added.add(`${table}.${column.name}`);if(definition!==column.definition)reports.push({kind:"nullability",table,name:column.name,detail:`${table}.${column.name} is added nullable; backfill it, then make it NOT NULL`});continue}if(column.isPrimaryKey)continue;const liveSql=current.maxLength?`${current.dataType}(${current.maxLength})`:current.dataType;if(column.type==="string"||column.type==="text"){const wanted=column.type==="text"?void 0:column.maxLength??255,isVarchar=current.dataType==="character varying",isText=current.dataType==="text"||isVarchar&&!current.maxLength;if(isVarchar&&current.maxLength&&(wanted===void 0||wanted>current.maxLength)){const to=wanted===void 0?"text":`varchar(${wanted})`;actions.push({kind:"widen-column",table,name:column.name,detail:`widen ${table}.${column.name} from varchar(${current.maxLength}) to ${to}`,sql:`ALTER TABLE ${quote(table)} ALTER COLUMN ${quote(column.name)} TYPE ${to}`})}else if(isVarchar&&current.maxLength&&wanted!==void 0&&wanted<current.maxLength)reports.push({kind:"type",table,name:column.name,detail:`${table}.${column.name} is varchar(${current.maxLength}); the model declares varchar(${wanted}) - narrowing could truncate data`});else if(isText&&wanted!==void 0)reports.push({kind:"type",table,name:column.name,detail:`${table}.${column.name} is unbounded text; the model declares varchar(${wanted}) - narrowing could truncate data`});else if(!isVarchar&&!isText&&current.dataType!=="USER-DEFINED")reports.push({kind:"type",table,name:column.name,detail:`${table}.${column.name} is ${liveSql}; the model declares ${column.type==="text"?"text":`varchar(${wanted})`}`})}else if(declaredIntegerRank(column.type)!==void 0&&liveIntegerRank(current)!==void 0){const want=declaredIntegerRank(column.type),have=liveIntegerRank(current);if(want>have)if(options.widenIntegers)actions.push({kind:"widen-column",table,name:column.name,detail:`widen ${table}.${column.name} from ${current.dataType} to bigint (rewrites the table)`,sql:`ALTER TABLE ${quote(table)} ALTER COLUMN ${quote(column.name)} TYPE bigint`});else reports.push({kind:"optional-widening",table,name:column.name,detail:`${table}.${column.name} is ${current.dataType}; the model declares bigint - pass --widen-integers to convert it (rewrites the table)`});else if(want<have)reports.push({kind:"type",table,name:column.name,detail:`${table}.${column.name} is ${current.dataType}; the model declares ${column.type} - narrowing could overflow`})}else if(column.type!=="enum"&&options.typeSatisfies&&!options.typeSatisfies(column.type,current.dataType))reports.push({kind:"type",table,name:column.name,detail:`${table}.${column.name} is ${liveSql}; the model declares ${column.type}`});const wantNullable=column.isNullable;if(wantNullable!==current.nullable)reports.push({kind:"nullability",table,name:column.name,detail:wantNullable?`${table}.${column.name} is NOT NULL; the model allows NULL`:`${table}.${column.name} allows NULL; the model declares NOT NULL`});const want=declaredDefault(column),have=comparableDefault(current.default);if((want??"")!==(have??"")&&!(want===void 0&&have==="null"))reports.push({kind:"default",table,name:column.name,detail:`${table}.${column.name} defaults to ${current.default??"nothing"}; the model declares ${want===void 0?"no default":String(column.defaultValue)}`})}}const desiredNames=new Set(desired.indexes.map((index)=>index.name)),liveByName=new Map(live.indexes.map((index)=>[index.name,index])),claimed=new Set,renames=[],creates=[];for(const index of desired.indexes){const liveColumns=live.tables.get(index.table);if(!liveColumns)continue;const existing=liveByName.get(index.name);if(existing){claimed.add(existing.name);if(existing.table!==index.table||existing.key&&existing.key!==index.key)reports.push({kind:"index-definition",table:index.table,name:index.name,detail:`index ${index.name} exists but differs from the model (${existing.definition}) - drop and recreate it by hand if the model is right`});continue}const twins=live.indexes.filter((candidate)=>candidate.table===index.table&&!candidate.primary&&candidate.key===index.key&&!desiredNames.has(candidate.name)&&!claimed.has(candidate.name));if(twins.length>0){const[twin,...extra]=twins;claimed.add(twin.name);if(live.names.has(index.name))reports.push({kind:"name-conflict",table:index.table,name:index.name,detail:`cannot rename ${twin.name} to ${index.name}: that name is taken by another object`});else renames.push({kind:"rename-index",table:index.table,name:index.name,detail:`rename index ${twin.name} to ${index.name}${twin.constraint?" (and the constraint it backs)":""}`,sql:`ALTER INDEX ${quote(twin.name)} RENAME TO ${quote(index.name)}`});for(const duplicate of extra){claimed.add(duplicate.name);reports.push({kind:"duplicate-index",table:index.table,name:duplicate.name,detail:`${duplicate.name} duplicates ${index.name} - drop it by hand once nothing depends on it`})}continue}const missingColumn=index.columns?.find((column)=>!liveColumns.has(column)&&!added.has(`${index.table}.${column}`));if(missingColumn){reports.push({kind:"missing-column",table:index.table,name:missingColumn,detail:`index ${index.name} needs ${index.table}.${missingColumn}, which cannot be added here`});continue}const statement=index.statement.replace(/^\s*CREATE\s+(UNIQUE\s+)?INDEX\s+(?!IF\s+NOT\s+EXISTS)/i,(_m,unique)=>`CREATE ${unique??""}INDEX IF NOT EXISTS `),action={kind:"create-index",table:index.table,name:index.name,detail:`create ${index.unique?"unique ":""}index ${index.name}`,sql:statement};if(index.unique&&index.columns){const columns=index.columns.map(quote).join(", "),where=[...index.columns.map((column)=>`${quote(column)} IS NOT NULL`),...index.where?[`(${index.where.replace(/^WHERE\s+/i,"")})`]:[]].join(" AND ");action.check={kind:"duplicates",sql:`SELECT COUNT(*) AS n FROM (SELECT 1 FROM ${quote(index.table)} WHERE ${where} GROUP BY ${columns} HAVING COUNT(*) > 1) AS duplicates`}}creates.push(action)}const foreignKeys=[];for(const[table,columns]of desired.tables){const liveColumns=live.tables.get(table);if(!liveColumns)continue;for(const column of columns){const ref=column.references;if(!ref)continue;const refTable=ref.table.toLowerCase(),refColumn=ref.column.toLowerCase();if(added.has(`${table}.${column.name}`)||!liveColumns.has(column.name))continue;const present=live.foreignKeys.find((fk)=>fk.table===table&&fk.column===column.name&&fk.refTable===refTable&&fk.refColumn===refColumn);if(present){const wantDelete=foreignKeyAction(ref.onDelete),wantUpdate=foreignKeyAction(ref.onUpdate);if(present.onDelete!==wantDelete||present.onUpdate!==wantUpdate)reports.push({kind:"foreign-key",table,name:present.name,detail:`${present.name} is ON DELETE ${present.onDelete} ON UPDATE ${present.onUpdate}; the model declares ON DELETE ${wantDelete} ON UPDATE ${wantUpdate}`});continue}if(!live.tables.get(refTable)?.has(refColumn)){reports.push({kind:"foreign-key",table,name:column.name,detail:`${table}.${column.name} references ${refTable}.${refColumn}, which does not exist`});continue}const name=postgresIdentifier(ref.constraintNames?.[0]??`${table}_${column.name}_fkey`);if(live.names.has(name)){reports.push({kind:"name-conflict",table,name,detail:`cannot add the foreign key for ${table}.${column.name} as ${name}: that name is taken`});continue}const actionsSql=[ref.onDelete?` ON DELETE ${foreignKeyAction(ref.onDelete)}`:"",ref.onUpdate?` ON UPDATE ${foreignKeyAction(ref.onUpdate)}`:""].join("");foreignKeys.push({kind:"add-foreign-key",table,name,detail:`add foreign key ${table}.${column.name} -> ${refTable}.${refColumn}`,sql:`ALTER TABLE ${quote(table)} ADD CONSTRAINT ${quote(name)} FOREIGN KEY (${quote(column.name)}) REFERENCES ${quote(refTable)} (${quote(refColumn)})${actionsSql}`,check:{kind:"orphans",sql:`SELECT COUNT(*) AS n FROM ${quote(table)} AS child WHERE child.${quote(column.name)} IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${quote(refTable)} AS parent WHERE parent.${quote(refColumn)} = child.${quote(column.name)})`}})}}return{actions:[...actions,...renames,...creates,...foreignKeys],reports,refused:[]}}function rows(result){return Array.isArray(result)?result:[]}function text(row,key){const value=row?.[key];return value===null||value===void 0?"":String(value)}export async function readPostgresAlignSchema(run){const tables=new Map;for(const row of rows(await run("SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_type = 'BASE TABLE'")))tables.set(text(row,"table_name").toLowerCase(),new Map);for(const row of rows(await run("SELECT table_name, column_name, data_type, udt_name, character_maximum_length, is_nullable, column_default FROM information_schema.columns WHERE table_schema = current_schema()"))){const columns=tables.get(text(row,"table_name").toLowerCase());if(!columns)continue;const length=Number(row.character_maximum_length);columns.set(text(row,"column_name").toLowerCase(),{name:text(row,"column_name").toLowerCase(),dataType:text(row,"data_type").toLowerCase()==="user-defined"?"USER-DEFINED":text(row,"data_type").toLowerCase(),udtName:text(row,"udt_name").toLowerCase(),...Number.isFinite(length)&&length>0?{maxLength:length}:{},nullable:text(row,"is_nullable").toUpperCase()==="YES",...row.column_default!==null&&row.column_default!==void 0?{default:String(row.column_default)}:{}})}const indexes=[];for(const row of rows(await run(`
1
+ import{comparableIndexKey,indexShape,postgresIndexStatement}from"./index-shape";import{sqlStatementsOf}from"./sql-statements";const quote=(name)=>`"${name.replace(/"/g,'""')}"`,sqlString=(value)=>`'${value.replace(/'/g,"''")}'`;export function postgresIdentifier(name,max=63){if(name.length<=max)return name;let hash=2166136261;for(let i=0;i<name.length;i++){hash^=name.charCodeAt(i);hash=Math.imul(hash,16777619)>>>0}const suffix=`_${hash.toString(36).padStart(7,"0").slice(-7)}`;return name.slice(0,max-suffix.length)+suffix}function topLevelParts(body){const parts=[];let depth=0,current="",quoted=null;for(const char of body){if(quoted){current+=char;if(char===quoted)quoted=null;continue}if(char==="'"||char==='"'){quoted=char;current+=char;continue}if(char==="(")depth++;if(char===")")depth--;if(char===","&&depth===0){parts.push(current);current="";continue}current+=char}parts.push(current);return parts.map((part)=>part.trim()).filter(Boolean)}export function columnDefinitionsOf(createStatement){const definitions=new Map,body=createStatement.match(/\(([\s\S]*)\)\s*;?\s*$/)?.[1];if(!body)return definitions;const constraint=/^\s*(?:PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK|CONSTRAINT|KEY|INDEX)\b/i;for(const part of topLevelParts(body)){if(constraint.test(part))continue;const name=part.match(/^["'`]?(\w+)["'`]?/)?.[1];if(name)definitions.set(name.toLowerCase(),part)}return definitions}function indexColumnsAndPredicate(statement){const open=/\bON\s+\S+\s*\(/i.exec(statement);if(!open)return;const start=open.index+open[0].length;let depth=1,end=start;for(;end<statement.length&&depth>0;end++)if(statement[end]==="(")depth++;else if(statement[end]===")")depth--;if(depth>0)return;return[statement,statement.slice(start,end-1),statement.slice(end).trim()]}export function desiredSchemaFrom(plan,statements){const creates=new Map,indexes=[],enums=new Map,visit=(statement)=>{const table=/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i.exec(statement)?.[1];if(table){creates.set(table.toLowerCase(),statement);return}const enumType=/^\s*CREATE\s+TYPE\s+["]?([\w$]+)["]?\s+AS\s+ENUM\s*\(([\s\S]*)\)\s*;?\s*$/i.exec(statement);if(enumType?.[1]){const labels=[...(enumType[2]??"").matchAll(/'((?:[^']|'')*)'/g)].map((m)=>(m[1]??"").replace(/''/g,"'"));enums.set(enumType[1].toLowerCase(),labels);return}const guarded=/^\s*DO\s+\$(\w*)\$\s*BEGIN\s+([\s\S]*?);\s*EXCEPTION\b/i.exec(statement);if(guarded?.[2]){visit(guarded[2]);return}const shape=indexShape(statement),key=comparableIndexKey(statement);if(shape&&key){const head=indexColumnsAndPredicate(statement),parts=topLevelParts(head?.[1]??""),plain=parts.every((part)=>/^["`]?\w+["`]?$/.test(part));indexes.push({name:shape.name,table:shape.table,unique:/^\s*CREATE\s+UNIQUE\b/i.test(statement),...plain?{columns:parts.map((part)=>part.replace(/["`]/g,"").toLowerCase())}:{},key,where:(head?.[2]??"").replace(/;\s*$/,"").trim(),statement:statement.trim().replace(/;\s*$/,"")})}};for(const statement of statements)for(const part of sqlStatementsOf(statement.trim().endsWith(";")?statement:`${statement};`))visit(part);const tables=new Map;for(const table of plan.tables){const name=table.table.toLowerCase(),definitions=creates.has(name)?columnDefinitionsOf(creates.get(name)):new Map;tables.set(name,table.columns.map((column)=>({name:column.name.toLowerCase(),type:column.type,...column.maxLength!==void 0?{maxLength:column.maxLength}:{},isPrimaryKey:column.isPrimaryKey,isNullable:column.isNullable,hasDefault:column.hasDefault,...column.defaultValue!==void 0?{defaultValue:column.defaultValue}:{},...definitions.get(column.name.toLowerCase())?{definition:definitions.get(column.name.toLowerCase())}:{},...column.references?{references:{...column.references}}:{}})))}return{tables,indexes,enums}}const INTEGER_RANK={smallint:1,integer:2,bigint:3};function declaredIntegerRank(type){if(type==="integer")return INTEGER_RANK.integer;if(type==="bigint")return INTEGER_RANK.bigint;return}function liveIntegerRank(column){return INTEGER_RANK[column.dataType]}export function comparableDefault(value){if(value===void 0||value===null)return;let text=String(value).trim();if(/^nextval\(/i.test(text))return;if(/\bnow\(\)|\b(?:current_timestamp|utc_timestamp|localtimestamp)\b/i.test(text))return"clock";text=text.replace(/::\s*(?:"[^"]+"|[a-z_]\w*)(?:\s+varying|\s+precision|\s+with(?:out)?\s+time\s+zone)?(?:\[\])?/gi,"");while(/^\(.*\)$/s.test(text))text=text.slice(1,-1).trim();text=text.replace(/^'(.*)'$/s,"$1").replace(/''/g,"'");return text.toLowerCase()}function declaredDefault(column){if(!column.hasDefault||column.defaultValue===void 0||column.defaultValue===null)return;const value=column.defaultValue;if(value instanceof Date)return comparableDefault(value.toISOString());return comparableDefault(typeof value==="string"?value:String(value))}function foreignKeyAction(action){return(action??"no action").toUpperCase()}const PG_ACTION={a:"NO ACTION",r:"RESTRICT",c:"CASCADE",n:"SET NULL",d:"SET DEFAULT"};export function planSchemaAlignment(desired,live,options={}){const actions=[],reports=[],added=new Set;for(const[type,labels]of desired.enums){const existing=live.enums.get(type);if(!existing)continue;for(const label of labels.filter((label)=>!existing.includes(label)))actions.push({kind:"add-enum-value",table:type,name:label,detail:`add '${label}' to enum type ${type}`,sql:`ALTER TYPE ${quote(type)} ADD VALUE IF NOT EXISTS ${sqlString(label)}`});const extra=existing.filter((label)=>!labels.includes(label));if(extra.length>0)reports.push({kind:"enum",table:type,detail:`enum type ${type} has label(s) the model does not declare: ${extra.join(", ")}`})}for(const[table,columns]of desired.tables){const liveColumns=live.tables.get(table);if(!liveColumns){reports.push({kind:"missing-table",table,detail:`table ${table} does not exist - \`buddy migrate\` creates it`});continue}const declared=new Set(columns.map((column)=>column.name));for(const name of liveColumns.keys())if(!declared.has(name))reports.push({kind:"extra-column",table,name,detail:`${table}.${name} exists but no model declares it`});for(const column of columns){const current=liveColumns.get(column.name);if(!current){if(!column.definition||/\bPRIMARY\s+KEY\b|\bUNIQUE\b/i.test(column.definition)){reports.push({kind:"missing-column",table,name:column.name,detail:`${table}.${column.name} is missing and cannot be added in place`});continue}const strict=/\bNOT\s+NULL\b/i.test(column.definition)&&!/\bDEFAULT\b/i.test(column.definition),populated=live.populated?.has(table)??!0,definition=strict&&populated?column.definition.replace(/\s*\bNOT\s+NULL\b/i,""):column.definition;actions.push({kind:"add-column",table,name:column.name,detail:`add ${table}.${column.name}${definition!==column.definition?" (nullable: the table has rows and the column has no default)":""}`,sql:`ALTER TABLE ${quote(table)} ADD COLUMN IF NOT EXISTS ${definition}`});added.add(`${table}.${column.name}`);if(definition!==column.definition)reports.push({kind:"nullability",table,name:column.name,detail:`${table}.${column.name} is added nullable; backfill it, then make it NOT NULL`});continue}if(column.isPrimaryKey)continue;const liveSql=current.maxLength?`${current.dataType}(${current.maxLength})`:current.dataType;if(column.type==="string"||column.type==="text"){const wanted=column.type==="text"?void 0:column.maxLength??255,isVarchar=current.dataType==="character varying",isText=current.dataType==="text"||isVarchar&&!current.maxLength;if(isVarchar&&current.maxLength&&(wanted===void 0||wanted>current.maxLength)){const to=wanted===void 0?"text":`varchar(${wanted})`;actions.push({kind:"widen-column",table,name:column.name,detail:`widen ${table}.${column.name} from varchar(${current.maxLength}) to ${to}`,sql:`ALTER TABLE ${quote(table)} ALTER COLUMN ${quote(column.name)} TYPE ${to}`})}else if(isVarchar&&current.maxLength&&wanted!==void 0&&wanted<current.maxLength)reports.push({kind:"type",table,name:column.name,detail:`${table}.${column.name} is varchar(${current.maxLength}); the model declares varchar(${wanted}) - narrowing could truncate data`});else if(isText&&wanted!==void 0)reports.push({kind:"type",table,name:column.name,detail:`${table}.${column.name} is unbounded text; the model declares varchar(${wanted}) - narrowing could truncate data`});else if(!isVarchar&&!isText&&current.dataType!=="USER-DEFINED")reports.push({kind:"type",table,name:column.name,detail:`${table}.${column.name} is ${liveSql}; the model declares ${column.type==="text"?"text":`varchar(${wanted})`}`})}else if(declaredIntegerRank(column.type)!==void 0&&liveIntegerRank(current)!==void 0){const want=declaredIntegerRank(column.type),have=liveIntegerRank(current);if(want>have)if(options.widenIntegers)actions.push({kind:"widen-column",table,name:column.name,detail:`widen ${table}.${column.name} from ${current.dataType} to bigint (rewrites the table)`,sql:`ALTER TABLE ${quote(table)} ALTER COLUMN ${quote(column.name)} TYPE bigint`});else reports.push({kind:"optional-widening",table,name:column.name,detail:`${table}.${column.name} is ${current.dataType}; the model declares bigint - pass --widen-integers to convert it (rewrites the table)`});else if(want<have)reports.push({kind:"type",table,name:column.name,detail:`${table}.${column.name} is ${current.dataType}; the model declares ${column.type} - narrowing could overflow`})}else if(column.type!=="enum"&&options.typeSatisfies&&!options.typeSatisfies(column.type,current.dataType))reports.push({kind:"type",table,name:column.name,detail:`${table}.${column.name} is ${liveSql}; the model declares ${column.type}`});const wantNullable=column.isNullable;if(wantNullable!==current.nullable)reports.push({kind:"nullability",table,name:column.name,detail:wantNullable?`${table}.${column.name} is NOT NULL; the model allows NULL`:`${table}.${column.name} allows NULL; the model declares NOT NULL`});const want=declaredDefault(column),have=comparableDefault(current.default);if((want??"")!==(have??"")&&!(want===void 0&&have==="null"))reports.push({kind:"default",table,name:column.name,detail:`${table}.${column.name} defaults to ${current.default??"nothing"}; the model declares ${want===void 0?"no default":String(column.defaultValue)}`})}}const desiredNames=new Set(desired.indexes.map((index)=>index.name)),liveByName=new Map(live.indexes.map((index)=>[index.name,index])),claimed=new Set,renames=[],creates=[];for(const index of desired.indexes){const liveColumns=live.tables.get(index.table);if(!liveColumns)continue;const existing=liveByName.get(index.name);if(existing){claimed.add(existing.name);if(existing.table!==index.table||existing.key&&existing.key!==index.key)reports.push({kind:"index-definition",table:index.table,name:index.name,detail:`index ${index.name} exists but differs from the model (${existing.definition}) - drop and recreate it by hand if the model is right`});continue}const twins=live.indexes.filter((candidate)=>candidate.table===index.table&&!candidate.primary&&candidate.key===index.key&&!desiredNames.has(candidate.name)&&!claimed.has(candidate.name));if(twins.length>0){const[twin,...extra]=[...twins].sort((a,b)=>Number(b.name.includes(index.name))-Number(a.name.includes(index.name)));claimed.add(twin.name);if(live.names.has(index.name))reports.push({kind:"name-conflict",table:index.table,name:index.name,detail:`cannot rename ${twin.name} to ${index.name}: that name is taken by another object`});else renames.push({kind:"rename-index",table:index.table,name:index.name,detail:`rename index ${twin.name} to ${index.name}${twin.constraint?" (and the constraint it backs)":""}`,sql:`ALTER INDEX ${quote(twin.name)} RENAME TO ${quote(index.name)}`});for(const duplicate of extra){claimed.add(duplicate.name);reports.push({kind:"duplicate-index",table:index.table,name:duplicate.name,detail:`${duplicate.name} duplicates ${index.name} - drop it by hand once nothing depends on it`})}continue}const missingColumn=index.columns?.find((column)=>!liveColumns.has(column)&&!added.has(`${index.table}.${column}`));if(missingColumn){reports.push({kind:"missing-column",table:index.table,name:missingColumn,detail:`index ${index.name} needs ${index.table}.${missingColumn}, which cannot be added here`});continue}const statement=index.statement.replace(/^\s*CREATE\s+(UNIQUE\s+)?INDEX\s+(?!IF\s+NOT\s+EXISTS)/i,(_m,unique)=>`CREATE ${unique??""}INDEX IF NOT EXISTS `),action={kind:"create-index",table:index.table,name:index.name,detail:`create ${index.unique?"unique ":""}index ${index.name}`,sql:statement};if(index.unique&&index.columns){const columns=index.columns.map(quote).join(", "),where=[...index.columns.map((column)=>`${quote(column)} IS NOT NULL`),...index.where?[`(${index.where.replace(/^WHERE\s+/i,"")})`]:[]].join(" AND ");action.check={kind:"duplicates",sql:`SELECT COUNT(*) AS n FROM (SELECT 1 FROM ${quote(index.table)} WHERE ${where} GROUP BY ${columns} HAVING COUNT(*) > 1) AS duplicates`}}creates.push(action)}const foreignKeys=[];for(const[table,columns]of desired.tables){const liveColumns=live.tables.get(table);if(!liveColumns)continue;for(const column of columns){const ref=column.references;if(!ref)continue;const refTable=ref.table.toLowerCase(),refColumn=ref.column.toLowerCase();if(added.has(`${table}.${column.name}`)||!liveColumns.has(column.name))continue;const present=live.foreignKeys.find((fk)=>fk.table===table&&fk.column===column.name&&fk.refTable===refTable&&fk.refColumn===refColumn);if(present){const wantDelete=foreignKeyAction(ref.onDelete),wantUpdate=foreignKeyAction(ref.onUpdate);if(present.onDelete!==wantDelete||present.onUpdate!==wantUpdate)reports.push({kind:"foreign-key",table,name:present.name,detail:`${present.name} is ON DELETE ${present.onDelete} ON UPDATE ${present.onUpdate}; the model declares ON DELETE ${wantDelete} ON UPDATE ${wantUpdate}`});continue}if(!live.tables.get(refTable)?.has(refColumn)){reports.push({kind:"foreign-key",table,name:column.name,detail:`${table}.${column.name} references ${refTable}.${refColumn}, which does not exist`});continue}const name=postgresIdentifier(ref.constraintNames?.[0]??`${table}_${column.name}_fkey`);if(live.names.has(name)){reports.push({kind:"name-conflict",table,name,detail:`cannot add the foreign key for ${table}.${column.name} as ${name}: that name is taken`});continue}const actionsSql=[ref.onDelete?` ON DELETE ${foreignKeyAction(ref.onDelete)}`:"",ref.onUpdate?` ON UPDATE ${foreignKeyAction(ref.onUpdate)}`:""].join("");foreignKeys.push({kind:"add-foreign-key",table,name,detail:`add foreign key ${table}.${column.name} -> ${refTable}.${refColumn}`,sql:`ALTER TABLE ${quote(table)} ADD CONSTRAINT ${quote(name)} FOREIGN KEY (${quote(column.name)}) REFERENCES ${quote(refTable)} (${quote(refColumn)})${actionsSql}`,check:{kind:"orphans",sql:`SELECT COUNT(*) AS n FROM ${quote(table)} AS child WHERE child.${quote(column.name)} IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${quote(refTable)} AS parent WHERE parent.${quote(refColumn)} = child.${quote(column.name)})`}})}}return{actions:[...actions,...renames,...creates,...foreignKeys],reports,refused:[]}}function rows(result){return Array.isArray(result)?result:[]}function text(row,key){const value=row?.[key];return value===null||value===void 0?"":String(value)}export async function readPostgresAlignSchema(run){const tables=new Map;for(const row of rows(await run("SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_type = 'BASE TABLE'")))tables.set(text(row,"table_name").toLowerCase(),new Map);for(const row of rows(await run("SELECT table_name, column_name, data_type, udt_name, character_maximum_length, is_nullable, column_default FROM information_schema.columns WHERE table_schema = current_schema()"))){const columns=tables.get(text(row,"table_name").toLowerCase());if(!columns)continue;const length=Number(row.character_maximum_length);columns.set(text(row,"column_name").toLowerCase(),{name:text(row,"column_name").toLowerCase(),dataType:text(row,"data_type").toLowerCase()==="user-defined"?"USER-DEFINED":text(row,"data_type").toLowerCase(),udtName:text(row,"udt_name").toLowerCase(),...Number.isFinite(length)&&length>0?{maxLength:length}:{},nullable:text(row,"is_nullable").toUpperCase()==="YES",...row.column_default!==null&&row.column_default!==void 0?{default:String(row.column_default)}:{}})}const indexes=[];for(const row of rows(await run(`
2
2
  SELECT t.relname AS table_name, i.relname AS index_name, ix.indisunique AS is_unique, ix.indisprimary AS is_primary,
3
3
  pg_get_indexdef(ix.indexrelid) AS definition, con.conname AS constraint_name
4
4
  FROM pg_index ix
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.143",
5
+ "version": "0.75.145",
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.143",
70
- "@stacksjs/env": "0.75.143",
71
- "@stacksjs/error-handling": "0.75.143",
72
- "@stacksjs/faker": "0.75.143",
73
- "@stacksjs/features": "0.75.143",
74
- "@stacksjs/logging": "0.75.143",
75
- "@stacksjs/model-meta": "0.75.143",
76
- "@stacksjs/path": "0.75.143",
77
- "@stacksjs/query-builder": "0.75.143",
78
- "@stacksjs/security": "0.75.143",
79
- "@stacksjs/storage": "0.75.143",
80
- "@stacksjs/strings": "0.75.143",
69
+ "@stacksjs/config": "0.75.145",
70
+ "@stacksjs/env": "0.75.145",
71
+ "@stacksjs/error-handling": "0.75.145",
72
+ "@stacksjs/faker": "0.75.145",
73
+ "@stacksjs/features": "0.75.145",
74
+ "@stacksjs/logging": "0.75.145",
75
+ "@stacksjs/model-meta": "0.75.145",
76
+ "@stacksjs/path": "0.75.145",
77
+ "@stacksjs/query-builder": "0.75.145",
78
+ "@stacksjs/security": "0.75.145",
79
+ "@stacksjs/storage": "0.75.145",
80
+ "@stacksjs/strings": "0.75.145",
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.143",
87
- "@stacksjs/router": "0.75.143",
88
- "@stacksjs/utils": "0.75.143",
86
+ "@stacksjs/cli": "0.75.145",
87
+ "@stacksjs/router": "0.75.145",
88
+ "@stacksjs/utils": "0.75.145",
89
89
  "better-dx": "^0.2.24"
90
90
  }
91
91
  }