@stacksjs/database 0.75.141 → 0.75.143

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.
@@ -0,0 +1,25 @@
1
+ /** Whether a migration's SQL starts with a generator's marker. */
2
+ export declare function carriesGeneratedMarker(sql: string): boolean;
3
+ /**
4
+ * The opt-out, from `database.safety.allowHandWrittenMigrations` or the
5
+ * `DB_ALLOW_HAND_WRITTEN_MIGRATIONS` env var (`1`/`true` allows all; a
6
+ * comma-separated list allows those files).
7
+ */
8
+ export declare function handWrittenMigrationPolicy(configured?: boolean | readonly string[], envValue?: string | undefined): HandWrittenMigrationPolicy;
9
+ /**
10
+ * The app-owned `.sql` files in a corpus directory that carry no generator
11
+ * marker, minus any the policy allows. Sorted, in run order.
12
+ */
13
+ export declare function findHandWrittenMigrations(dir: string, policy?: HandWrittenMigrationPolicy): string[];
14
+ /**
15
+ * The configured corpus's hand-written migrations, under the configured
16
+ * policy. Never throws: a check that breaks `migrate` is worse than one that
17
+ * does not run.
18
+ */
19
+ export declare function handWrittenMigrations(): Promise<{ dir: string, files: string[] }>;
20
+ /** The marker every generator writes as the first line of a migration it emits. */
21
+ export declare const QB_GENERATED_MARKER: '-- qb:generated';
22
+ export declare interface HandWrittenMigrationPolicy {
23
+ allowAll: boolean
24
+ allowed: ReadonlySet<string>
25
+ }
@@ -0,0 +1 @@
1
+ import{readdirSync,readFileSync}from"node:fs";import{join}from"node:path";import process from"node:process";import{isPackageMigration}from"./package-migrations";export const QB_GENERATED_MARKER="-- qb:generated";export function carriesGeneratedMarker(sql){const head=sql.slice(0,400);return head.includes(QB_GENERATED_MARKER)||head.includes("@generated by `buddy migrate:regenerate`")}export function handWrittenMigrationPolicy(configured,envValue=process.env.DB_ALLOW_HAND_WRITTEN_MIGRATIONS){const env=envValue?.trim();if(env){if(/^(?:1|true|yes|on)$/i.test(env))return{allowAll:!0,allowed:new Set};if(!/^(?:0|false|no|off)$/i.test(env))return{allowAll:!1,allowed:new Set(env.split(",").map((name)=>name.trim()).filter(Boolean))}}if(configured===!0)return{allowAll:!0,allowed:new Set};return{allowAll:!1,allowed:new Set(Array.isArray(configured)?configured:[])}}const logicalName=(file)=>file.replace(/^\d+[-_]/,"").replace(/\.sql$/i,"");export function findHandWrittenMigrations(dir,policy={allowAll:!1,allowed:new Set}){if(policy.allowAll)return[];let files;try{files=readdirSync(dir).filter((file)=>file.toLowerCase().endsWith(".sql")).sort()}catch{return[]}return files.filter((file)=>{if(isPackageMigration(file))return!1;if(policy.allowed.has(file)||policy.allowed.has(logicalName(file)))return!1;try{return!carriesGeneratedMarker(readFileSync(join(dir,file),"utf8"))}catch{return!1}})}export async function handWrittenMigrations(){try{const{activeMigrationDirectory}=await import("./migrations"),dir=await activeMigrationDirectory();let configured;try{const{config,overridesReady}=await import("@stacksjs/config");await overridesReady;configured=config?.database?.safety?.allowHandWrittenMigrations}catch{}return{dir,files:findHandWrittenMigrations(dir,handWrittenMigrationPolicy(configured))}}catch{return{dir:"",files:[]}}}
@@ -0,0 +1,37 @@
1
+ export declare function indexShape(statement: string): IndexShape | undefined;
2
+ /**
3
+ * A Postgres catalog index definition rewritten into the shape a migration
4
+ * writes, so {@link indexShape} reads both the same way.
5
+ *
6
+ * `pg_get_indexdef` / `pg_indexes.indexdef` qualify the table with its schema
7
+ * and name the access method: `CREATE UNIQUE INDEX x ON public.users USING
8
+ * btree (email)`. A migration says neither, so both come off. Only the default
9
+ * `btree` method is dropped; any other method stays in the key, because a GIN
10
+ * index and a btree index over the same column are not the same index.
11
+ */
12
+ export declare function postgresIndexStatement(definition: string): string;
13
+ /**
14
+ * An index key two different spellings of the same index agree on.
15
+ *
16
+ * Stricter than nothing and looser than {@link indexShape}'s `key`: the
17
+ * catalog hands back what the server parsed, not what was written, so a
18
+ * predicate written `WHERE deleted_at IS NULL` comes back as
19
+ * `WHERE (deleted_at IS NULL)` and `status = 'active'` as
20
+ * `((status)::text = 'active'::text)`. Casts and brackets are dropped on both
21
+ * sides; names, operators and literals are kept, so two indexes that differ in
22
+ * anything that changes which rows they cover still differ here.
23
+ */
24
+ export declare function comparableIndexKey(statement: string): string | undefined;
25
+ /**
26
+ * What a `CREATE INDEX` builds, apart from what it calls it.
27
+ *
28
+ * `key` is the table, whether the index is unique, its column list (with any
29
+ * sort order) and its `WHERE` clause, normalized - everything that decides what
30
+ * the index does, and nothing that only names it. Two statements with the same
31
+ * key build the same index.
32
+ */
33
+ export declare interface IndexShape {
34
+ name: string
35
+ table: string
36
+ key: string
37
+ }
@@ -0,0 +1 @@
1
+ const unquoteIdentifiers=(sql)=>sql.replace(/["'`[\]]/g,""),collapseWhitespace=(sql)=>sql.replace(/\s+/g," ").trim();export function indexShape(statement){const head=/^\s*CREATE\s+(UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?["'`[]?([\w$]+)["'`\]]?\s+ON\s+["'`[]?([\w$]+)["'`\]]?\s*\(/i.exec(statement);if(!head?.[2]||!head[3])return;let depth=1,end=head[0].length;for(;end<statement.length&&depth>0;end++)if(statement[end]==="(")depth++;else if(statement[end]===")")depth--;if(depth>0)return;const columns=statement.slice(head[0].length,end-1).split(",").map((column)=>collapseWhitespace(unquoteIdentifiers(column)).toLowerCase().replace(/\s+asc$/,"")),where=collapseWhitespace(unquoteIdentifiers(statement.slice(end)).replace(/;\s*$/,"")).toLowerCase(),table=head[3].toLowerCase();return{name:head[2].toLowerCase(),table,key:`${head[1]?"unique":"index"} ${table} (${columns.join(", ")})${where?` ${where}`:""}`}}export function postgresIndexStatement(definition){return definition.replace(/\bON\s+ONLY\s+/i,"ON ").replace(/\bON\s+(?:"[^"]+"|\w+)\.("?[\w$]+"?)/i,"ON $1").replace(/\s+USING\s+btree\s*\(/i," (")}export function comparableIndexKey(statement){const shape=indexShape(statement);if(!shape)return;return collapseWhitespace(shape.key.replace(/::\s*(?:"[^"]+"|[a-z_]\w*)(?:\s+varying|\s+precision|\s+with(?:out)?\s+time\s+zone)?(?:\[\])?/gi,"").replace(/[()]/g," ").replace(/\s*,\s*/g,", "))}
package/dist/index.d.ts CHANGED
@@ -150,6 +150,9 @@ export * from './migration-path';
150
150
  // `migrations` table, and the live schema, because regeneration renumbers files
151
151
  // and the ledger keys on the filename.
152
152
  export * from './migration-ledger';
153
+ export * from './schema-align';
154
+ export * from './hand-written-migrations';
155
+ export { comparableIndexKey, postgresIndexStatement } from './index-shape';
153
156
  // Model resolution for the generator: userland + framework defaults, flattened
154
157
  // because bun-query-builder's loadModels reads only the top level of a dir.
155
158
  export * from './model-sources';
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export{Database,createDatabase,createMysqlDatabase,createPostgresDatabase,createSqliteDatabase,createTursoDatabase}from"./database";export{detectDriver,driverDefaults,getConfigFromEnv,getConnectionString,mergeWithDefaults,validateDriverConfig}from"./driver-config";export*from"./utils";export*from"./row-lock";export{BUN_BROKEN_MYSQL_POOL_MESSAGE,BUN_BROKEN_POSTGRES_POOL_MESSAGE,getBrokenDatabasePools,isBrokenBunPoolError}from"./broken-pool";export*from"./types";export*from"./migrations";export*from"./framework-renames";export{setQueryTracker,logQuery}from"./query-logger";export{addColumnSafely,backfillInBatches,renameColumnSafely}from"./safe-migrations";export{matchedRows,mutationCount}from"./affected-rows";export*from"./seeder";export*from"./drivers";export*from"./custom";export*from"./auth-tables";export*from"./utc-defaults";export*from"./uuid-columns";export*from"./managed-columns";export*from"./relation-columns";export{ensureNotificationForeignKeys,migrateNotificationTables}from"./notification-tables";export{migrateRbacTables}from"./rbac-tables";export*from"./trait-tables";export*from"./datetime-columns";export*from"./dialect";export*from"./replicas";export*from"./ddl-constraints";export*from"./erd";export*from"./vschema";export*from"./sql-helpers";export*from"./defaults";export*from"./migration-dialect";export*from"./migration-path";export*from"./migration-ledger";export*from"./model-sources";export*from"./package-migrations";export*from"./package-models";export*from"./shadowed-models";export*from"./ensure-database";export{auditForeignKeys,classifyDeclaredFKs,findFkOrphans,fkKey,getDeclaredFKs,getLiveFKs,getLiveTables}from"./fk-audit";export{auditSchemaDrift,formatSchemaDrift}from"./schema-drift";export{describeIgnoreRule,findIgnoredMigrations,formatIgnoredMigrations,trackIgnoredGeneratedMigrations}from"./ignored-migrations";export{auditUniqueIndexes,getDeclaredUniques,getLiveUniqueIndexes}from"./unique-audit";export{__flushAfterCommitNow,__pendingAfterCommitCount,enqueueAfterCommit,isInTransaction,runInTransactionScope}from"./transaction-context";export{createQueryBuilder,setConfig}from"@stacksjs/query-builder";export{createDynamo,dynamo,EntityQueryBuilder,generateKeyPattern,parseKeyPattern,buildKey,marshall,unmarshall}from"./drivers/dynamodb";
1
+ export{Database,createDatabase,createMysqlDatabase,createPostgresDatabase,createSqliteDatabase,createTursoDatabase}from"./database";export{detectDriver,driverDefaults,getConfigFromEnv,getConnectionString,mergeWithDefaults,validateDriverConfig}from"./driver-config";export*from"./utils";export*from"./row-lock";export{BUN_BROKEN_MYSQL_POOL_MESSAGE,BUN_BROKEN_POSTGRES_POOL_MESSAGE,getBrokenDatabasePools,isBrokenBunPoolError}from"./broken-pool";export*from"./types";export*from"./migrations";export*from"./framework-renames";export{setQueryTracker,logQuery}from"./query-logger";export{addColumnSafely,backfillInBatches,renameColumnSafely}from"./safe-migrations";export{matchedRows,mutationCount}from"./affected-rows";export*from"./seeder";export*from"./drivers";export*from"./custom";export*from"./auth-tables";export*from"./utc-defaults";export*from"./uuid-columns";export*from"./managed-columns";export*from"./relation-columns";export{ensureNotificationForeignKeys,migrateNotificationTables}from"./notification-tables";export{migrateRbacTables}from"./rbac-tables";export*from"./trait-tables";export*from"./datetime-columns";export*from"./dialect";export*from"./replicas";export*from"./ddl-constraints";export*from"./erd";export*from"./vschema";export*from"./sql-helpers";export*from"./defaults";export*from"./migration-dialect";export*from"./migration-path";export*from"./migration-ledger";export*from"./schema-align";export*from"./hand-written-migrations";export{comparableIndexKey,postgresIndexStatement}from"./index-shape";export*from"./model-sources";export*from"./package-migrations";export*from"./package-models";export*from"./shadowed-models";export*from"./ensure-database";export{auditForeignKeys,classifyDeclaredFKs,findFkOrphans,fkKey,getDeclaredFKs,getLiveFKs,getLiveTables}from"./fk-audit";export{auditSchemaDrift,formatSchemaDrift}from"./schema-drift";export{describeIgnoreRule,findIgnoredMigrations,formatIgnoredMigrations,trackIgnoredGeneratedMigrations}from"./ignored-migrations";export{auditUniqueIndexes,getDeclaredUniques,getLiveUniqueIndexes}from"./unique-audit";export{__flushAfterCommitNow,__pendingAfterCommitCount,enqueueAfterCommit,isInTransaction,runInTransactionScope}from"./transaction-context";export{createQueryBuilder,setConfig}from"@stacksjs/query-builder";export{createDynamo,dynamo,EntityQueryBuilder,generateKeyPattern,parseKeyPattern,buildKey,marshall,unmarshall}from"./drivers/dynamodb";
@@ -152,6 +152,8 @@ export declare function reconcileMigrationLedger(options?: {
152
152
  dialect?: LedgerDialect
153
153
 
154
154
  run?: SqlRunner
155
+
156
+ regenerated?: { removed: readonly string[], written: readonly string[] }
155
157
  }): Promise<ReconcileResult>;
156
158
  /** `["`[]?ident["`\]]?` — accepts every identifier quoting style in play. */
157
159
  declare const IDENT: string;
@@ -168,6 +170,7 @@ export declare interface MigrationEffect {
168
170
  kind: 'table' | 'column' | 'index' | 'constraint' | 'enum'
169
171
  table?: string
170
172
  name: string
173
+ shape?: string
171
174
  }
172
175
  export declare interface MigrationLedgerEntry {
173
176
  file: string
@@ -208,6 +211,7 @@ export declare interface LiveSchema {
208
211
  tables: Set<string>
209
212
  columns: Map<string, Set<string>>
210
213
  indexes: Set<string>
214
+ indexShapes: Set<string>
211
215
  constraints: Set<string>
212
216
  enums: Set<string>
213
217
  }
@@ -216,6 +220,7 @@ export declare interface ReconcileResult {
216
220
  recorded: string[]
217
221
  pruned: string[]
218
222
  requeued: string[]
223
+ retired: string[]
219
224
  skipped: Array<{ file: string, reason: string }>
220
225
  }
221
226
  /**
@@ -1 +1 @@
1
- import{existsSync,readdirSync,readFileSync}from"node:fs";import process from"node:process";import{join}from"node:path";import{resolveMigrationDirectory}from"./migration-path";const SAFE_MIGRATION_FILE=/^[\w.-]+\.sql$/,IDENT=String.raw`["\`\[]?([A-Za-z_]\w*)["\`\]]?`;export function stripForEffects(sql){let out="",i=0;const blank=(text)=>text.replace(/[^\n]/g," ");while(i<sql.length){const rest=sql.slice(i),line=rest.match(/^--[^\n]*/);if(line){out+=blank(line[0]);i+=line[0].length;continue}if(rest.startsWith("/*")){const end=rest.indexOf("*/"),chunk=end===-1?rest:rest.slice(0,end+2);out+=blank(chunk);i+=chunk.length;continue}if(rest[0]==="'"){let j=1;while(j<rest.length&&rest[j]!=="'")j++;const chunk=rest.slice(0,Math.min(j+1,rest.length));out+=blank(chunk);i+=chunk.length;continue}out+=sql[i];i+=1}return out}function statementsOf(sql){return stripForEffects(sql).split(";").map((s)=>s.trim()).filter((s)=>s.length>0)}export function logicalName(file){return file.replace(/^\d+[-_]/,"").replace(/\.sql$/i,"")}export function migrationRemovals(sql){const removals=[],seen=new Set,push=(effect)=>{const key=effectKey(effect);if(seen.has(key))return;seen.add(key);removals.push(effect)};for(const statement of statementsOf(sql)){const index=new RegExp(String.raw`^DROP\s+INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+EXISTS\s+)?${IDENT}`,"i").exec(statement);if(index?.[1]){push({kind:"index",name:index[1]});continue}const table=new RegExp(String.raw`^DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?${IDENT}`,"i").exec(statement);if(table?.[1]){push({kind:"table",name:table[1]});continue}const enumType=new RegExp(String.raw`^DROP\s+TYPE\s+(?:IF\s+EXISTS\s+)?${IDENT}`,"i").exec(statement);if(enumType?.[1]){push({kind:"enum",name:enumType[1]});continue}const alter=new RegExp(String.raw`^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?${IDENT}\s+(.*)$`,"is").exec(statement);if(!alter?.[1])continue;const owner=alter[1];for(const clause of(alter[2]??"").split(",")){const constraint=new RegExp(String.raw`^\s*DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?${IDENT}`,"i").exec(clause);if(constraint?.[1]){push({kind:"constraint",table:owner,name:constraint[1]});continue}const column=new RegExp(String.raw`^\s*DROP\s+(?:COLUMN\s+)?(?:IF\s+EXISTS\s+)?${IDENT}`,"i").exec(clause);if(column?.[1]&&!/^(?:constraint|index|key|primary|foreign|unique|check|default|partition)$/i.test(column[1]))push({kind:"column",table:owner,name:column[1]})}}return removals}export function migrationEffects(sql){const effects=[],seen=new Set,renamedAway=new Set,push=(effect)=>{const key=effectKey(effect);if(seen.has(key))return;seen.add(key);effects.push(effect)};for(const statement of statementsOf(sql)){const create=new RegExp(String.raw`^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"i").exec(statement);if(create?.[1]){push({kind:"table",name:create[1]});continue}const rename=new RegExp(String.raw`^ALTER\s+TABLE\s+${IDENT}\s+RENAME\s+TO\s+${IDENT}`,"i").exec(statement);if(rename?.[2]){if(rename[1])renamedAway.add(rename[1].toLowerCase());push({kind:"table",name:rename[2]});continue}const index=new RegExp(String.raw`^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}(?:\s+ON\s+${IDENT})?`,"i").exec(statement);if(index?.[1]){push(index[2]?{kind:"index",name:index[1],table:index[2]}:{kind:"index",name:index[1]});continue}const enumType=new RegExp(String.raw`^CREATE\s+TYPE\s+${IDENT}\s+AS\s+ENUM`,"i").exec(statement);if(enumType?.[1]){push({kind:"enum",name:enumType[1]});continue}const alter=new RegExp(String.raw`^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?${IDENT}\s+(.*)$`,"is").exec(statement);if(!alter?.[1]||!alter[2])continue;const table=alter[1],addColumn=new RegExp(String.raw`\bADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"gi");for(const m of alter[2].matchAll(addColumn))if(m[1])push({kind:"column",table,name:m[1]});const addConstraint=new RegExp(String.raw`\bADD\s+CONSTRAINT\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"gi");for(const m of alter[2].matchAll(addConstraint))if(m[1])push({kind:"constraint",table,name:m[1]});const addBare=new RegExp(String.raw`\bADD\s+(?!COLUMN\b|CONSTRAINT\b|INDEX\b|KEY\b|PRIMARY\b|UNIQUE\b|FOREIGN\b|FULLTEXT\b|SPATIAL\b|CHECK\b)${IDENT}\s+\w`,"gi");for(const m of alter[2].matchAll(addBare))if(m[1])push({kind:"column",table,name:m[1]})}if(renamedAway.size===0)return effects;return effects.filter((effect)=>{const owner=(effect.kind==="table"?effect.name:effect.table??"").toLowerCase();return!renamedAway.has(owner)})}function effectKey(effect){return`${effect.kind}:${(effect.table??"").toLowerCase()}.${effect.name.toLowerCase()}`}function removalKey(effect){if(effect.kind==="column"||effect.kind==="constraint")return effectKey(effect);return`${effect.kind}:${effect.name.toLowerCase()}`}export function verifiableEffects(effects,dialect){if(dialect==="postgres")return effects;if(dialect==="mysql")return effects.filter((e)=>e.kind!=="enum");return effects.filter((e)=>e.kind!=="constraint"&&e.kind!=="enum")}function emptySchema(){return{tables:new Set,columns:new Map,indexes:new Set,constraints:new Set,enums:new Set}}function rowsOf(result){return Array.isArray(result)?result:[]}async function defaultRunner(){const{db}=await import("./utils");return async(sql)=>rowsOf(await db.unsafe(sql).execute())}function pick(row,...keys){for(const key of keys){const value=row?.[key]??row?.[key.toLowerCase()]??row?.[key.toUpperCase()];if(typeof value==="string"&&value.length>0)return value}return""}export async function readLiveSchema(dialect,runner){const schema=emptySchema(),run=runner??await defaultRunner();if(dialect==="sqlite"){for(const row of await run("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")){const name=pick(row,"name");if(name)schema.tables.add(name.toLowerCase())}for(const row of await run("SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'")){const name=pick(row,"name");if(name)schema.indexes.add(name.toLowerCase())}for(const table of schema.tables){if(!/^[a-z_]\w*$/i.test(table))continue;const cols=new Set;for(const row of await run(`PRAGMA table_info("${table}")`)){const name=pick(row,"name");if(name)cols.add(name.toLowerCase())}schema.columns.set(table,cols)}return schema}if(dialect==="mysql"){for(const row of await run("SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()")){const name=pick(row,"name","TABLE_NAME");if(name)schema.tables.add(name.toLowerCase())}for(const row of await run("SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()")){const table=pick(row,"TABLE_NAME").toLowerCase(),column=pick(row,"COLUMN_NAME").toLowerCase();if(!table||!column)continue;if(!schema.columns.has(table))schema.columns.set(table,new Set);schema.columns.get(table).add(column)}for(const row of await run("SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE()")){const name=pick(row,"name","INDEX_NAME");if(name)schema.indexes.add(name.toLowerCase())}for(const row of await run("SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE()")){const name=pick(row,"name","CONSTRAINT_NAME");if(name)schema.constraints.add(name.toLowerCase())}return schema}for(const row of await run("SELECT tablename AS name FROM pg_tables WHERE schemaname = 'public'")){const name=pick(row,"name","tablename");if(name)schema.tables.add(name.toLowerCase())}for(const row of await run("SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public'")){const table=pick(row,"table_name").toLowerCase(),column=pick(row,"column_name").toLowerCase();if(!table||!column)continue;if(!schema.columns.has(table))schema.columns.set(table,new Set);schema.columns.get(table).add(column)}for(const row of await run("SELECT indexname AS name FROM pg_indexes WHERE schemaname = 'public'")){const name=pick(row,"name","indexname");if(name)schema.indexes.add(name.toLowerCase())}for(const row of await run("SELECT c.conname AS name FROM pg_constraint c JOIN pg_namespace n ON n.oid = c.connamespace WHERE n.nspname = 'public'")){const name=pick(row,"name","conname");if(name)schema.constraints.add(name.toLowerCase())}for(const row of await run("SELECT t.typname AS name FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE t.typtype = 'e' AND n.nspname = 'public'")){const name=pick(row,"name","typname");if(name)schema.enums.add(name.toLowerCase())}return schema}export function effectPresent(effect,schema){const name=effect.name.toLowerCase();switch(effect.kind){case"table":return schema.tables.has(name);case"column":return schema.columns.get((effect.table??"").toLowerCase())?.has(name)??!1;case"index":{if(schema.indexes.has(name))return!0;const table=(effect.table??"").toLowerCase();if(!table)return!1;return schema.indexes.has(`${table}_${name}`)}case"constraint":return schema.constraints.has(name);case"enum":return schema.enums.has(name)}}export function classifyMigration(recorded,present,absent){const verifiable=present.length+absent.length;if(recorded){if(verifiable===0||absent.length===0)return"applied";return"reverted"}if(verifiable===0)return"unverifiable";if(absent.length===0)return"stranded";if(present.length===0)return"pending";return"partial"}function migrationsDir(dir){if(dir)return dir;const driver=String(process.env.DB_CONNECTION||"sqlite").toLowerCase();return resolveMigrationDirectory(driver==="singlestore"?"singlestore":driver)}function listMigrationFiles(dir){if(!existsSync(dir))return[];try{return readdirSync(dir).filter((f)=>f.toLowerCase().endsWith(".sql")).sort()}catch{return[]}}async function currentDialect(){const driver=((await import("@stacksjs/env")).env?.DB_CONNECTION??"sqlite").toLowerCase();if(driver==="sqlite"||driver==="mysql"||driver==="postgres")return driver;if(driver==="vitess"||driver==="singlestore")return"mysql";if(driver==="turso"||driver==="libsql")return"sqlite";return"other"}export async function readLedger(runner){try{return(await(runner??await defaultRunner())("SELECT migration FROM migrations")).map((row)=>pick(row,"migration")).filter((name)=>name.length>0).sort()}catch{return[]}}export function planLedgerRemap(ledger,diskFiles){const onDisk=new Set(diskFiles),byLogical=new Map;for(const file of diskFiles){const key=logicalName(file);if(!byLogical.has(key))byLogical.set(key,[]);byLogical.get(key).push(file)}const claimed=new Set(ledger.filter((row)=>onDisk.has(row))),remap=[],ambiguous=[],dropped=[],superseded=[],targets=new Map;for(const row of ledger){if(onDisk.has(row))continue;const sameLogical=byLogical.get(logicalName(row))??[],candidates=sameLogical.filter((f)=>!claimed.has(f));if(candidates.length===0){if(sameLogical.length>0)superseded.push(row);else dropped.push(row);continue}if(candidates.length>1){ambiguous.push(row);continue}const to=candidates[0];if(!targets.has(to))targets.set(to,[]);targets.get(to).push(row);remap.push({from:row,to})}const contested=new Set([...targets.entries()].filter(([,rows])=>rows.length>1).flatMap(([,rows])=>rows));if(contested.size===0)return{remap,ambiguous,dropped,superseded};return{remap:remap.filter((r)=>!contested.has(r.from)),ambiguous:[...ambiguous,...contested].sort(),dropped,superseded}}export async function auditMigrationLedger(options={}){const dir=migrationsDir(options.dir),dialect=options.dialect??await currentDialect(),files=listMigrationFiles(dir),counts={applied:0,stranded:0,pending:0,partial:0,unverifiable:0,reverted:0},emptyPlan={remap:[],ambiguous:[],dropped:[],superseded:[]};if(dialect==="other")return{supported:!1,dialect,dir,entries:[],orphans:[],gated:[],counts,recordedCount:0,remapPlan:emptyPlan,drift:!1};const run=options.run??await defaultRunner(),ledger=await readLedger(run),recorded=new Set(ledger),schema=await readLiveSchema(dialect,run),gate=await resolveGate(options,dir),sources=new Map,gated=files.filter((file)=>gate?.exclude.has(file));for(const file of files){if(gate?.exclude.has(file))continue;try{const sql=readFileSync(join(dir,file),"utf8");sources.set(file,gate?gate.rewrite(sql):sql)}catch{continue}}const readFiles=[...sources.keys()],removedLater=new Map,tablesDroppedLater=new Map;for(let i=0;i<readFiles.length;i++){const laterKeys=new Set,laterTables=new Set;for(let j=i+1;j<readFiles.length;j++)for(const removal of migrationRemovals(sources.get(readFiles[j]))){laterKeys.add(removalKey(removal));if(removal.kind==="table")laterTables.add(removal.name.toLowerCase())}removedLater.set(readFiles[i],laterKeys);tablesDroppedLater.set(readFiles[i],laterTables)}const entries=[];for(const file of readFiles){const sql=sources.get(file),effects=verifiableEffects(migrationEffects(sql),dialect),dropped=removedLater.get(file)??new Set,droppedTables=tablesDroppedLater.get(file)??new Set,survives=(effect)=>effectPresent(effect,schema)||dropped.has(removalKey(effect))||effect.table!==void 0&&droppedTables.has(effect.table.toLowerCase()),present=effects.filter(survives),absent=effects.filter((effect)=>!survives(effect)),isRecorded=recorded.has(file),status=classifyMigration(isRecorded,present,absent);counts[status]+=1;entries.push({file,logical:logicalName(file),recorded:isRecorded,status,effects,present,absent})}const readable=[...entries.map((entry)=>entry.file),...gated].sort(),remapPlan=planLedgerRemap(ledger,readable),renamedTo=new Map(remapPlan.remap.map((r)=>[r.from,r.to])),orphans=ledger.filter((row)=>!readable.includes(row)).map((row)=>({migration:row,renamedTo:renamedTo.get(row)})),drift=counts.stranded>0||counts.partial>0||counts.reverted>0||orphans.length>0;return{supported:!0,dialect,dir,entries,orphans,gated,counts,recordedCount:ledger.length,remapPlan,drift}}async function ensureLedgerTable(dialect,run){const id=dialect==="postgres"?"id SERIAL PRIMARY KEY":dialect==="mysql"?"id INT AUTO_INCREMENT PRIMARY KEY":"id INTEGER PRIMARY KEY AUTOINCREMENT",timestamp=dialect==="postgres"?"TIMESTAMP":"DATETIME",utcNow=dialect==="postgres"?"(now() AT TIME ZONE 'utc')":dialect==="mysql"?"(UTC_TIMESTAMP)":"CURRENT_TIMESTAMP";await run(`CREATE TABLE IF NOT EXISTS migrations (${id}, migration VARCHAR(255) NOT NULL UNIQUE, executed_at ${timestamp} DEFAULT ${utcNow})`);if(dialect!=="sqlite")try{await run(`ALTER TABLE migrations ALTER COLUMN executed_at SET DEFAULT ${utcNow}`)}catch{}}const Q=(pattern)=>new RegExp(pattern.replaceAll("{id}",IDENT),"i"),CREATE_TABLE=Q(String.raw`^CREATE\s+(?:TEMP(?:ORARY)?\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?{id}`),DROP_TABLE=Q(String.raw`^DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?{id}`),RENAME_TABLE=Q(String.raw`^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?{id}\s+RENAME\s+TO\s+{id}`),ALTER_TABLE=Q(String.raw`^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?{id}`),CREATE_INDEX=Q(String.raw`^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?{id}\s+ON\s+(?:ONLY\s+)?{id}`),DROP_INDEX=Q(String.raw`^DROP\s+INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+EXISTS\s+)?{id}(?:\s+ON\s+{id})?`),DML=Q(String.raw`^(?:INSERT\s+(?:OR\s+\w+\s+)?INTO|REPLACE\s+INTO|UPDATE|DELETE\s+FROM)\s+{id}`),NEUTRAL=/^(?:BEGIN|COMMIT|END|PRAGMA|SET|SELECT\s+1|COMMENT\s+ON|CREATE\s+TYPE\s+\S+\s+AS\s+ENUM|ALTER\s+TYPE\s+\S+\s+ADD\s+VALUE|STACKS_ENUM_GUARD)\b/i;function statementShape(statement,indexTables){const lower=(name)=>(name??"").toLowerCase();if(NEUTRAL.test(statement))return{modifies:[]};let m=RENAME_TABLE.exec(statement);if(m)return{modifies:[lower(m[1]),lower(m[2])],rename:{from:lower(m[1]),to:lower(m[2])}};m=CREATE_TABLE.exec(statement);if(m)return{modifies:[lower(m[1])],creates:lower(m[1])};m=DROP_TABLE.exec(statement);if(m)return{modifies:[lower(m[1])],drops:lower(m[1])};m=ALTER_TABLE.exec(statement);if(m)return{modifies:[lower(m[1])]};m=CREATE_INDEX.exec(statement);if(m)return{modifies:[lower(m[2])],index:{name:lower(m[1]),table:lower(m[2])}};m=DROP_INDEX.exec(statement);if(m){const table=m[2]?lower(m[2]):indexTables.get(lower(m[1]));return table?{modifies:[table]}:{modifies:[],unknown:!0}}m=DML.exec(statement);if(m)return{modifies:[lower(m[1])]};return{modifies:[],unknown:!0}}function requeueStatements(sql){return stripForEffects(sql).replace(/\bDO\s+\$(\w*)\$([\s\S]*?)\$\1\$/gi,(block,_tag,body)=>/^\s*BEGIN\s+CREATE\s+TYPE\b[\s\S]*\bEXCEPTION\s+WHEN\s+duplicate_object\s+THEN\s+null\s*;\s*END\s*$/i.test(body)?"STACKS_ENUM_GUARD":block).split(";").map((statement)=>statement.trim()).filter(Boolean)}async function resolveGate(options,dir){if(options.gate||options.dir)return options.gate;try{const{featureGateForLedger}=await import("./migrations");return await featureGateForLedger(dir)}catch{return}}export function planRequeue(files,liveTables){const ordered=[...files].sort((a,b)=>a.file.localeCompare(b.file)),indexTables=new Map,expected=new Set,lineageOf=new Map,lineage=(table)=>{let group=lineageOf.get(table);if(!group){group=new Set([table]);lineageOf.set(table,group)}return group},link=(a,b)=>{const merged=new Set([...lineage(a),...lineage(b)]);for(const name of merged)lineageOf.set(name,merged)},shapes=ordered.map(({file,sql,recorded})=>{const statements=requeueStatements(sql).map((statement)=>statementShape(statement,indexTables));for(const shape of statements){if(shape.index)indexTables.set(shape.index.name,shape.index.table);if(shape.creates)expected.add(shape.creates);if(shape.drops)expected.delete(shape.drops);if(shape.rename){expected.delete(shape.rename.from);expected.add(shape.rename.to);link(shape.rename.from,shape.rename.to)}}const created=new Set(statements.flatMap((shape)=>shape.creates?[shape.creates]:[])),removed=new Set(statements.flatMap((shape)=>[shape.drops,shape.rename?.from].filter((t)=>Boolean(t)))),scaffold=new Set([...created].filter((table)=>removed.has(table)||statements.some((shape)=>shape.rename?.from===table)));return{file,recorded,unknown:statements.some((shape)=>shape.unknown),modifies:new Set(statements.flatMap((shape)=>shape.modifies)),scaffold}}),missing=[...expected].filter((table)=>!liveTables.has(table)).sort(),rebuildable=new Set(missing.flatMap((table)=>[...lineage(table)]).filter((table)=>!liveTables.has(table))),chains=new Map(missing.map((table)=>{const names=lineage(table);return[table,shapes.filter((shape)=>[...shape.modifies].some((name)=>names.has(name)))]})),why=new Map;for(const[table,chain]of chains)for(const shape of chain){if(shape.unknown){why.set(table,`${shape.file} contains a statement the planner cannot classify`);break}const elsewhere=[...shape.modifies].filter((t)=>!rebuildable.has(t)&&!shape.scaffold.has(t));if(elsewhere.length>0){why.set(table,`${shape.file} also changes ${elsewhere.join(", ")}, which ${elsewhere.length===1?"is":"are"} not missing`);break}}let changed=!0;while(changed){changed=!1;for(const[table,chain]of chains){if(why.has(table))continue;const blocker=chain.flatMap((shape)=>[...shape.modifies]).find((other)=>other!==table&&missing.some((t)=>why.has(t)&&lineage(t).has(other)));if(blocker){why.set(table,`shares a migration with ${blocker}, which cannot be rebuilt`);changed=!0}}}const requeue=new Set;for(const[table,chain]of chains){if(why.has(table))continue;for(const shape of chain)if(shape.recorded)requeue.add(shape.file)}return{missing,requeue:ordered.map(({file})=>file).filter((file)=>requeue.has(file)),refused:[...why].map(([table,reason])=>({table,reason}))}}export async function auditMissingTables(options={}){const dialect=options.dialect??await currentDialect();if(dialect==="other")return{supported:!1,missing:[],live:new Set};const dir=migrationsDir(options.dir),run=options.run??await defaultRunner(),recorded=new Set(await readLedger(run)),gate=await resolveGate(options,dir),files=listMigrationFiles(dir).filter((file)=>recorded.has(file)&&!gate?.exclude.has(file)).flatMap((file)=>{try{const sql=readFileSync(join(dir,file),"utf8");return[{file,sql:gate?gate.rewrite(sql):sql,recorded:!0}]}catch{return[]}}),live=await readLiveSchema(dialect,run);return{supported:!0,missing:planRequeue(files,live.tables).missing,live:live.tables}}function byLogicalOnDisk(row,diskFiles){const key=logicalName(row),matches=diskFiles.filter((f)=>logicalName(f)===key);return matches.length===1?matches[0]:void 0}export async function reconcileMigrationLedger(options={}){const run=options.run??await defaultRunner(),gate=await resolveGate(options,migrationsDir(options.dir)),audit=await auditMigrationLedger({dir:options.dir,dialect:options.dialect,run,gate}),result={remapped:[],recorded:[],pruned:[],requeued:[],skipped:[]};if(!audit.supported){result.skipped.push({file:"*",reason:`dialect "${audit.dialect}" is not audited`});return result}const plan=audit.remapPlan;for(const row of plan.ambiguous)result.skipped.push({file:row,reason:"ledger row matches more than one file by logical name"});for(const row of plan.dropped)result.skipped.push({file:row,reason:"recorded migration no longer exists on disk"});const prunable=plan.superseded.filter((row)=>SAFE_MIGRATION_FILE.test(row));for(const row of plan.superseded.filter((row)=>!SAFE_MIGRATION_FILE.test(row)))result.skipped.push({file:row,reason:"ledger row is not safe to write to the ledger"});const toRecord=[],revertedEntries=[];for(const entry of audit.entries){if(entry.status==="stranded"){toRecord.push(entry.file);continue}if(entry.status==="partial"){if(options.includePartial){toRecord.push(entry.file);continue}result.skipped.push({file:entry.file,reason:`${entry.present.length}/${entry.effects.length} effects present - resolve by hand, or pass --include-partial`});continue}if(entry.status==="reverted")revertedEntries.push(entry)}const toRequeue=[];if(options.requeueReverted){const live=await readLiveSchema(audit.dialect,run),recordedFiles=new Set(audit.entries.filter((entry)=>entry.recorded).map((entry)=>entry.file)),corpus=audit.entries.flatMap((entry)=>{try{const sql=readFileSync(join(audit.dir,entry.file),"utf8");return[{file:entry.file,sql:gate?gate.rewrite(sql):sql,recorded:recordedFiles.has(entry.file)}]}catch{return[]}}),plan=planRequeue(corpus,live.tables);toRequeue.push(...plan.requeue);for(const{table,reason}of plan.refused)result.skipped.push({file:table,reason:`missing table not rebuilt: ${reason}`})}for(const entry of revertedEntries.filter((entry)=>!toRequeue.includes(entry.file)))result.skipped.push({file:entry.file,reason:options.requeueReverted?`recorded, but ${entry.absent.length} effect(s) are missing from the schema`:`recorded, but ${entry.absent.length} effect(s) are missing from the schema - --requeue-reverted rebuilds a table that is gone entirely`});const unsafe=(file)=>!SAFE_MIGRATION_FILE.test(file);for(const{from,to}of plan.remap.filter((r)=>unsafe(r.from)||unsafe(r.to)))result.skipped.push({file:unsafe(from)?from:to,reason:"migration filename is not safe to write to the ledger"});for(const file of toRecord.filter(unsafe))result.skipped.push({file,reason:"migration filename is not safe to write to the ledger"});const remapped=plan.remap.filter((r)=>!unsafe(r.from)&&!unsafe(r.to)),recordable=toRecord.filter((file)=>!unsafe(file)&&!remapped.some((r)=>r.to===file)),requeueable=toRequeue.filter((file)=>!unsafe(file));for(const file of toRequeue.filter(unsafe))result.skipped.push({file,reason:"migration filename is not safe to write to the ledger"});if(options.dryRun){result.remapped=remapped;result.recorded=recordable;result.pruned=prunable;result.requeued=requeueable;return result}await ensureLedgerTable(audit.dialect,run);for(const{from,to}of remapped){await run(`UPDATE migrations SET migration = '${to}' WHERE migration = '${from}'`);result.remapped.push({from,to})}for(const file of recordable){if((await run(`SELECT migration FROM migrations WHERE migration = '${file}'`)).length>0)continue;await run(`INSERT INTO migrations (migration) VALUES ('${file}')`);result.recorded.push(file)}for(const row of prunable){const survivor=byLogicalOnDisk(row,audit.entries.map((e)=>e.file));if(!survivor)continue;if((await run(`SELECT migration FROM migrations WHERE migration = '${survivor}'`)).length===0){result.skipped.push({file:row,reason:`would leave ${survivor} unrecorded`});continue}await run(`DELETE FROM migrations WHERE migration = '${row}'`);result.pruned.push(row)}for(const file of requeueable){await run(`DELETE FROM migrations WHERE migration = '${file}'`);result.requeued.push(file)}return result}
1
+ import{existsSync,readdirSync,readFileSync}from"node:fs";import process from"node:process";import{join}from"node:path";import{comparableIndexKey,postgresIndexStatement}from"./index-shape";import{resolveMigrationDirectory}from"./migration-path";import{sqlStatementsOf}from"./sql-statements";const SAFE_MIGRATION_FILE=/^[\w.-]+\.sql$/,IDENT=String.raw`["\`\[]?([A-Za-z_]\w*)["\`\]]?`;export function stripForEffects(sql){let out="",i=0;const blank=(text)=>text.replace(/[^\n]/g," ");while(i<sql.length){const rest=sql.slice(i),line=rest.match(/^--[^\n]*/);if(line){out+=blank(line[0]);i+=line[0].length;continue}if(rest.startsWith("/*")){const end=rest.indexOf("*/"),chunk=end===-1?rest:rest.slice(0,end+2);out+=blank(chunk);i+=chunk.length;continue}if(rest[0]==="'"){let j=1;while(j<rest.length&&rest[j]!=="'")j++;const chunk=rest.slice(0,Math.min(j+1,rest.length));out+=blank(chunk);i+=chunk.length;continue}out+=sql[i];i+=1}return out}function statementsOf(sql){return stripForEffects(sql).split(";").map((s)=>s.trim()).filter((s)=>s.length>0)}export function logicalName(file){return file.replace(/^\d+[-_]/,"").replace(/\.sql$/i,"")}export function migrationRemovals(sql){const removals=[],seen=new Set,push=(effect)=>{const key=effectKey(effect);if(seen.has(key))return;seen.add(key);removals.push(effect)};for(const statement of statementsOf(sql)){const index=new RegExp(String.raw`^DROP\s+INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+EXISTS\s+)?${IDENT}`,"i").exec(statement);if(index?.[1]){push({kind:"index",name:index[1]});continue}const table=new RegExp(String.raw`^DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?${IDENT}`,"i").exec(statement);if(table?.[1]){push({kind:"table",name:table[1]});continue}const enumType=new RegExp(String.raw`^DROP\s+TYPE\s+(?:IF\s+EXISTS\s+)?${IDENT}`,"i").exec(statement);if(enumType?.[1]){push({kind:"enum",name:enumType[1]});continue}const alter=new RegExp(String.raw`^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?${IDENT}\s+(.*)$`,"is").exec(statement);if(!alter?.[1])continue;const owner=alter[1];for(const clause of(alter[2]??"").split(",")){const constraint=new RegExp(String.raw`^\s*DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?${IDENT}`,"i").exec(clause);if(constraint?.[1]){push({kind:"constraint",table:owner,name:constraint[1]});continue}const column=new RegExp(String.raw`^\s*DROP\s+(?:COLUMN\s+)?(?:IF\s+EXISTS\s+)?${IDENT}`,"i").exec(clause);if(column?.[1]&&!/^(?:constraint|index|key|primary|foreign|unique|check|default|partition)$/i.test(column[1]))push({kind:"column",table:owner,name:column[1]})}}return removals}export function migrationEffects(sql){const effects=[],seen=new Set,renamedAway=new Set,push=(effect)=>{const key=effectKey(effect);if(seen.has(key))return;seen.add(key);effects.push(effect)},shapes=new Map;for(const statement of sqlStatementsOf(sql)){const match=/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?["`[]?([\w$]+)/i.exec(statement),key=match?.[1]?comparableIndexKey(statement):void 0;if(match?.[1]&&key)shapes.set(match[1].toLowerCase(),key)}for(const statement of statementsOf(sql)){const create=new RegExp(String.raw`^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"i").exec(statement);if(create?.[1]){push({kind:"table",name:create[1]});continue}const rename=new RegExp(String.raw`^ALTER\s+TABLE\s+${IDENT}\s+RENAME\s+TO\s+${IDENT}`,"i").exec(statement);if(rename?.[2]){if(rename[1])renamedAway.add(rename[1].toLowerCase());push({kind:"table",name:rename[2]});continue}const index=new RegExp(String.raw`^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}(?:\s+ON\s+${IDENT})?`,"i").exec(statement);if(index?.[1]){const shape=shapes.get(index[1].toLowerCase());push({kind:"index",name:index[1],...index[2]?{table:index[2]}:{},...shape?{shape}:{}});continue}const enumType=new RegExp(String.raw`^CREATE\s+TYPE\s+${IDENT}\s+AS\s+ENUM`,"i").exec(statement);if(enumType?.[1]){push({kind:"enum",name:enumType[1]});continue}const alter=new RegExp(String.raw`^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?${IDENT}\s+(.*)$`,"is").exec(statement);if(!alter?.[1]||!alter[2])continue;const table=alter[1],addColumn=new RegExp(String.raw`\bADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"gi");for(const m of alter[2].matchAll(addColumn))if(m[1])push({kind:"column",table,name:m[1]});const addConstraint=new RegExp(String.raw`\bADD\s+CONSTRAINT\s+(?:IF\s+NOT\s+EXISTS\s+)?${IDENT}`,"gi");for(const m of alter[2].matchAll(addConstraint))if(m[1])push({kind:"constraint",table,name:m[1]});const addBare=new RegExp(String.raw`\bADD\s+(?!COLUMN\b|CONSTRAINT\b|INDEX\b|KEY\b|PRIMARY\b|UNIQUE\b|FOREIGN\b|FULLTEXT\b|SPATIAL\b|CHECK\b)${IDENT}\s+\w`,"gi");for(const m of alter[2].matchAll(addBare))if(m[1])push({kind:"column",table,name:m[1]})}if(renamedAway.size===0)return effects;return effects.filter((effect)=>{const owner=(effect.kind==="table"?effect.name:effect.table??"").toLowerCase();return!renamedAway.has(owner)})}function effectKey(effect){return`${effect.kind}:${(effect.table??"").toLowerCase()}.${effect.name.toLowerCase()}`}function removalKey(effect){if(effect.kind==="column"||effect.kind==="constraint")return effectKey(effect);return`${effect.kind}:${effect.name.toLowerCase()}`}export function verifiableEffects(effects,dialect){if(dialect==="postgres")return effects;if(dialect==="mysql")return effects.filter((e)=>e.kind!=="enum");return effects.filter((e)=>e.kind!=="constraint"&&e.kind!=="enum")}function emptySchema(){return{tables:new Set,columns:new Map,indexes:new Set,indexShapes:new Set,constraints:new Set,enums:new Set}}function rowsOf(result){return Array.isArray(result)?result:[]}async function defaultRunner(){const{db}=await import("./utils");return async(sql)=>rowsOf(await db.unsafe(sql).execute())}function pick(row,...keys){for(const key of keys){const value=row?.[key]??row?.[key.toLowerCase()]??row?.[key.toUpperCase()];if(typeof value==="string"&&value.length>0)return value}return""}export async function readLiveSchema(dialect,runner){const schema=emptySchema(),run=runner??await defaultRunner();if(dialect==="sqlite"){for(const row of await run("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")){const name=pick(row,"name");if(name)schema.tables.add(name.toLowerCase())}for(const row of await run("SELECT name, sql FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'")){const name=pick(row,"name");if(name)schema.indexes.add(name.toLowerCase());const key=comparableIndexKey(pick(row,"sql"));if(key)schema.indexShapes.add(key)}for(const table of schema.tables){if(!/^[a-z_]\w*$/i.test(table))continue;const cols=new Set;for(const row of await run(`PRAGMA table_info("${table}")`)){const name=pick(row,"name");if(name)cols.add(name.toLowerCase())}schema.columns.set(table,cols)}return schema}if(dialect==="mysql"){for(const row of await run("SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()")){const name=pick(row,"name","TABLE_NAME");if(name)schema.tables.add(name.toLowerCase())}for(const row of await run("SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()")){const table=pick(row,"TABLE_NAME").toLowerCase(),column=pick(row,"COLUMN_NAME").toLowerCase();if(!table||!column)continue;if(!schema.columns.has(table))schema.columns.set(table,new Set);schema.columns.get(table).add(column)}for(const row of await run("SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE()")){const name=pick(row,"name","INDEX_NAME");if(name)schema.indexes.add(name.toLowerCase())}for(const row of await run("SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE()")){const name=pick(row,"name","CONSTRAINT_NAME");if(name)schema.constraints.add(name.toLowerCase())}return schema}for(const row of await run("SELECT tablename AS name FROM pg_tables WHERE schemaname = 'public'")){const name=pick(row,"name","tablename");if(name)schema.tables.add(name.toLowerCase())}for(const row of await run("SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public'")){const table=pick(row,"table_name").toLowerCase(),column=pick(row,"column_name").toLowerCase();if(!table||!column)continue;if(!schema.columns.has(table))schema.columns.set(table,new Set);schema.columns.get(table).add(column)}for(const row of await run("SELECT indexname AS name, indexdef AS definition FROM pg_indexes WHERE schemaname = 'public'")){const name=pick(row,"name","indexname");if(name)schema.indexes.add(name.toLowerCase());const key=comparableIndexKey(postgresIndexStatement(pick(row,"definition","indexdef")));if(key)schema.indexShapes.add(key)}for(const row of await run("SELECT c.conname AS name FROM pg_constraint c JOIN pg_namespace n ON n.oid = c.connamespace WHERE n.nspname = 'public'")){const name=pick(row,"name","conname");if(name)schema.constraints.add(name.toLowerCase())}for(const row of await run("SELECT t.typname AS name FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE t.typtype = 'e' AND n.nspname = 'public'")){const name=pick(row,"name","typname");if(name)schema.enums.add(name.toLowerCase())}return schema}export function effectPresent(effect,schema){const name=effect.name.toLowerCase();switch(effect.kind){case"table":return schema.tables.has(name);case"column":return schema.columns.get((effect.table??"").toLowerCase())?.has(name)??!1;case"index":{if(schema.indexes.has(name))return!0;if(effect.shape&&schema.indexShapes.has(effect.shape))return!0;const table=(effect.table??"").toLowerCase();if(!table)return!1;return schema.indexes.has(`${table}_${name}`)}case"constraint":return schema.constraints.has(name);case"enum":return schema.enums.has(name)}}export function classifyMigration(recorded,present,absent){const verifiable=present.length+absent.length;if(recorded){if(verifiable===0||absent.length===0)return"applied";return"reverted"}if(verifiable===0)return"unverifiable";if(absent.length===0)return"stranded";if(present.length===0)return"pending";return"partial"}function migrationsDir(dir){if(dir)return dir;const driver=String(process.env.DB_CONNECTION||"sqlite").toLowerCase();return resolveMigrationDirectory(driver==="singlestore"?"singlestore":driver)}function listMigrationFiles(dir){if(!existsSync(dir))return[];try{return readdirSync(dir).filter((f)=>f.toLowerCase().endsWith(".sql")).sort()}catch{return[]}}async function currentDialect(){const driver=((await import("@stacksjs/env")).env?.DB_CONNECTION??"sqlite").toLowerCase();if(driver==="sqlite"||driver==="mysql"||driver==="postgres")return driver;if(driver==="vitess"||driver==="singlestore")return"mysql";if(driver==="turso"||driver==="libsql")return"sqlite";return"other"}export async function readLedger(runner){try{return(await(runner??await defaultRunner())("SELECT migration FROM migrations")).map((row)=>pick(row,"migration")).filter((name)=>name.length>0).sort()}catch{return[]}}export function planLedgerRemap(ledger,diskFiles){const onDisk=new Set(diskFiles),byLogical=new Map;for(const file of diskFiles){const key=logicalName(file);if(!byLogical.has(key))byLogical.set(key,[]);byLogical.get(key).push(file)}const claimed=new Set(ledger.filter((row)=>onDisk.has(row))),remap=[],ambiguous=[],dropped=[],superseded=[],targets=new Map;for(const row of ledger){if(onDisk.has(row))continue;const sameLogical=byLogical.get(logicalName(row))??[],candidates=sameLogical.filter((f)=>!claimed.has(f));if(candidates.length===0){if(sameLogical.length>0)superseded.push(row);else dropped.push(row);continue}if(candidates.length>1){ambiguous.push(row);continue}const to=candidates[0];if(!targets.has(to))targets.set(to,[]);targets.get(to).push(row);remap.push({from:row,to})}const contested=new Set([...targets.entries()].filter(([,rows])=>rows.length>1).flatMap(([,rows])=>rows));if(contested.size===0)return{remap,ambiguous,dropped,superseded};return{remap:remap.filter((r)=>!contested.has(r.from)),ambiguous:[...ambiguous,...contested].sort(),dropped,superseded}}export async function auditMigrationLedger(options={}){const dir=migrationsDir(options.dir),dialect=options.dialect??await currentDialect(),files=listMigrationFiles(dir),counts={applied:0,stranded:0,pending:0,partial:0,unverifiable:0,reverted:0},emptyPlan={remap:[],ambiguous:[],dropped:[],superseded:[]};if(dialect==="other")return{supported:!1,dialect,dir,entries:[],orphans:[],gated:[],counts,recordedCount:0,remapPlan:emptyPlan,drift:!1};const run=options.run??await defaultRunner(),ledger=await readLedger(run),recorded=new Set(ledger),schema=await readLiveSchema(dialect,run),gate=await resolveGate(options,dir),sources=new Map,gated=files.filter((file)=>gate?.exclude.has(file));for(const file of files){if(gate?.exclude.has(file))continue;try{const sql=readFileSync(join(dir,file),"utf8");sources.set(file,gate?gate.rewrite(sql):sql)}catch{continue}}const readFiles=[...sources.keys()],removedLater=new Map,tablesDroppedLater=new Map;for(let i=0;i<readFiles.length;i++){const laterKeys=new Set,laterTables=new Set;for(let j=i+1;j<readFiles.length;j++)for(const removal of migrationRemovals(sources.get(readFiles[j]))){laterKeys.add(removalKey(removal));if(removal.kind==="table")laterTables.add(removal.name.toLowerCase())}removedLater.set(readFiles[i],laterKeys);tablesDroppedLater.set(readFiles[i],laterTables)}const entries=[];for(const file of readFiles){const sql=sources.get(file),effects=verifiableEffects(migrationEffects(sql),dialect),dropped=removedLater.get(file)??new Set,droppedTables=tablesDroppedLater.get(file)??new Set,survives=(effect)=>effectPresent(effect,schema)||dropped.has(removalKey(effect))||effect.table!==void 0&&droppedTables.has(effect.table.toLowerCase()),present=effects.filter(survives),absent=effects.filter((effect)=>!survives(effect)),isRecorded=recorded.has(file),status=classifyMigration(isRecorded,present,absent);counts[status]+=1;entries.push({file,logical:logicalName(file),recorded:isRecorded,status,effects,present,absent})}const readable=[...entries.map((entry)=>entry.file),...gated].sort(),remapPlan=planLedgerRemap(ledger,readable),renamedTo=new Map(remapPlan.remap.map((r)=>[r.from,r.to])),orphans=ledger.filter((row)=>!readable.includes(row)).map((row)=>({migration:row,renamedTo:renamedTo.get(row)})),drift=counts.stranded>0||counts.partial>0||counts.reverted>0||orphans.length>0;return{supported:!0,dialect,dir,entries,orphans,gated,counts,recordedCount:ledger.length,remapPlan,drift}}async function ensureLedgerTable(dialect,run){const id=dialect==="postgres"?"id SERIAL PRIMARY KEY":dialect==="mysql"?"id INT AUTO_INCREMENT PRIMARY KEY":"id INTEGER PRIMARY KEY AUTOINCREMENT",timestamp=dialect==="postgres"?"TIMESTAMP":"DATETIME",utcNow=dialect==="postgres"?"(now() AT TIME ZONE 'utc')":dialect==="mysql"?"(UTC_TIMESTAMP)":"CURRENT_TIMESTAMP";await run(`CREATE TABLE IF NOT EXISTS migrations (${id}, migration VARCHAR(255) NOT NULL UNIQUE, executed_at ${timestamp} DEFAULT ${utcNow})`);if(dialect!=="sqlite")try{await run(`ALTER TABLE migrations ALTER COLUMN executed_at SET DEFAULT ${utcNow}`)}catch{}}const Q=(pattern)=>new RegExp(pattern.replaceAll("{id}",IDENT),"i"),CREATE_TABLE=Q(String.raw`^CREATE\s+(?:TEMP(?:ORARY)?\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?{id}`),DROP_TABLE=Q(String.raw`^DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?{id}`),RENAME_TABLE=Q(String.raw`^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?{id}\s+RENAME\s+TO\s+{id}`),ALTER_TABLE=Q(String.raw`^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?{id}`),CREATE_INDEX=Q(String.raw`^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?{id}\s+ON\s+(?:ONLY\s+)?{id}`),DROP_INDEX=Q(String.raw`^DROP\s+INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+EXISTS\s+)?{id}(?:\s+ON\s+{id})?`),DML=Q(String.raw`^(?:INSERT\s+(?:OR\s+\w+\s+)?INTO|REPLACE\s+INTO|UPDATE|DELETE\s+FROM)\s+{id}`),NEUTRAL=/^(?:BEGIN|COMMIT|END|PRAGMA|SET|SELECT\s+1|COMMENT\s+ON|CREATE\s+TYPE\s+\S+\s+AS\s+ENUM|ALTER\s+TYPE\s+\S+\s+ADD\s+VALUE|STACKS_ENUM_GUARD)\b/i;function statementShape(statement,indexTables){const lower=(name)=>(name??"").toLowerCase();if(NEUTRAL.test(statement))return{modifies:[]};let m=RENAME_TABLE.exec(statement);if(m)return{modifies:[lower(m[1]),lower(m[2])],rename:{from:lower(m[1]),to:lower(m[2])}};m=CREATE_TABLE.exec(statement);if(m)return{modifies:[lower(m[1])],creates:lower(m[1])};m=DROP_TABLE.exec(statement);if(m)return{modifies:[lower(m[1])],drops:lower(m[1])};m=ALTER_TABLE.exec(statement);if(m)return{modifies:[lower(m[1])]};m=CREATE_INDEX.exec(statement);if(m)return{modifies:[lower(m[2])],index:{name:lower(m[1]),table:lower(m[2])}};m=DROP_INDEX.exec(statement);if(m){const table=m[2]?lower(m[2]):indexTables.get(lower(m[1]));return table?{modifies:[table]}:{modifies:[],unknown:!0}}m=DML.exec(statement);if(m)return{modifies:[lower(m[1])]};return{modifies:[],unknown:!0}}function requeueStatements(sql){return stripForEffects(sql).replace(/\bDO\s+\$(\w*)\$([\s\S]*?)\$\1\$/gi,(block,_tag,body)=>/^\s*BEGIN\s+CREATE\s+TYPE\b[\s\S]*\bEXCEPTION\s+WHEN\s+duplicate_object\s+THEN\s+null\s*;\s*END\s*$/i.test(body)?"STACKS_ENUM_GUARD":block).split(";").map((statement)=>statement.trim()).filter(Boolean)}async function resolveGate(options,dir){if(options.gate||options.dir)return options.gate;try{const{featureGateForLedger}=await import("./migrations");return await featureGateForLedger(dir)}catch{return}}export function planRequeue(files,liveTables){const ordered=[...files].sort((a,b)=>a.file.localeCompare(b.file)),indexTables=new Map,expected=new Set,lineageOf=new Map,lineage=(table)=>{let group=lineageOf.get(table);if(!group){group=new Set([table]);lineageOf.set(table,group)}return group},link=(a,b)=>{const merged=new Set([...lineage(a),...lineage(b)]);for(const name of merged)lineageOf.set(name,merged)},shapes=ordered.map(({file,sql,recorded})=>{const statements=requeueStatements(sql).map((statement)=>statementShape(statement,indexTables));for(const shape of statements){if(shape.index)indexTables.set(shape.index.name,shape.index.table);if(shape.creates)expected.add(shape.creates);if(shape.drops)expected.delete(shape.drops);if(shape.rename){expected.delete(shape.rename.from);expected.add(shape.rename.to);link(shape.rename.from,shape.rename.to)}}const created=new Set(statements.flatMap((shape)=>shape.creates?[shape.creates]:[])),removed=new Set(statements.flatMap((shape)=>[shape.drops,shape.rename?.from].filter((t)=>Boolean(t)))),scaffold=new Set([...created].filter((table)=>removed.has(table)||statements.some((shape)=>shape.rename?.from===table)));return{file,recorded,unknown:statements.some((shape)=>shape.unknown),modifies:new Set(statements.flatMap((shape)=>shape.modifies)),scaffold}}),missing=[...expected].filter((table)=>!liveTables.has(table)).sort(),rebuildable=new Set(missing.flatMap((table)=>[...lineage(table)]).filter((table)=>!liveTables.has(table))),chains=new Map(missing.map((table)=>{const names=lineage(table);return[table,shapes.filter((shape)=>[...shape.modifies].some((name)=>names.has(name)))]})),why=new Map;for(const[table,chain]of chains)for(const shape of chain){if(shape.unknown){why.set(table,`${shape.file} contains a statement the planner cannot classify`);break}const elsewhere=[...shape.modifies].filter((t)=>!rebuildable.has(t)&&!shape.scaffold.has(t));if(elsewhere.length>0){why.set(table,`${shape.file} also changes ${elsewhere.join(", ")}, which ${elsewhere.length===1?"is":"are"} not missing`);break}}let changed=!0;while(changed){changed=!1;for(const[table,chain]of chains){if(why.has(table))continue;const blocker=chain.flatMap((shape)=>[...shape.modifies]).find((other)=>other!==table&&missing.some((t)=>why.has(t)&&lineage(t).has(other)));if(blocker){why.set(table,`shares a migration with ${blocker}, which cannot be rebuilt`);changed=!0}}}const requeue=new Set;for(const[table,chain]of chains){if(why.has(table))continue;for(const shape of chain)if(shape.recorded)requeue.add(shape.file)}return{missing,requeue:ordered.map(({file})=>file).filter((file)=>requeue.has(file)),refused:[...why].map(([table,reason])=>({table,reason}))}}export async function auditMissingTables(options={}){const dialect=options.dialect??await currentDialect();if(dialect==="other")return{supported:!1,missing:[],live:new Set};const dir=migrationsDir(options.dir),run=options.run??await defaultRunner(),recorded=new Set(await readLedger(run)),gate=await resolveGate(options,dir),files=listMigrationFiles(dir).filter((file)=>recorded.has(file)&&!gate?.exclude.has(file)).flatMap((file)=>{try{const sql=readFileSync(join(dir,file),"utf8");return[{file,sql:gate?gate.rewrite(sql):sql,recorded:!0}]}catch{return[]}}),live=await readLiveSchema(dialect,run);return{supported:!0,missing:planRequeue(files,live.tables).missing,live:live.tables}}function byLogicalOnDisk(row,diskFiles){const key=logicalName(row),matches=diskFiles.filter((f)=>logicalName(f)===key);return matches.length===1?matches[0]:void 0}export async function reconcileMigrationLedger(options={}){const run=options.run??await defaultRunner(),gate=await resolveGate(options,migrationsDir(options.dir)),audit=await auditMigrationLedger({dir:options.dir,dialect:options.dialect,run,gate}),result={remapped:[],recorded:[],pruned:[],requeued:[],retired:[],skipped:[]};if(!audit.supported){result.skipped.push({file:"*",reason:`dialect "${audit.dialect}" is not audited`});return result}const written=new Set(options.regenerated?.written??[]),removedByRegeneration=new Set(options.regenerated?.removed??[]),onDisk=new Set([...audit.entries.map((entry)=>entry.file),...audit.gated]),plan={...audit.remapPlan,remap:audit.remapPlan.remap.filter((r)=>!written.has(r.to))},retirable=[...audit.remapPlan.remap.filter((r)=>written.has(r.to)).map((r)=>r.from),...audit.remapPlan.dropped,...audit.remapPlan.superseded,...audit.remapPlan.ambiguous].filter((row)=>removedByRegeneration.has(row)&&!onDisk.has(row)&&SAFE_MIGRATION_FILE.test(row)),retiring=new Set(retirable);for(const row of plan.ambiguous.filter((row)=>!retiring.has(row)))result.skipped.push({file:row,reason:"ledger row matches more than one file by logical name"});for(const row of plan.dropped.filter((row)=>!retiring.has(row)))result.skipped.push({file:row,reason:"recorded migration no longer exists on disk"});for(const{from}of audit.remapPlan.remap.filter((r)=>written.has(r.to)&&!retiring.has(r.from)))result.skipped.push({file:from,reason:"its file was replaced by a regenerated one; not repointed, because the contents differ"});const prunable=plan.superseded.filter((row)=>SAFE_MIGRATION_FILE.test(row)&&!retiring.has(row));for(const row of plan.superseded.filter((row)=>!SAFE_MIGRATION_FILE.test(row)))result.skipped.push({file:row,reason:"ledger row is not safe to write to the ledger"});const toRecord=[],revertedEntries=[];for(const entry of audit.entries){if(entry.status==="stranded"){toRecord.push(entry.file);continue}if(entry.status==="partial"){if(options.includePartial){toRecord.push(entry.file);continue}result.skipped.push({file:entry.file,reason:written.has(entry.file)?`${entry.present.length}/${entry.effects.length} effects present - left pending; run \`buddy migrate:align\` first so the next migrate does not duplicate what exists under another name`:`${entry.present.length}/${entry.effects.length} effects present - resolve by hand, or pass --include-partial`});continue}if(entry.status==="reverted")revertedEntries.push(entry)}const toRequeue=[];if(options.requeueReverted){const live=await readLiveSchema(audit.dialect,run),recordedFiles=new Set(audit.entries.filter((entry)=>entry.recorded).map((entry)=>entry.file)),corpus=audit.entries.flatMap((entry)=>{try{const sql=readFileSync(join(audit.dir,entry.file),"utf8");return[{file:entry.file,sql:gate?gate.rewrite(sql):sql,recorded:recordedFiles.has(entry.file)}]}catch{return[]}}),plan=planRequeue(corpus,live.tables);toRequeue.push(...plan.requeue);for(const{table,reason}of plan.refused)result.skipped.push({file:table,reason:`missing table not rebuilt: ${reason}`})}for(const entry of revertedEntries.filter((entry)=>!toRequeue.includes(entry.file))){if(written.has(entry.file)){result.skipped.push({file:entry.file,reason:`recorded under the same name as the file it replaced, but ${entry.absent.length} effect(s) of the regenerated file are missing - \`buddy migrate:align\` creates them`});continue}result.skipped.push({file:entry.file,reason:options.requeueReverted?`recorded, but ${entry.absent.length} effect(s) are missing from the schema`:`recorded, but ${entry.absent.length} effect(s) are missing from the schema - --requeue-reverted rebuilds a table that is gone entirely`})}const unsafe=(file)=>!SAFE_MIGRATION_FILE.test(file);for(const{from,to}of plan.remap.filter((r)=>unsafe(r.from)||unsafe(r.to)))result.skipped.push({file:unsafe(from)?from:to,reason:"migration filename is not safe to write to the ledger"});for(const file of toRecord.filter(unsafe))result.skipped.push({file,reason:"migration filename is not safe to write to the ledger"});const remapped=plan.remap.filter((r)=>!unsafe(r.from)&&!unsafe(r.to)),recordable=toRecord.filter((file)=>!unsafe(file)&&!remapped.some((r)=>r.to===file)),requeueable=toRequeue.filter((file)=>!unsafe(file));for(const file of toRequeue.filter(unsafe))result.skipped.push({file,reason:"migration filename is not safe to write to the ledger"});if(options.dryRun){result.remapped=remapped;result.recorded=recordable;result.pruned=prunable;result.requeued=requeueable;result.retired=retirable;return result}await ensureLedgerTable(audit.dialect,run);for(const{from,to}of remapped){await run(`UPDATE migrations SET migration = '${to}' WHERE migration = '${from}'`);result.remapped.push({from,to})}for(const file of recordable){if((await run(`SELECT migration FROM migrations WHERE migration = '${file}'`)).length>0)continue;await run(`INSERT INTO migrations (migration) VALUES ('${file}')`);result.recorded.push(file)}for(const row of prunable){const survivor=byLogicalOnDisk(row,audit.entries.map((e)=>e.file));if(!survivor)continue;if((await run(`SELECT migration FROM migrations WHERE migration = '${survivor}'`)).length===0){result.skipped.push({file:row,reason:`would leave ${survivor} unrecorded`});continue}await run(`DELETE FROM migrations WHERE migration = '${row}'`);result.pruned.push(row)}for(const row of retirable){await run(`DELETE FROM migrations WHERE migration = '${row}'`);result.retired.push(row)}for(const file of requeueable){await run(`DELETE FROM migrations WHERE migration = '${file}'`);result.requeued.push(file)}return result}
@@ -1,10 +1,19 @@
1
1
  import { GENERATED_MIGRATION_MARKER, isGeneratedMigration } from './generated-migration-marker';
2
+ import { indexShape } from './index-shape';
3
+ import { resolveModelSources } from './model-sources';
2
4
  import { sqlStatementsOf } from './sql-statements';
3
5
  import type { CorpusGate } from './migration-ledger';
4
6
  import type { DatabaseRenameResult, RenameSqlRunner } from './framework-renames';
7
+ import type { IndexShape } from './index-shape';
5
8
  import type { MigrationOperation, MigrationPlan } from '@stacksjs/query-builder';
6
9
  import type { Result } from '@stacksjs/error-handling';
10
+ export type { IndexShape };
7
11
  export type { MigrationResult as MigrationResultType };
12
+ /**
13
+ * Configure bun-query-builder with stacks database settings
14
+ */
15
+ /** The corpus `buddy migrate` runs for the configured connection. */
16
+ export declare function activeMigrationDirectory(): Promise<string>;
8
17
  export declare function prepareMigrationModelsDir(): {
9
18
  modelsDir: string
10
19
  skip: boolean
@@ -370,6 +379,18 @@ export declare function inlineSqliteAddedColumnReferences(statements: string[],
370
379
  * happens to point its foreign keys at.
371
380
  */
372
381
  export declare function tablesOperatedOn(sql: string): string[];
382
+ export declare function describeMigrationContent(sql: string): MigrationContent;
383
+ /** The enum types a set of generated statements creates, lowercased. */
384
+ export declare function enumTypesCreatedBy(statements: readonly string[]): Set<string>;
385
+ /**
386
+ * Why a regeneration may replace a file that names no table, or undefined
387
+ * when it may not. See {@link describeMigrationContent}.
388
+ *
389
+ * `keptIndexes` / `keptSeedTables` describe the files that will survive the
390
+ * regeneration whatever happens: dropping an index one of them creates, or
391
+ * clearing a table one of them fills, is not history and stays.
392
+ */
393
+ export declare function obsoleteMigrationReason(content: MigrationContent, context: { emittedTypes: ReadonlySet<string>, keptIndexes: ReadonlySet<string>, keptSeedTables: ReadonlySet<string> }): 'types' | 'drop-index' | 'data' | undefined;
373
394
  /**
374
395
  * The columns a CREATE TABLE statement defines, in order.
375
396
  *
@@ -455,6 +476,20 @@ export declare function allocateMigrationOrdinals(count: number, startAt: number
455
476
  * `isGeneratedMigration` takes, for the same reason.
456
477
  */
457
478
  export declare function migrationsOutsideCorpus(dir: string, files: readonly string[], corpusTables: readonly string[]): string[];
479
+ /**
480
+ * The complete SQL the models describe for one dialect, as if no database
481
+ * existed yet.
482
+ *
483
+ * Shared by `migrate:regenerate`, which writes it out as a corpus, and
484
+ * `migrate:align`, which compares it with a live database - so the index names,
485
+ * enum type names and foreign keys the two reason about are the generator's
486
+ * own, not a second derivation of them.
487
+ */
488
+ export declare function generateFullModelMigration(options?: {
489
+ dialect?: string
490
+
491
+ onlyExistingTables?: boolean
492
+ }): Promise<Result<FullModelMigration, Error>>;
458
493
  /**
459
494
  * Rebuild the whole migration corpus for one dialect, from the models.
460
495
  *
@@ -493,7 +528,6 @@ export declare function regenerateMigrationCorpus(options?: {
493
528
  * four spellings have to reach the same name.
494
529
  */
495
530
  export declare function createdTableName(statement: string): string | undefined;
496
- export declare function indexShape(statement: string): IndexShape | undefined;
497
531
  /** The index a `DROP INDEX` removes, lowercased, or undefined. */
498
532
  export declare function droppedIndexName(statement: string): string | undefined;
499
533
  /**
@@ -610,30 +644,57 @@ declare interface MigrationPlanTable {
610
644
  declare interface MigrationPlanLike {
611
645
  tables?: MigrationPlanTable[]
612
646
  }
647
+ /**
648
+ * What a migration file does, in the terms `regenerateMigrationCorpus` needs to
649
+ * decide whether a regenerated corpus can stand in for it.
650
+ *
651
+ * `tablesOperatedOn` answers that for files that create or alter tables. It
652
+ * says nothing about the three shapes a long-lived corpus accumulates around
653
+ * them, and "names no table" used to mean "keep, because nothing proves it is
654
+ * ours" for all three:
655
+ *
656
+ * - a types-only file (`CREATE TYPE ... AS ENUM` behind a `DO` guard,
657
+ * `ALTER TYPE ... ADD VALUE`), which is obsolete once the regenerated
658
+ * corpus declares every one of those types itself;
659
+ * - a file that only drops indexes, which is history: a database built from
660
+ * the regenerated corpus never had them;
661
+ * - a data-only file (`DELETE` / `UPDATE`), which is a no-op on a database
662
+ * the regenerated corpus has just built, because every table starts empty.
663
+ *
664
+ * `seedTables` is the one shape that is never obsolete: `INSERT ... VALUES` and
665
+ * `COPY` can be reference data no model describes.
666
+ */
667
+ export declare interface MigrationContent {
668
+ types: string[]
669
+ droppedTypes: string[]
670
+ droppedIndexes: string[]
671
+ createdIndexes: string[]
672
+ dataTables: string[]
673
+ seedTables: string[]
674
+ createdTables: string[]
675
+ other: boolean
676
+ }
677
+ export declare interface FullModelMigration {
678
+ dialect: string
679
+ statements: string[]
680
+ plan: MigrationPlan
681
+ sources: NonNullable<ReturnType<typeof resolveModelSources>>
682
+ }
613
683
  export declare interface RegeneratedCorpus {
614
684
  dialect: string
615
685
  models: number
616
686
  modelRoots: string[]
617
687
  files: Array<{ name: string, statements: number }>
618
688
  removed: string[]
689
+ removedObsolete: string[]
690
+ removedDataOnly: string[]
619
691
  preserved: string[]
620
692
  unrebuildable: string[]
621
693
  preservedOutOfScope: string[]
694
+ preservedData: string[]
695
+ relocated: Array<{ from: string, to: string }>
622
696
  dir: string
623
697
  }
624
- /**
625
- * What a `CREATE INDEX` builds, apart from what it calls it.
626
- *
627
- * `key` is the table, whether the index is unique, its column list (with any
628
- * sort order) and its `WHERE` clause, normalized - everything that decides what
629
- * the index does, and nothing that only names it. Two statements with the same
630
- * key build the same index.
631
- */
632
- export declare interface IndexShape {
633
- name: string
634
- table: string
635
- key: string
636
- }
637
698
  export declare interface CommittedMigrationIndex {
638
699
  sql: string
639
700
  createdTables: Set<string>
@@ -666,3 +727,4 @@ export { sqlStatementsOf } from './sql-statements';
666
727
  * not glue it onto the first statement.
667
728
  */
668
729
  export { GENERATED_MIGRATION_MARKER, isGeneratedMigration } from './generated-migration-marker';
730
+ export { indexShape };
@@ -1,4 +1,4 @@
1
- var {require}=import.meta;import{existsSync,mkdirSync,readdirSync,readFileSync,renameSync,unlinkSync,writeFileSync}from"node:fs";import{isPackageMigration,stagePackageMigrations}from"./package-migrations";import{basename,dirname,isAbsolute,join,resolve}from"node:path";import{log as _log}from"@stacksjs/logging";const log={info:(...args)=>typeof _log?.info==="function"?_log.info(...args):console.log(...args),success:(msg)=>typeof _log?.success==="function"?_log.success(msg):console.log(msg),warn:(msg)=>typeof _log?.warn==="function"?_log.warn(msg):console.warn(msg),error:(...args)=>typeof _log?.error==="function"?_log.error(...args):console.error(...args),debug:(...args)=>typeof _log?.debug==="function"?_log.debug(...args):console.debug(...args)};import{err,handleError,ok}from"@stacksjs/error-handling";import{path}from"@stacksjs/path";import{defaultModelsPath}from"./seeder";import{createQueryBuilder,executeMigration as qbExecuteMigration,generateDiffOperations,generateMigration as qbGenerateMigration,resetDatabase as qbResetDatabase,config as qbConfig,saveMigrationSnapshot,setConfig}from"@stacksjs/query-builder";import{db,ensureDatabaseConfigLoaded,qbSnapshotDir,resetDatabaseConnection,resolvedDatabaseConfig}from"./utils";import{classifyConnectionError,createDatabase,describeTarget,manualCreateHint,probeTargetDatabase,resolveConnectionTarget}from"./ensure-database";import{declaredModelTables,resolveModelSources}from"./model-sources";import{applicableFrameworkRenames,applyFrameworkRenamesToDatabase,applyFrameworkRenamesToPlan}from"./framework-renames";import{findShadowedColumnDrops,shadowDropsAllowed,shadowedDropMessage}from"./shadowed-models";import{frameworkManagedColumns,withoutManagedColumnDrops,withoutManagedColumnDropSql}from"./managed-columns";import{acquireLibsqlMigrationLock,acquireMigrationLock}from"./migration-lock";import{auditMissingTables}from"./migration-ledger";import{relativeMigrationDirectory,resolveMigrationDirectory}from"./migration-path";import{ensureNotificationForeignKeys,migrateNotificationTables,notificationTablesMissingCreateStatements}from"./notification-tables";import{traitTableNames}from"./trait-tables";import{AUTH_TABLES}from"./auth-tables";import{sqlStatementsOf}from"./sql-statements";import{corpusUniqueIndexes,hasIndex,restorableMissingUniqueIndexes,restoreDroppedUniqueIndexes,uniqueIndexIsRestorable}from"./declared-unique-indexes";import{ensureUuidColumns}from"./uuid-columns";import{sqlHelpers}from"./sql-helpers";import{GENERATED_MIGRATION_MARKER,isGeneratedMigration}from"./generated-migration-marker";import{trackIgnoredGeneratedMigrations}from"./ignored-migrations";export{sqlStatementsOf}from"./sql-statements";import{isLibsqlDriver,isVitessSharded,toSqlIntrospectionDialect}from"./dialect";const dbConfig={get default(){return resolvedDatabaseConfig().driver},get connections(){const{connections}=resolvedDatabaseConfig();return{...connections,turso:connections.turso??{url:"",authToken:""}}}};function usesLibsql(){return isLibsqlDriver(getDriver())}function sqliteDatabasePath(){const configured=dbConfig.connections.sqlite.database||"stacks.db";return isAbsolute(configured)?configured:join(process.cwd(),configured)}function getDriver(){return dbConfig.default||"sqlite"}function getDialect(){const driver=getDriver();if(driver==="sqlite"||driver==="mysql"||driver==="vitess"||driver==="postgres")return driver;if(driver==="turso")return"sqlite";if(driver==="singlestore")return"mysql";if(driver==="dynamodb")throw Error("[database] DB_CONNECTION=dynamodb is not compatible with the SQL migration runner. DynamoDB has no schema-migration concept - use the entity-style `dynamo.entity(...)` API from @stacksjs/database directly. To run SQL migrations, set DB_CONNECTION to one of: sqlite, turso, mysql, singlestore, vitess, postgres.");throw Error(`[database] Unknown DB_CONNECTION "${driver}". Allowed values: sqlite, turso, mysql, singlestore, vitess, postgres, dynamodb.`)}function getQbDialect(){return getDriver()==="singlestore"?"singlestore":getDialect()}function migrationDirectory(dialect=getQbDialect()){return resolveMigrationDirectory(dialect,{configured:qbConfig.migrationDir,snapshotDir:qbSnapshotDir()})}function configureQueryBuilder(targetDialect=getQbDialect(),vitessSharded){const connectionConfig=dbConfig.connections[targetDialect],libsql=targetDialect==="sqlite"&&usesLibsql()?{url:dbConfig.connections.turso.url||void 0,authToken:dbConfig.connections.turso.authToken||void 0}:{url:void 0,authToken:void 0};if(targetDialect==="sqlite"&&usesLibsql()&&!libsql.url)throw Error("[database] DB_CONNECTION=turso needs TURSO_DATABASE_URL (libsql://<db>-<org>.turso.io, or http://127.0.0.1:8080 for `turso dev`).");setConfig({dialect:targetDialect,vitess:{sharded:isVitessSharded(vitessSharded??connectionConfig?.sharded)},verbose:!1,snapshotDir:qbSnapshotDir(),migrationDir:relativeMigrationDirectory(migrationDirectory(targetDialect)),database:{database:connectionConfig?.name||connectionConfig?.database||"stacks",host:connectionConfig?.host||"localhost",port:connectionConfig?.port||(targetDialect==="postgres"?5432:targetDialect==="vitess"?15306:targetDialect==="mysql"||targetDialect==="singlestore"?3306:0),username:connectionConfig?.username||"",password:connectionConfig?.password||"",...libsql}});resetDatabaseConnection()}export function prepareMigrationModelsDir(){const sources=resolveModelSources(),excludedTables=sources?.excludedTables??[];return{modelsDir:sources?.dir??path.userModelsPath(),skip:!sources,excludedTables,protectedTables:[...new Set([...excludedTables,...traitTableNames()])]}}function generatedStatementTables(statement){return statement.split(";").map((part)=>part.trim()).filter(Boolean).flatMap((part)=>{const table=rebuiltTable(part)??statementTable(part),index=/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?\w+["`]?\s+ON\s+["`]?(\w+)["`]?/i.exec(part)?.[1],dropped=DROP_TABLE_RE.exec(part)?.[1];return[table,index,dropped].filter((name)=>Boolean(name)).map((name)=>name.toLowerCase())})}export function withoutRuntimeOwnedTableSql(statements,owned=AUTH_TABLES){const ownedSet=new Set(owned.map((table)=>table.toLowerCase())),removed=[];return{statements:statements.filter((statement)=>{if(!generatedStatementTables(statement).some((table)=>ownedSet.has(table)))return!0;removed.push(statement);return!1}),removed}}const DROP_TABLE_RE=/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i;export function withoutProtectedTableDropSql(statements,protectedTables,operations){if(protectedTables.length===0)return{statements,removed:[]};const excluded=new Set(protectedTables.map((table)=>table.toLowerCase())),normalize=(sql)=>sql.replace(/\s+/g," ").trim().replace(/;$/,""),dropped=new Set(operations.filter((op)=>op.kind==="drop_table"&&excluded.has(op.table.toLowerCase())).filter((op)=>Boolean(op.sql)).map((op)=>normalize(op.sql))),removed=[];return{statements:statements.filter((statement)=>{if(dropped.has(normalize(statement))){removed.push(statement);return!1}const match=statement.match(DROP_TABLE_RE);if(match?.[1]&&excluded.has(match[1].toLowerCase())){removed.push(statement);return!1}return!0}),removed}}export function orderPostgresColumnTypeChanges(sql){const lines=sql.split(`
1
+ var {require}=import.meta;import{existsSync,mkdirSync,readdirSync,readFileSync,renameSync,unlinkSync,writeFileSync}from"node:fs";import{isPackageMigration,stagePackageMigrations}from"./package-migrations";import{basename,dirname,isAbsolute,join,resolve}from"node:path";import{log as _log}from"@stacksjs/logging";const log={info:(...args)=>typeof _log?.info==="function"?_log.info(...args):console.log(...args),success:(msg)=>typeof _log?.success==="function"?_log.success(msg):console.log(msg),warn:(msg)=>typeof _log?.warn==="function"?_log.warn(msg):console.warn(msg),error:(...args)=>typeof _log?.error==="function"?_log.error(...args):console.error(...args),debug:(...args)=>typeof _log?.debug==="function"?_log.debug(...args):console.debug(...args)};import{err,handleError,ok}from"@stacksjs/error-handling";import{path}from"@stacksjs/path";import{defaultModelsPath}from"./seeder";import{createQueryBuilder,executeMigrationWithReport as qbExecuteMigrationWithReport,generateDiffOperations,generateMigration as qbGenerateMigration,resetDatabase as qbResetDatabase,config as qbConfig,saveMigrationSnapshot,setConfig}from"@stacksjs/query-builder";import{db,ensureDatabaseConfigLoaded,qbSnapshotDir,resetDatabaseConnection,resolvedDatabaseConfig}from"./utils";import{classifyConnectionError,createDatabase,describeTarget,manualCreateHint,probeTargetDatabase,resolveConnectionTarget}from"./ensure-database";import{declaredModelTables,resolveModelSources}from"./model-sources";import{applicableFrameworkRenames,applyFrameworkRenamesToDatabase,applyFrameworkRenamesToPlan}from"./framework-renames";import{findShadowedColumnDrops,shadowDropsAllowed,shadowedDropMessage}from"./shadowed-models";import{frameworkManagedColumns,withoutManagedColumnDrops,withoutManagedColumnDropSql}from"./managed-columns";import{acquireLibsqlMigrationLock,acquireMigrationLock}from"./migration-lock";import{auditMissingTables}from"./migration-ledger";import{relativeMigrationDirectory,resolveMigrationDirectory}from"./migration-path";import{ensureNotificationForeignKeys,migrateNotificationTables,notificationTablesMissingCreateStatements}from"./notification-tables";import{traitTableNames}from"./trait-tables";import{AUTH_TABLES}from"./auth-tables";import{sqlStatementsOf}from"./sql-statements";import{indexShape}from"./index-shape";import{corpusUniqueIndexes,hasIndex,restorableMissingUniqueIndexes,restoreDroppedUniqueIndexes,uniqueIndexIsRestorable}from"./declared-unique-indexes";import{ensureUuidColumns}from"./uuid-columns";import{sqlHelpers}from"./sql-helpers";import{GENERATED_MIGRATION_MARKER,isGeneratedMigration}from"./generated-migration-marker";import{trackIgnoredGeneratedMigrations}from"./ignored-migrations";export{sqlStatementsOf}from"./sql-statements";import{isLibsqlDriver,isVitessSharded,toSqlIntrospectionDialect}from"./dialect";const dbConfig={get default(){return resolvedDatabaseConfig().driver},get connections(){const{connections}=resolvedDatabaseConfig();return{...connections,turso:connections.turso??{url:"",authToken:""}}}};function usesLibsql(){return isLibsqlDriver(getDriver())}function sqliteDatabasePath(){const configured=dbConfig.connections.sqlite.database||"stacks.db";return isAbsolute(configured)?configured:join(process.cwd(),configured)}function getDriver(){return dbConfig.default||"sqlite"}function getDialect(){const driver=getDriver();if(driver==="sqlite"||driver==="mysql"||driver==="vitess"||driver==="postgres")return driver;if(driver==="turso")return"sqlite";if(driver==="singlestore")return"mysql";if(driver==="dynamodb")throw Error("[database] DB_CONNECTION=dynamodb is not compatible with the SQL migration runner. DynamoDB has no schema-migration concept - use the entity-style `dynamo.entity(...)` API from @stacksjs/database directly. To run SQL migrations, set DB_CONNECTION to one of: sqlite, turso, mysql, singlestore, vitess, postgres.");throw Error(`[database] Unknown DB_CONNECTION "${driver}". Allowed values: sqlite, turso, mysql, singlestore, vitess, postgres, dynamodb.`)}function getQbDialect(){return getDriver()==="singlestore"?"singlestore":getDialect()}function migrationDirectory(dialect=getQbDialect()){return resolveMigrationDirectory(dialect,{configured:qbConfig.migrationDir,snapshotDir:qbSnapshotDir()})}export async function activeMigrationDirectory(){await ensureDatabaseConfigLoaded();return migrationDirectory(getDialect())}function configureQueryBuilder(targetDialect=getQbDialect(),vitessSharded){const connectionConfig=dbConfig.connections[targetDialect],libsql=targetDialect==="sqlite"&&usesLibsql()?{url:dbConfig.connections.turso.url||void 0,authToken:dbConfig.connections.turso.authToken||void 0}:{url:void 0,authToken:void 0};if(targetDialect==="sqlite"&&usesLibsql()&&!libsql.url)throw Error("[database] DB_CONNECTION=turso needs TURSO_DATABASE_URL (libsql://<db>-<org>.turso.io, or http://127.0.0.1:8080 for `turso dev`).");setConfig({dialect:targetDialect,vitess:{sharded:isVitessSharded(vitessSharded??connectionConfig?.sharded)},verbose:!1,snapshotDir:qbSnapshotDir(),migrationDir:relativeMigrationDirectory(migrationDirectory(targetDialect)),database:{database:connectionConfig?.name||connectionConfig?.database||"stacks",host:connectionConfig?.host||"localhost",port:connectionConfig?.port||(targetDialect==="postgres"?5432:targetDialect==="vitess"?15306:targetDialect==="mysql"||targetDialect==="singlestore"?3306:0),username:connectionConfig?.username||"",password:connectionConfig?.password||"",...libsql}});resetDatabaseConnection()}export function prepareMigrationModelsDir(){const sources=resolveModelSources(),excludedTables=sources?.excludedTables??[];return{modelsDir:sources?.dir??path.userModelsPath(),skip:!sources,excludedTables,protectedTables:[...new Set([...excludedTables,...traitTableNames()])]}}function generatedStatementTables(statement){return statement.split(";").map((part)=>part.trim()).filter(Boolean).flatMap((part)=>{const table=rebuiltTable(part)??statementTable(part),index=/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?\w+["`]?\s+ON\s+["`]?(\w+)["`]?/i.exec(part)?.[1],dropped=DROP_TABLE_RE.exec(part)?.[1];return[table,index,dropped].filter((name)=>Boolean(name)).map((name)=>name.toLowerCase())})}export function withoutRuntimeOwnedTableSql(statements,owned=AUTH_TABLES){const ownedSet=new Set(owned.map((table)=>table.toLowerCase())),removed=[];return{statements:statements.filter((statement)=>{if(!generatedStatementTables(statement).some((table)=>ownedSet.has(table)))return!0;removed.push(statement);return!1}),removed}}const DROP_TABLE_RE=/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i;export function withoutProtectedTableDropSql(statements,protectedTables,operations){if(protectedTables.length===0)return{statements,removed:[]};const excluded=new Set(protectedTables.map((table)=>table.toLowerCase())),normalize=(sql)=>sql.replace(/\s+/g," ").trim().replace(/;$/,""),dropped=new Set(operations.filter((op)=>op.kind==="drop_table"&&excluded.has(op.table.toLowerCase())).filter((op)=>Boolean(op.sql)).map((op)=>normalize(op.sql))),removed=[];return{statements:statements.filter((statement)=>{if(dropped.has(normalize(statement))){removed.push(statement);return!1}const match=statement.match(DROP_TABLE_RE);if(match?.[1]&&excluded.has(match[1].toLowerCase())){removed.push(statement);return!1}return!0}),removed}}export function orderPostgresColumnTypeChanges(sql){const lines=sql.split(`
2
2
  `),output=[];for(const line of lines){const match=/^(\s*)ALTER\s+TABLE\s+("?[\w.]+"?)\s+ALTER\s+COLUMN\s+("?[\w]+"?)\s+TYPE\s/i.exec(line);if(match){const[,indent,table,column]=match,drop=`${indent}ALTER TABLE ${table} ALTER COLUMN ${column} DROP DEFAULT;`;if((output.length>0?output[output.length-1].trim():"")!==drop.trim())output.push(drop)}output.push(line)}return output.join(`
3
3
  `)}export function guardPostgresEnumTypes(sql){return assertPostgresEnumMembers(wrapPostgresEnumTypes(sql))}function wrapPostgresEnumTypes(sql){return sql.replace(/CREATE\s+TYPE\s+("?[\w.]+"?)\s+AS\s+ENUM\s*\(([^)]*)\)/gi,(match,name,members,offset,whole)=>{if(/\bBEGIN\s*$/i.test(whole.slice(Math.max(0,offset-40),offset)))return match;return`DO $stacks$ BEGIN CREATE TYPE ${name} AS ENUM (${members}); EXCEPTION WHEN duplicate_object THEN null; END $stacks$`})}function assertPostgresEnumMembers(sql){return sql.replace(/DO \$stacks\$[\s\S]*?END \$stacks\$;?/g,(block)=>{const created=/CREATE\s+TYPE\s+("?[\w.]+"?)\s+AS\s+ENUM\s*\(([^)]*)\)/i.exec(block);if(!created)return block;const[,name,members]=created,missing=enumMembers(members).map((member)=>`ALTER TYPE ${name} ADD VALUE IF NOT EXISTS ${member};`).filter((statement)=>!sql.includes(statement));if(missing.length===0)return block;return`${block.endsWith(";")?block:`${block};`}
4
4
  ${missing.join(`
@@ -11,14 +11,14 @@ ${missing.join(`
11
11
  )`);const insert=writeDb.prepare("INSERT OR IGNORE INTO migrations (migration) VALUES (?)");for(const migration of droppedMigrations)insert.run(migration);const unrecord=writeDb.prepare("DELETE FROM migrations WHERE migration = ?");for(const migration of replayMigrations)unrecord.run(migration)}finally{writeDb.close()}}catch(e){log.debug(`[migration] Could not record dropped migrations as executed: ${e}`)}return quarantined}function mayCreateMissingDatabase(){const signal=process.env.STACKS_CREATE_DATABASE;if(signal==="1")return!0;if(signal==="0")return!1;const policy=String(process.env.DB_CREATE_DATABASE||"").toLowerCase();return!(policy==="never"||policy==="false"||policy==="0")}function describeProbeFailure(target,kind,error){const where=describeTarget(target),detail=error instanceof Error?error.message:String(error??"");switch(kind){case"missing-role":return`The user "${target.username}" does not exist on ${where}. Set DB_USERNAME to a role that exists, or create it with: createuser -s ${target.username}`;case"auth-failed":return`Authentication failed for user "${target.username}" on ${where}. Check DB_USERNAME and DB_PASSWORD.`;case"server-unreachable":return`Could not reach the database server at ${target.host}:${target.port}. Check that it is running and that DB_HOST and DB_PORT are correct.`;case"timeout":return`Timed out connecting to the database server at ${target.host}:${target.port}. ${detail}`;case"permission-denied":return`The user "${target.username}" is not allowed to connect to "${target.database}" on ${where}.`;default:return`Could not connect to the database "${target.database}" on ${where}. ${detail}`}}async function ensureDatabaseExists(){const target=resolveConnectionTarget();if(!target)return;const probe=await probeTargetDatabase(target);if(probe.ok)return;if(probe.kind!=="missing-database")throw Error(describeProbeFailure(target,probe.kind,probe.error));if(!mayCreateMissingDatabase())throw Error(`The database "${target.database}" does not exist on ${describeTarget(target)}. Create it with: ${manualCreateHint(target)}`);const result=await createDatabase(target);if(!result.created&&result.error)throw Error(`The database "${target.database}" does not exist on ${describeTarget(target)} and it could not be created automatically. ${describeProbeFailure(target,result.kind,result.error)}
12
12
  Create it yourself with: ${manualCreateHint(target)}`);if(result.created)log.success(`Created database "${target.database}" on ${target.host}:${target.port}`)}let databaseBootstrapped=!1;export async function ensureDatabaseReady(){await ensureDatabaseConfigLoaded();if(databaseBootstrapped)return;await ensureDatabaseExists();databaseBootstrapped=!0}export function resetDatabaseBootstrapCache(){databaseBootstrapped=!1}export async function featureGatedMigrationFiles(dir=migrationDirectory()){const gated=[];try{const{appModelClaimsTable,FEATURE_NAMES,migrationFeature,migrationTable}=await import("@stacksjs/features"),{feature:isFeatureEnabled}=await import("@stacksjs/config");if(!existsSync(dir))return gated;const disabledFeatures=new Set(FEATURE_NAMES.filter((f)=>!isFeatureEnabled(f)));if(disabledFeatures.size===0)return gated;for(const file of readdirSync(dir).filter((f)=>f.endsWith(".sql"))){const owner=migrationFeature(file);if(!owner||!disabledFeatures.has(owner))continue;const table=migrationTable(file);if(table&&appModelClaimsTable(table))continue;gated.push({file,feature:owner,table})}}catch{}return gated}export async function featureGateForLedger(dir=migrationDirectory(getDialect())){const gated=await featureGatedMigrationFiles(dir),tables=new Set(gated.flatMap((entry)=>entry.table?[entry.table.toLowerCase()]:[]));return{exclude:new Set(gated.map((entry)=>entry.file)),rewrite:(sql)=>tables.size>0?withoutGatedStatements(sql,tables):sql}}async function hideDisabledFeatureMigrations(){const hidden=[];try{const fs=await import("node:fs/promises"),migrationsDir=migrationDirectory();if(!existsSync(migrationsDir))return hidden;const gated=await featureGatedMigrationFiles(migrationsDir);if(gated.length===0)return hidden;const files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql")),gatedTables=new Set;for(const{file,feature,table}of gated){if(table)gatedTables.add(table.toLowerCase());const original=join(migrationsDir,file),hiddenPath=`${original}.disabled`;await fs.rename(original,hiddenPath);hidden.push({original,hidden:hiddenPath,feature})}for(const file of files){const filePath=join(migrationsDir,file);if(!existsSync(filePath))continue;const sql=readFileSync(filePath,"utf8"),filtered=withoutGatedStatements(sql,gatedTables);if(filtered===sql)continue;const backup=`${filePath}.ungated`;await fs.rename(filePath,backup);writeFileSync(filePath,filtered);hidden.push({original:filePath,hidden:backup,feature:"mixed"})}if(hidden.length>0){const summary=Object.entries(hidden.reduce((acc,h)=>{acc[h.feature]=(acc[h.feature]??0)+1;return acc},{})).map(([f,n])=>`${f}: ${n}`).join(", ");log.info(`[migration] Skipping ${hidden.length} migration(s) for disabled features (${summary}). Run \`./buddy <feature>:install\` to enable.`)}}catch{}return hidden}function afterCommonTableExpressions(statement){if(!/^\s*WITH\b/i.test(statement))return statement;let depth=0;for(let index=0;index<statement.length;index++){const char=statement[index];if(char==="("){depth++;continue}if(char!==")")continue;depth--;if(depth!==0)continue;const rest=statement.slice(index+1);if(/^\s*,/.test(rest))continue;return rest}return statement}export function statementTable(statement){const body=afterCommonTableExpressions(statement),patterns=[/^\s*ALTER\s+TABLE\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?([a-z0-9_]+)["`]?/i,/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["`]?([a-z0-9_]+)["`]?/i,/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?[a-z0-9_]+["`]?\s+ON\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*UPDATE\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*INSERT\s+(?:OR\s+\w+\s+)?INTO\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*DELETE\s+FROM\s+["`]?([a-z0-9_]+)["`]?/i];for(const pattern of patterns){const match=pattern.exec(body);if(match)return match[1].toLowerCase()}return null}export function statementReferencesTable(statement,table){const escaped=table.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return new RegExp(`\\b(?:FROM|JOIN|UPDATE|INTO)\\s+["\`\\[]?${escaped}["\`\\]]?\\b`,"i").test(statement)}function rebuiltTable(statement){const rename=/^\s*ALTER\s+TABLE\s+["`]?([a-z0-9_]+)["`]?\s+RENAME\s+TO\s+["`]?([a-z0-9_]+)["`]?/i.exec(statement);if(rename?.[2])return rename[2].toLowerCase();const table=statementTable(statement);if(table?.startsWith("_qb_tmp_"))return table.slice(8);return null}export function withoutGatedStatements(sql,gated){if(gated.size===0)return sql;const statements=sql.split(";").map((s)=>s.trim()).filter(Boolean),kept=statements.filter((statement)=>{const table=rebuiltTable(statement)??statementTable(statement);if(table&&gated.has(table))return!1;for(const candidate of gated)if(statementReferencesTable(statement,candidate))return!1;return!0});if(kept.length===statements.length)return sql;return kept.length===0?"":`${kept.join(`;
13
13
  `)};
14
- `}async function restoreHiddenMigrations(hidden){const fs=await import("node:fs/promises");for(const{original,hidden:h}of hidden)try{if(h.endsWith(".ungated"))await fs.rm(original,{force:!0});await fs.rename(h,original)}catch{}}export async function countAppliedMigrations(){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]??"",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(`;
15
15
  `)};
16
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 (
17
17
  id INTEGER PRIMARY KEY AUTOINCREMENT,
18
18
  migration TEXT NOT NULL UNIQUE,
19
19
  executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
20
20
  )`).execute();for(const migration of record)await db.unsafe("INSERT OR IGNORE INTO migrations (migration) VALUES (?)",[migration]).execute();for(const migration of replay)await db.unsafe("DELETE FROM migrations WHERE migration = ?",[migration]).execute()}catch(e){log.debug(`[migration] Could not record dropped migrations as executed: ${e}`)}}async function preprocessLibsqlMigrations(){const mirror=await loadLibsqlSchemaMirror();let ledger={record:[],replay:[]};try{const quarantined=preprocessSqliteMigrations({schema:mirror,recordMigrations:(record,replay)=>{ledger={record,replay}}});await recordLibsqlMigrations(ledger.record,ledger.replay);return quarantined}finally{mirror.close()}}async function restoreUniqueIndexesAfterBatch(corpusDir){if(usesLibsql()){const mirror=await loadLibsqlSchemaMirror();let missing;try{missing=restorableMissingUniqueIndexes(mirror,corpusDir)}finally{mirror.close()}for(const index of missing){try{await db.unsafe(index.statement).execute()}catch(error){const detail=error instanceof Error?error.message:String(error);throw Error(`Could not restore unique index "${index.name}" on "${index.table}" (declared in ${index.file}), which a later table rebuild dropped: ${detail}`)}log.info(`Restored unique index ${index.name} on ${index.table} (declared in ${index.file}, dropped by a later table rebuild)`)}return}const dbPath=sqliteDatabasePath();if(!existsSync(dbPath))return;const{Database}=require("bun:sqlite"),liveDb=new Database(dbPath);try{liveDb.exec("PRAGMA busy_timeout = 5000");for(const index of restoreDroppedUniqueIndexes(liveDb,corpusDir))log.info(`Restored unique index ${index.name} on ${index.table} (declared in ${index.file}, dropped by a later table rebuild)`)}finally{liveDb.close()}}export async function runDatabaseMigration(){await ensureDatabaseConfigLoaded();const startedAt=Date.now(),hidden=await hideDisabledFeatureMigrations();let quarantinedMigrations=[],lockHandle=null;try{log.debug("Migrating database...");await ensureDatabaseReady();configureQueryBuilder();const dialect=getDialect();if(usesLibsql())lockHandle=await acquireLibsqlMigrationLock(db);else{const lockDb=dialect==="sqlite"?null:createQueryBuilder();lockHandle=await acquireMigrationLock(dialect,lockDb)}try{const{packageMigrationRoots}=await import("@stacksjs/config"),staged=stagePackageMigrations({roots:packageMigrationRoots(),corpusDir:migrationDirectory(dialect)});if(staged.length>0){const packages=[...new Set(staged.map((s)=>s.package))].join(", ");log.info(`Staged ${staged.length} migration(s) from ${packages}`)}}catch(error){log.warn(`Could not stage package migrations: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite"){quarantinedMigrations=usesLibsql()?await preprocessLibsqlMigrations():preprocessSqliteMigrations();hidden.push(...quarantinedMigrations)}else if(dialect==="postgres")makeMigrationsIdempotent();const corpusDir=migrationDirectory(dialect),migrationsDir=migrationDirectory();let migrationSql="";try{migrationSql=readdirSync(migrationsDir).filter((file)=>file.endsWith(".sql")).sort().map((file)=>readFileSync(join(migrationsDir,file),"utf8")).join(`
21
- `)}catch{}if(migrationSql){const preflightTables=notificationTablesMissingCreateStatements(migrationSql);if(preflightTables.length>0){const preflight=await migrateNotificationTables({tables:preflightTables});if(!preflight.success)throw Error(preflight.error||"Failed to prepare notification tables before migrations")}}const appliedBefore=await countAppliedMigrations();if(dialect==="sqlite"&&appliedBefore>0)await ensureUuidColumns(sqlHelpers(dialect));await catchUpFrameworkRenames();log.debug(`[migration] Running migrations from: ${corpusDir}`);await qbExecuteMigration(corpusDir);const appliedAfter=await countAppliedMigrations();if(dialect==="sqlite"&&appliedAfter>appliedBefore)await ensureUuidColumns(sqlHelpers(dialect));await catchUpFrameworkRenames();if(dialect==="sqlite")await restoreUniqueIndexesAfterBatch(corpusDir);const notificationTables=await migrateNotificationTables();if(!notificationTables.success)throw Error(notificationTables.error||"Failed to prepare notification tables");await ensureNotificationForeignKeys();const appliedCount=Math.max(0,appliedAfter-appliedBefore);await writeMigrateMarker(appliedCount);let ledgerRows=null;try{const rows=await db.unsafe("SELECT migration FROM migrations").execute();ledgerRows=(Array.isArray(rows)?rows:[]).map((row)=>String(row.migration??""))}catch(error){const message=error instanceof Error?error.message:String(error);if(/no such table|does not exist|doesn't exist/i.test(message))ledgerRows=[];else log.warn(`[migration] Could not read the migrations table to confirm every file was applied: ${message}`)}const stillPending=ledgerRows?unrecordedMigrationFiles(listCorpusFiles(corpusDir),ledgerRows):[];if(stillPending.length>0&&process.env.STACKS_ALLOW_PENDING_MIGRATIONS!=="1")return err(Error(`Applied ${appliedCount} migration${appliedCount===1?"":"s"}, but ${stillPending.length} migration file${stillPending.length===1?" is":"s are"} still not recorded in the migrations table after the run: ${stillPending.slice(0,5).join(", ")}${stillPending.length>5?`, +${stillPending.length-5} more`:""}. The runner did not apply ${stillPending.length===1?"it":"them"}, so the schema is behind the corpus. Run \`./buddy migrate:status\` to see what the database actually has. Set STACKS_ALLOW_PENDING_MIGRATIONS=1 to proceed anyway.`));log.debug(`Database migration completed in ${Date.now()-startedAt}ms (applied ${appliedCount}).`);if(quarantinedMigrations.length>0&&process.env.STACKS_ALLOW_PENDING_MIGRATIONS!=="1"){const names=quarantinedMigrations.map((q)=>basename(q.original));return err(Error(`Applied ${appliedCount} migration${appliedCount===1?"":"s"}, but ${names.length} could not run on SQLite and ${names.length===1?"is":"are"} still pending: ${names.join(", ")}. ${names.length===1?"It mixes":"They mix"} statements SQLite cannot execute with statements it can, so splitting them into separate migrations lets the runnable half apply. Set STACKS_ALLOW_PENDING_MIGRATIONS=1 to proceed anyway.`))}return ok(appliedCount===0?"Nothing to migrate.":`Applied ${appliedCount} migration${appliedCount===1?"":"s"}.`)}catch(error){const detail=error instanceof Error?error.message:String(error);log.error(`[migration] Failed after ${Date.now()-startedAt}ms: ${detail}`);if(/UNIQUE constraint failed/i.test(detail)){const viaIndex=/index\s+'/i.test(detail);log.info("[migration] This is a data conflict, not a schema one - `migrate:fresh` replays the same statements against the same rows and fails the same way. Clear or de-duplicate the offending rows first.");if(viaIndex)log.info("[migration] The error names an index rather than a column, which SQLite only does for an expression index or a table rebuild - so the conflict is arising while rows are being copied, not from a bare CREATE UNIQUE INDEX.")}else log.info("[migration] Run `./buddy migrate:fresh` to drop and recreate the schema if state is partial.");return err(handleError("Migration failed",error))}finally{if(lockHandle)try{await lockHandle.release()}catch{}await restoreHiddenMigrations(hidden)}}function listCorpusFiles(dir){try{return readdirSync(dir).filter((file)=>file.endsWith(".sql"))}catch{return[]}}export async function pendingMigrationFiles(){await ensureDatabaseConfigLoaded();configureQueryBuilder();const dir=migrationDirectory(getDialect()),gated=new Set((await featureGatedMigrationFiles(dir)).map((entry)=>entry.file));let ledger=[];try{const rows=await db.unsafe("SELECT migration FROM migrations").execute();ledger=(Array.isArray(rows)?rows:[]).map((row)=>String(row.migration??""))}catch(error){const message=error instanceof Error?error.message:String(error);if(!/no such table|does not exist|doesn't exist/i.test(message))throw error}return unrecordedMigrationFiles(listCorpusFiles(dir).filter((file)=>!gated.has(file)),ledger)}export async function missingRecordedTables(){await ensureDatabaseConfigLoaded();configureQueryBuilder();const dialect=getDialect(),dir=migrationDirectory(dialect),ledgerDialect=dialect==="vitess"?"mysql":dialect,{supported,missing,live}=await auditMissingTables({dir,dialect:ledgerDialect});if(!supported||missing.length===0)return[];const renamed=new Map;for(const rename of frameworkRenamesForThisApp())for(const table of rename.tables)renamed.set(table.from.toLowerCase(),table.to.toLowerCase());return missing.filter((table)=>{const seen=new Set([table]);for(let current=renamed.get(table);current&&!seen.has(current);current=renamed.get(current)){if(live.has(current))return!1;seen.add(current)}return!0})}export function unrecordedMigrationFiles(diskFiles,ledger){const recorded=new Set(ledger);return diskFiles.filter((file)=>!recorded.has(file)).sort()}const FRAMEWORK_TABLES=["oauth_refresh_tokens","oauth_access_tokens","oauth_clients","passkeys","failed_jobs","jobs","notifications","password_reset_tokens"];export async function resetDatabase(){await ensureDatabaseConfigLoaded();try{await ensureDatabaseReady();configureQueryBuilder();const modelsDir=path.userModelsPath(),dialect=getDialect();await dropFrameworkTables(dialect);if(existsSync(modelsDir))await qbResetDatabase(modelsDir,{dialect,preserveMigrationState:!0});else log.debug(`No models directory at ${modelsDir}; skipping model table drops.`);const defaultsDir=defaultModelsPath();if(existsSync(defaultsDir))await qbResetDatabase(defaultsDir,{dialect,preserveMigrationState:!0});else log.debug(`No framework default models directory at ${defaultsDir}; skipping.`);if(dialect==="postgres")await dropOrphanedEnumTypes();return ok("All tables dropped successfully!")}catch(error){return err(handleError("Database reset failed",error))}}async function dropOrphanedEnumTypes(){try{const raw=await db.unsafe(`
21
+ `)}catch{}if(migrationSql){const preflightTables=notificationTablesMissingCreateStatements(migrationSql);if(preflightTables.length>0){const preflight=await migrateNotificationTables({tables:preflightTables});if(!preflight.success)throw Error(preflight.error||"Failed to prepare notification tables before migrations")}}const appliedBefore=await countAppliedMigrations();if(dialect==="sqlite"&&appliedBefore>0)await ensureUuidColumns(sqlHelpers(dialect));await catchUpFrameworkRenames();log.debug(`[migration] Running migrations from: ${corpusDir}`);const strict=process.env.STACKS_MIGRATE_STRICT==="1";await qbExecuteMigrationWithReport(corpusDir,strict?{strict:!0}:{auditDrift:!1});const appliedAfter=await countAppliedMigrations();if(dialect==="sqlite"&&appliedAfter>appliedBefore)await ensureUuidColumns(sqlHelpers(dialect));await catchUpFrameworkRenames();if(dialect==="sqlite")await restoreUniqueIndexesAfterBatch(corpusDir);const notificationTables=await migrateNotificationTables();if(!notificationTables.success)throw Error(notificationTables.error||"Failed to prepare notification tables");await ensureNotificationForeignKeys();const appliedCount=Math.max(0,appliedAfter-appliedBefore);await writeMigrateMarker(appliedCount);let ledgerRows=null;try{const rows=await db.unsafe("SELECT migration FROM migrations").execute();ledgerRows=(Array.isArray(rows)?rows:[]).map((row)=>String(row.migration??""))}catch(error){const message=error instanceof Error?error.message:String(error);if(/no such table|does not exist|doesn't exist/i.test(message))ledgerRows=[];else log.warn(`[migration] Could not read the migrations table to confirm every file was applied: ${message}`)}const stillPending=ledgerRows?unrecordedMigrationFiles(listCorpusFiles(corpusDir),ledgerRows):[];if(stillPending.length>0&&process.env.STACKS_ALLOW_PENDING_MIGRATIONS!=="1")return err(Error(`Applied ${appliedCount} migration${appliedCount===1?"":"s"}, but ${stillPending.length} migration file${stillPending.length===1?" is":"s are"} still not recorded in the migrations table after the run: ${stillPending.slice(0,5).join(", ")}${stillPending.length>5?`, +${stillPending.length-5} more`:""}. The runner did not apply ${stillPending.length===1?"it":"them"}, so the schema is behind the corpus. Run \`./buddy migrate:status\` to see what the database actually has. Set STACKS_ALLOW_PENDING_MIGRATIONS=1 to proceed anyway.`));log.debug(`Database migration completed in ${Date.now()-startedAt}ms (applied ${appliedCount}).`);if(quarantinedMigrations.length>0&&process.env.STACKS_ALLOW_PENDING_MIGRATIONS!=="1"){const names=quarantinedMigrations.map((q)=>basename(q.original));return err(Error(`Applied ${appliedCount} migration${appliedCount===1?"":"s"}, but ${names.length} could not run on SQLite and ${names.length===1?"is":"are"} still pending: ${names.join(", ")}. ${names.length===1?"It mixes":"They mix"} statements SQLite cannot execute with statements it can, so splitting them into separate migrations lets the runnable half apply. Set STACKS_ALLOW_PENDING_MIGRATIONS=1 to proceed anyway.`))}return ok(appliedCount===0?"Nothing to migrate.":`Applied ${appliedCount} migration${appliedCount===1?"":"s"}.`)}catch(error){const detail=error instanceof Error?error.message:String(error);log.error(`[migration] Failed after ${Date.now()-startedAt}ms: ${detail}`);if(/UNIQUE constraint failed/i.test(detail)){const viaIndex=/index\s+'/i.test(detail);log.info("[migration] This is a data conflict, not a schema one - `migrate:fresh` replays the same statements against the same rows and fails the same way. Clear or de-duplicate the offending rows first.");if(viaIndex)log.info("[migration] The error names an index rather than a column, which SQLite only does for an expression index or a table rebuild - so the conflict is arising while rows are being copied, not from a bare CREATE UNIQUE INDEX.")}else log.info("[migration] Run `./buddy migrate:fresh` to drop and recreate the schema if state is partial.");return err(handleError("Migration failed",error))}finally{if(lockHandle)try{await lockHandle.release()}catch{}await restoreHiddenMigrations(hidden)}}function listCorpusFiles(dir){try{return readdirSync(dir).filter((file)=>file.endsWith(".sql"))}catch{return[]}}export async function pendingMigrationFiles(){await ensureDatabaseConfigLoaded();configureQueryBuilder();const dir=migrationDirectory(getDialect()),gated=new Set((await featureGatedMigrationFiles(dir)).map((entry)=>entry.file));let ledger=[];try{const rows=await db.unsafe("SELECT migration FROM migrations").execute();ledger=(Array.isArray(rows)?rows:[]).map((row)=>String(row.migration??""))}catch(error){const message=error instanceof Error?error.message:String(error);if(!/no such table|does not exist|doesn't exist/i.test(message))throw error}return unrecordedMigrationFiles(listCorpusFiles(dir).filter((file)=>!gated.has(file)),ledger)}export async function missingRecordedTables(){await ensureDatabaseConfigLoaded();configureQueryBuilder();const dialect=getDialect(),dir=migrationDirectory(dialect),ledgerDialect=dialect==="vitess"?"mysql":dialect,{supported,missing,live}=await auditMissingTables({dir,dialect:ledgerDialect});if(!supported||missing.length===0)return[];const renamed=new Map;for(const rename of frameworkRenamesForThisApp())for(const table of rename.tables)renamed.set(table.from.toLowerCase(),table.to.toLowerCase());return missing.filter((table)=>{const seen=new Set([table]);for(let current=renamed.get(table);current&&!seen.has(current);current=renamed.get(current)){if(live.has(current))return!1;seen.add(current)}return!0})}export function unrecordedMigrationFiles(diskFiles,ledger){const recorded=new Set(ledger);return diskFiles.filter((file)=>!recorded.has(file)).sort()}const FRAMEWORK_TABLES=["oauth_refresh_tokens","oauth_access_tokens","oauth_clients","passkeys","failed_jobs","jobs","notifications","password_reset_tokens"];export async function resetDatabase(){await ensureDatabaseConfigLoaded();try{await ensureDatabaseReady();configureQueryBuilder();const modelsDir=path.userModelsPath(),dialect=getDialect();await dropFrameworkTables(dialect);if(existsSync(modelsDir))await qbResetDatabase(modelsDir,{dialect,preserveMigrationState:!0});else log.debug(`No models directory at ${modelsDir}; skipping model table drops.`);const defaultsDir=defaultModelsPath();if(existsSync(defaultsDir))await qbResetDatabase(defaultsDir,{dialect,preserveMigrationState:!0});else log.debug(`No framework default models directory at ${defaultsDir}; skipping.`);if(dialect==="postgres")await dropOrphanedEnumTypes();return ok("All tables dropped successfully!")}catch(error){return err(handleError("Database reset failed",error))}}async function dropOrphanedEnumTypes(){try{const raw=await db.unsafe(`
22
22
  SELECT t.typname AS name
23
23
  FROM pg_type t
24
24
  JOIN pg_namespace n ON n.oid = t.typnamespace
@@ -29,14 +29,14 @@ Create it yourself with: ${manualCreateHint(target)}`);if(result.created)log.su
29
29
  JOIN pg_class c ON c.oid = a.attrelid
30
30
  WHERE a.atttypid = t.oid AND c.relkind = 'r' AND NOT a.attisdropped
31
31
  )
32
- `).execute(),names=(Array.isArray(raw)?raw:raw?.rows??[]).map((row)=>String(row.name??row.typname??"")).filter(Boolean);for(const name of names)try{await db.unsafe(`DROP TYPE IF EXISTS "${name}" CASCADE`).execute();log.debug(`Dropped orphaned enum type: ${name}`)}catch(error){log.warn(`Could not drop enum type ${name}: ${error instanceof Error?error.message:String(error)}`)}}catch(error){log.warn(`Could not list enum types to drop: ${error instanceof Error?error.message:String(error)}`)}}async function dropFrameworkTables(dialect){if(dialect==="mysql"||dialect==="vitess")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 0").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = OFF").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}for(const tableName of FRAMEWORK_TABLES)try{let dropSql;if(dialect==="postgres")dropSql=`DROP TABLE IF EXISTS "${tableName}" CASCADE`;else if(dialect==="mysql"||dialect==="vitess")dropSql=`DROP TABLE IF EXISTS \`${tableName}\``;else dropSql=`DROP TABLE IF EXISTS "${tableName}"`;log.info(`Dropping framework table: ${tableName}`);await db.unsafe(dropSql).execute();log.info(`Dropped framework table: ${tableName}`)}catch(error){const kind=classifyConnectionError(error);if(kind==="missing-database"||kind==="missing-role"||kind==="auth-failed"||kind==="server-unreachable"||kind==="timeout")throw error;log.warn(`Could not drop table ${tableName}: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="mysql"||dialect==="vitess")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 1").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = ON").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}}function frameworkRenamesForThisApp(){return applicableFrameworkRenames(declaredModelTables())}function describeRenames(renames){return renames.map((rename)=>`${rename.id}, ${rename.reference}`).join("; ")}export function catchUpSnapshotFrameworkRenames(options){configureQueryBuilder();const dialect=getQbDialect(),stored=readStoredMigrationPlan(dialect);if(!stored)return{changes:[]};const renames=frameworkRenamesForThisApp();if(renames.length===0)return{changes:[]};const{plan,changes}=applyFrameworkRenamesToPlan(stored,renames);if(changes.length===0)return{changes};if(options.persist){saveMigrationSnapshot(plan,{dialect});log.info(`[migration] Updated the model snapshot for framework renames (${describeRenames(renames)}): ${changes.join(", ")}`)}else log.debug(`[migration] Previewing against the snapshot with framework renames applied: ${changes.join(", ")}`);return{changes,plan}}export async function catchUpFrameworkRenames(options={}){await ensureDatabaseConfigLoaded();const dialect=toSqlIntrospectionDialect(getDialect());if(dialect==="other")return{applied:[],skipped:[]};const renames=frameworkRenamesForThisApp();if(renames.length===0)return{applied:[],skipped:[]};const runner=options.runner??(async(sql)=>{const result=await db.unsafe(sql).execute();return Array.isArray(result)?result:[]}),result=await applyFrameworkRenamesToDatabase(runner,dialect,renames);if(result.applied.length>0)log.info(`[migration] Applied framework renames to the database (${describeRenames(renames)}): ${result.applied.join(", ")}`);for(const skipped of result.skipped)log.warn(`[migration] Framework rename not applied: ${skipped}`);return result}function resolveGenerateOptions(options){const applyRenames=options.applyRenames??(process.env.STACKS_MIGRATE_NO_RENAME==="1"?!1:void 0),fromDb=options.fromDb??(process.env.STACKS_MIGRATE_FROM_DB==="1"?!0:void 0);return{applyRenames,fromDb}}export async function previewPendingMigrations(options={}){try{return await pendingMigrationOperations(options)}catch(error){log.debug(`[migration] preview failed: ${error instanceof Error?error.message:String(error)}`);return[]}}export async function pendingMigrationOperations(options={}){await ensureDatabaseConfigLoaded();configureQueryBuilder();const dialect=getDialect(),{modelsDir,skip,protectedTables}=prepareMigrationModelsDir();if(skip)return[];const{applyRenames,fromDb}=resolveGenerateOptions(options),qbDialect=getQbDialect(),result=await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,dryRun:!0,applyRenames,fromDb}),corrected=fromDb?void 0:catchUpSnapshotFrameworkRenames({persist:!1}).plan;if(corrected&&result.plan)result.operations=generateDiffOperations(corrected,result.plan,{applyRenames,dryRun:!0}).operations;const authOwned=new Set(AUTH_TABLES);let operations=(result.operations??[]).filter((op)=>!authOwned.has(op.table.toLowerCase()));if(protectedTables.length>0){const excluded=new Set(protectedTables.map((table)=>table.toLowerCase()));operations=operations.filter((op)=>!(op.kind==="drop_table"&&excluded.has(op.table.toLowerCase())))}if(!operations.some((op)=>op.kind==="drop_column"))return operations;return withoutManagedColumnDrops(operations,await frameworkManagedColumns())}function snapshotDirLabel(){return qbConfig?.snapshotDir||qbSnapshotDir()}function resolveSnapshotDir(){const label=snapshotDirLabel();return isAbsolute(label)?label:resolve(process.cwd(),label)}function snapshotPathFor(dialect){return join(resolveSnapshotDir(),`model-snapshot.${dialect}.json`)}function readStoredMigrationPlan(dialect){const snapshotPath=snapshotPathFor(dialect);if(!existsSync(snapshotPath))return;let parsed;try{parsed=JSON.parse(readFileSync(snapshotPath,"utf8"))}catch(error){throw Error(`The migration snapshot is not valid JSON: ${snapshotPath}. Repair or regenerate it before creating migrations.`,{cause:error})}const candidate=parsed&&typeof parsed==="object"&&"plan"in parsed?parsed.plan:parsed;if(!candidate||typeof candidate!=="object"||!Array.isArray(candidate.tables))throw TypeError(`The migration snapshot has an invalid structure: ${snapshotPath}. Repair or regenerate it before creating migrations.`);return candidate}export function preserveMigrationPlanTableOrder(next,previous){const orderedByPrevious=(values,previousValues,keyFor)=>{if(!previousValues)return[...values];const previousOrder=new Map(previousValues.map((value,index)=>[keyFor(value),index])),nextOrder=new Map(values.map((value,index)=>[keyFor(value),index])),previousCount=previousValues.length;return[...values].sort((left,right)=>{const leftKey=keyFor(left),rightKey=keyFor(right),leftOrder=previousOrder.get(leftKey)??previousCount+nextOrder.get(leftKey),rightOrder=previousOrder.get(rightKey)??previousCount+nextOrder.get(rightKey);return leftOrder-rightOrder})},previousTables=new Map(previous?.tables.map((table)=>[table.table,table])??[]),tables=next.tables.map((table)=>{const previousTable=previousTables.get(table.table),previousColumns=new Map(previousTable?.columns.map((column)=>[column.name,column])??[]);return{...table,columns:orderedByPrevious(table.columns.map((column)=>{if(next.dialect!=="sqlite"||!column.enumTypeName||previousColumns.get(column.name)?.enumTypeName)return column;const portableColumn={...column};delete portableColumn.enumTypeName;return portableColumn}),previousTable?.columns,(column)=>column.name),indexes:orderedByPrevious(table.indexes,previousTable?.indexes,(index)=>index.name)}});return{...next,tables:orderedByPrevious(tables,previous?.tables,(table)=>table.table)}}function detectSnapshotDialectMismatch(dialect){const qbDir=resolveSnapshotDir();let files;try{files=readdirSync(qbDir)}catch{return null}const snapshotFor=(d)=>`model-snapshot.${d}.json`;if(files.includes(snapshotFor(dialect)))return null;for(const f of files){const m=/^model-snapshot\.(\w+)\.json$/.exec(f);if(m?.[1]&&m[1]!==dialect)return m[1]}return null}export async function generateMigrations(options={}){await ensureDatabaseConfigLoaded();try{log.debug("Generating migrations...");configureQueryBuilder();const dialect=getDialect(),mismatch=detectSnapshotDialectMismatch(dialect),flatMigrationDir=join(process.cwd(),"database","migrations");if(mismatch&&migrationDirectory(dialect)===flatMigrationDir){const snapshotDir=snapshotDirLabel();return err(Error(`Refusing to generate migrations: resolved dialect "${dialect}" has no snapshot in ${snapshotDir}/, but "${mismatch}" does. DB_CONNECTION is likely unset or wrong (missing .env?) - generating now would write a full duplicate migration set in the wrong dialect. Set DB_CONNECTION=${mismatch} (or your intended dialect) and retry. To intentionally start a new dialect from scratch, remove ${snapshotDir}/model-snapshot.${mismatch}.json first.`))}catchUpSnapshotFrameworkRenames({persist:!0});const storedPlan=readStoredMigrationPlan(getQbDialect()),{modelsDir,skip,excludedTables,protectedTables}=prepareMigrationModelsDir();if(skip){trackIgnoredGeneratedMigrations(migrationDirectory());log.debug("No app/Models directory found; using committed framework migrations");return ok("Migrations generated")}const{applyRenames,fromDb}=resolveGenerateOptions(options);log.debug(`[migration] Generating migrations for dialect: ${dialect}, models: ${modelsDir}`);if(excludedTables.length>0)log.debug(`[migration] ${excludedTables.length} framework default model(s) out of scope because app/Models defines this app's schema. Enable database.models.includeFrameworkDefaults (or STACKS_INCLUDE_FRAMEWORK_MODELS=1) to generate them too.`);const qbDialect=getQbDialect(),result=await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,dryRun:!0,applyRenames,fromDb});let sqlStatements=result.sqlStatements??[];if(dialect==="sqlite")sqlStatements=inlineSqliteAddedColumnReferences(sqlStatements,result.plan);{const runtimeOwned=withoutRuntimeOwnedTableSql(sqlStatements);if(runtimeOwned.removed.length>0)log.debug(`[migration] Left out ${runtimeOwned.removed.length} generated statement(s) for tables the auth layer creates itself`);sqlStatements=runtimeOwned.statements}if(result.hasChanges&&sqlStatements.length>0){const filtered=withoutManagedColumnDropSql(sqlStatements,await frameworkManagedColumns(),result.operations??[]);if(filtered.removed.length>0)log.debug(`[migration] Skipped ${filtered.removed.length} generated drop(s) of framework-managed column(s) (stacksjs/stacks#2075)`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0&&protectedTables.length>0){const filtered=withoutProtectedTableDropSql(sqlStatements,protectedTables,result.operations??[]);if(filtered.removed.length>0)log.info(`[migration] Left ${filtered.removed.length} framework-owned table(s) in place rather than dropping them. They are no longer generated because app/Models defines this app's schema; the tables and their data are untouched. Set database.models.includeFrameworkDefaults to keep generating them (stacksjs/stacks#2220).`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0){const dangling=findDanglingTypeReferences(sqlStatements);if(dangling.length>0){const created=createMissingEnumTypes(dangling,result.plan);if(created.statements.length>0){sqlStatements=[...created.statements,...sqlStatements];log.debug(`[migration] Created ${created.statements.length} enum type(s) an ALTER needed: ${created.defined.join(", ")}`)}const unresolved=dangling.filter((name)=>!created.defined.includes(name));if(unresolved.length>0){const before=sqlStatements.length;sqlStatements=sqlStatements.filter((statement)=>!referencesUndefinedType(statement,unresolved));log.warn(`[migration] Skipped ${before-sqlStatements.length} generated statement(s) referencing enum type(s) nothing creates and no model defines values for (${unresolved.slice(0,3).join(", ")}${unresolved.length>3?", \u2026":""}).`)}}}if(result.hasChanges&&sqlStatements.length>0&&!shadowDropsAllowed()){const shadowed=resolveModelSources()?.shadowed??[],drops=findShadowedColumnDrops(sqlStatements,shadowed);if(drops.length>0)return err(Error(shadowedDropMessage(drops)))}if(result.hasChanges){const written=persistGeneratedMigrations(sqlStatements,storedPlan);if(written>0)log.success(`Generated ${written} migration file${written===1?"":"s"}`);else log.debug("Migration generation produced no new files (already up to date)")}else log.debug("No changes detected");trackIgnoredGeneratedMigrations(migrationDirectory());const stablePlan=preserveMigrationPlanTableOrder(result.plan,storedPlan);if(!storedPlan||JSON.stringify(stablePlan)!==JSON.stringify(storedPlan))saveMigrationSnapshot(stablePlan,{dialect:getQbDialect()});else log.debug("Model snapshot unchanged");return ok("Migrations generated")}catch(error){const because=error instanceof Error?error.message:String(error);return err(handleError(`Migration generation failed: ${because}`,error))}}export function referencesUndefinedType(statement,dangling){return dangling.some((name)=>statement.includes(`"${name}"`))}export function findDanglingTypeReferences(statements){const defined=new Set,referenced=new Set;for(const statement of statements){for(const match of statement.matchAll(/CREATE\s+TYPE\s+"([^"]+)"/gi))defined.add(match[1]);for(const match of statement.matchAll(/\bTYPE\s+"([^"]+)"/gi))referenced.add(match[1])}return[...referenced].filter((name)=>!defined.has(name)).sort()}export function createMissingEnumTypes(dangling,plan){if(dangling.length===0)return{statements:[],defined:[]};const values=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){if(!column.name||!column.enumValues?.length)continue;values.set(`${table.table}_${column.name}_type`,column.enumValues)}}const statements=[],defined=[];for(const name of dangling){const members=values.get(name);if(!members?.length)continue;const literals=members.map((member)=>`'${String(member).replaceAll("'","''")}'`).join(", "),guarded=guardPostgresEnumTypes(`CREATE TYPE "${name}" AS ENUM (${literals})`);statements.push(guarded.endsWith(";")?guarded:`${guarded};`);defined.push(name)}return{statements,defined}}export function inlineSqliteAddedColumnReferences(statements,plan){const references=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){const reference=column.references;if(!column.name||!reference?.table||!reference.column)continue;references.set(`${table.table}.${column.name}`,{table:reference.table,column:reference.column})}}if(references.size===0)return statements;return statements.map((statement)=>{if(/\bREFERENCES\b/i.test(statement))return statement;const match=statement.match(/^(\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+COLUMN\s+["`]?(\w+)["`]?\s+)([\s\S]*?)(;?\s*)$/i),reference=match?.[2]&&match[3]?references.get(`${match[2]}.${match[3]}`):void 0;if(!match||!reference)return statement;return`${match[1]}${match[4].trimEnd()} REFERENCES "${reference.table}"("${reference.column}")${match[5]}`})}export{GENERATED_MIGRATION_MARKER,isGeneratedMigration}from"./generated-migration-marker";export function tablesOperatedOn(sql){const tables=new Set;for(const statement of sqlStatementsOf(sql)){const stmt=statement.trim(),direct=stmt.match(/^(?:CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?|ALTER\s+TABLE|DROP\s+TABLE(?:\s+IF\s+EXISTS)?|TRUNCATE\s+TABLE)\s+["'`]?(\w+)["'`]?/i);if(direct?.[1]){tables.add(direct[1].toLowerCase());continue}const index=stmt.match(/^CREATE\s+(?:UNIQUE\s+)?INDEX(?:\s+IF\s+NOT\s+EXISTS)?\s+\S+\s+ON\s+["'`]?(\w+)["'`]?/i);if(index?.[1])tables.add(index[1].toLowerCase())}return[...tables]}export function columnsDefinedByCreate(statement){const body=statement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?\w+["'`]?\s*\(([\s\S]*)\)\s*;?\s*$/i)?.[1];if(!body)return[];const parts=[];let depth=0,current="";for(const char of body){if(char==="(")depth++;if(char===")")depth--;if(char===","&&depth===0){parts.push(current);current="";continue}current+=char}parts.push(current);const constraint=/^\s*(?:PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK|CONSTRAINT|KEY|INDEX)\b/i;return parts.map((part)=>part.trim()).filter((part)=>part&&!constraint.test(part)).flatMap((part)=>part.match(/^["'`]?(\w+)["'`]?/)?.[1]??[])}export function columnsProducedByMigrations(dir,files,table){const columns=new Set,target=table.toLowerCase();for(const file of[...files].sort()){let content;try{content=readFileSync(join(dir,file),"utf8")}catch{continue}for(const statement of sqlStatementsOf(content)){const stmt=statement.trim();if(stmt.match(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i)?.[1]?.toLowerCase()===target){for(const column of columnsDefinedByCreate(stmt))columns.add(column.toLowerCase());continue}const added=stmt.match(/^ALTER\s+TABLE\s+["'`]?(\w+)["'`]?\s+ADD\s+(?:COLUMN\s+)?["'`]?(\w+)["'`]?/i);if(added?.[1]?.toLowerCase()===target&&added[2])columns.add(added[2].toLowerCase());const dropped=stmt.match(/^ALTER\s+TABLE\s+["'`]?(\w+)["'`]?\s+DROP\s+(?:COLUMN\s+)?["'`]?(\w+)["'`]?/i);if(dropped?.[1]?.toLowerCase()===target&&dropped[2])columns.delete(dropped[2].toLowerCase());const rebuilt=stmt.match(/^ALTER\s+TABLE\s+["'`]?_qb_tmp_(\w+)["'`]?\s+RENAME\s+TO\s+["'`]?(\w+)["'`]?/i);if(rebuilt?.[2]?.toLowerCase()===target){const temp=sqlStatementsOf(content).find((s)=>new RegExp(`^CREATE\\s+TABLE\\s+["'\`]?_qb_tmp_${rebuilt[1]}["'\`]?`,"i").test(s.trim()));if(temp){columns.clear();for(const column of columnsDefinedByCreate(temp))columns.add(column.toLowerCase())}}}}return columns}export function rootedTableCatchUpStatements(createStatement,existingColumns){const table=createStatement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i)?.[1];if(!table)return[];const body=createStatement.match(/\(([\s\S]*)\)\s*;?\s*$/)?.[1];if(!body)return[];const definitions=new Map;let depth=0,current="";const parts=[];for(const char of body){if(char==="(")depth++;if(char===")")depth--;if(char===","&&depth===0){parts.push(current);current="";continue}current+=char}parts.push(current);const constraint=/^\s*(?:PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK|CONSTRAINT|KEY|INDEX)\b/i;for(const raw of parts){const part=raw.trim();if(!part||constraint.test(part))continue;const name=part.match(/^["'`]?(\w+)["'`]?/)?.[1];if(name)definitions.set(name.toLowerCase(),part)}const statements=[];for(const[name,definition]of definitions){if(existingColumns.has(name))continue;if(/\b(?:PRIMARY\s+KEY|UNIQUE|AUTOINCREMENT)\b/i.test(definition))continue;const nullable=/\bNOT\s+NULL\b/i.test(definition)&&!/\bDEFAULT\b/i.test(definition)?definition.replace(/\s*\bNOT\s+NULL\b/i,""):definition;statements.push(`ALTER TABLE "${table}" ADD COLUMN ${nullable.trim()}`)}return statements}export function createdTablesOf(statements){const tables=new Set;for(const statement of statements){const match=statement.trim().match(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i);if(match?.[1])tables.add(match[1].toLowerCase())}return[...tables]}export function historicallyRootedTables(dir,files){const tables=new Set;for(const file of files){if(isGeneratedMigration(dir,file))continue;try{for(const statement of sqlStatementsOf(readFileSync(join(dir,file),"utf8"))){const match=statement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i);if(match?.[1])tables.add(match[1].toLowerCase())}}catch{}}return[...tables]}export function tablesDefinedByCorpus(dir,files){const tables=new Set;for(const file of files)try{for(const statement of sqlStatementsOf(readFileSync(join(dir,file),"utf8"))){const match=statement.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i);if(match?.[1])tables.add(match[1].toLowerCase())}}catch{}return[...tables]}export function migrationTouchesRootedTable(dir,file,rootedTables){try{return tablesOperatedOn(readFileSync(join(dir,file),"utf8")).some((table)=>rootedTables.has(table.toLowerCase()))}catch{return!1}}export function allocateMigrationOrdinals(count,startAt,reserved){const ordinals=[];let cursor=startAt;while(ordinals.length<count){if(!reserved.has(cursor))ordinals.push(cursor);cursor+=1}return ordinals}export function migrationsOutsideCorpus(dir,files,corpusTables){const rebuilt=new Set(corpusTables.map((table)=>table.toLowerCase()));return files.filter((file)=>{let contents;try{contents=readFileSync(join(dir,file),"utf8")}catch{return!0}const touched=tablesOperatedOn(contents);if(touched.length===0)return!0;return touched.some((table)=>!rebuilt.has(table))})}function migrationOrdinal(file){const match=file.match(/^(\d+)/);return match?Number(match[1]):0}export async function regenerateMigrationCorpus(options={}){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 dir=options.dir??migrationDirectory(dialect),sources=resolveModelSources({forceStage:!0,...options.onlyExistingTables?{includeFrameworkDefaults:!0}:{}});if(!sources)return err(Error("No models found. Define models in app/Models, or ensure the framework defaults at storage/framework/defaults/app/Models are present."));try{writeFileSync(join(sources.dir,`.qb-migrations.${dialect}.json`),JSON.stringify({plan:{dialect,tables:[]}}))}catch{}const snapshotPath=join(resolveSnapshotDir(),`model-snapshot.${dialect}.json`),parkedSnapshot=`${snapshotPath}.regenerating`;let snapshotParked=!1;if(existsSync(snapshotPath))try{renameSync(snapshotPath,parkedSnapshot);snapshotParked=!0}catch{}let result;try{result=await qbGenerateMigration(sources.dir,{dialect,vitessSharded:requestedVitessSharded,dryRun:!0,full:!0})}finally{if(snapshotParked)try{renameSync(parkedSnapshot,snapshotPath)}catch{}}const statements=result.sqlStatements??[];if(statements.length===0)return err(Error(`The generator produced no SQL for dialect "${dialect}".`));const dangling=findDanglingTypeReferences(statements);if(dangling.length>0)return err(Error(`The generator emitted ${dangling.length} reference(s) to enum type(s) it never creates: ${dangling.slice(0,5).join(", ")}${dangling.length>5?`, +${dangling.length-5} more`:""}. Writing this corpus would fail partway through a migration. This is a bug in the migration generator, not in your models.`));let groups=groupGeneratedStatements(statements),existing=[];try{existing=readdirSync(dir).filter((f)=>f.endsWith(".sql")).sort()}catch{}let unrebuildable=[];if(options.onlyExistingTables){const corpusTables=new Set(tablesDefinedByCorpus(dir,existing));if(corpusTables.size===0)return err(Error(`No CREATE TABLE statements found in ${dir}, so there is nothing to regenerate in place. Run \`buddy migrate:regenerate <dialect>\` without --only-existing-tables to write a corpus from your models.`));const emitted=new Set(createdTablesOf(statements).map((table)=>table.toLowerCase()));unrebuildable=[...corpusTables].filter((table)=>!emitted.has(table)).sort();groups=groups.map((group)=>({...group,statements:group.statements.filter((statement)=>{const table=statementTable(statement);return table?corpusTables.has(table.toLowerCase()):!1})})).filter((group)=>group.statements.length>0);if(groups.length===0)return err(Error(`None of the ${corpusTables.size} table(s) in ${dir} have a model behind them, so none can be regenerated. Declare the models, or publish the framework ones with \`buddy publish model <Name>\`.`))}const rootedTables=new Set(options.replaceUnmarked||options.onlyExistingTables?[]:historicallyRootedTables(dir,existing)),outOfScope=new Set(migrationsOutsideCorpus(dir,existing,createdTablesOf(statements))),removed=(options.replaceUnmarked||options.onlyExistingTables?existing:existing.filter((file)=>isGeneratedMigration(dir,file))).filter((file)=>!isPackageMigration(file)).filter((file)=>{return!outOfScope.has(file)&&!migrationTouchesRootedTable(dir,file,rootedTables)}),preserved=existing.filter((file)=>!removed.includes(file)),preservedOutOfScope=preserved.filter((file)=>outOfScope.has(file)),catchUp=[];for(const table of rootedTables){const create=groups.flatMap((group)=>group.statements).find((statement)=>statementTable(statement)===table&&/^\s*CREATE\s+TABLE\b/i.test(statement));if(!create)continue;const produced=columnsProducedByMigrations(dir,preserved,table);if(produced.size===0)continue;catchUp.push(...rootedTableCatchUpStatements(create,produced))}const writableGroups=groups.map((group)=>({...group,statements:group.statements.filter((statement)=>{const table=statementTable(statement);return!table||!rootedTables.has(table)})})).filter((group)=>group.statements.length>0).concat(catchUp.length>0?[{label:"alter-rooted-tables-columns",statements:catchUp}]:[]),historicalBoundary=existing.filter((file)=>!isGeneratedMigration(dir,file)&&!isPackageMigration(file)).reduce((max,file)=>Math.max(max,migrationOrdinal(file)),0),startAt=rootedTables.size>0?historicalBoundary+1:preserved.filter((file)=>!isPackageMigration(file)).reduce((max,file)=>Math.max(max,migrationOrdinal(file)),0)+1,reservedOrdinals=new Set(preserved.map(migrationOrdinal)),ordinals=allocateMigrationOrdinals(writableGroups.length,startAt,reservedOrdinals),files=writableGroups.map((group,index)=>({name:`${String(ordinals[index]).padStart(10,"0")}-${group.label}.sql`,statements:group.statements.length}));if(options.dryRun)return ok({dialect,models:sources.models.length,modelRoots:sources.roots,files,removed,preserved,preservedOutOfScope,unrebuildable,dir});mkdirSync(dir,{recursive:!0});for(const file of removed)unlinkSync(join(dir,file));writableGroups.forEach((group,index)=>{const body=`${group.statements.map((s)=>s.trim().replace(/;\s*$/,"")).join(`;
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
34
  `;writeFileSync(join(dir,files[index].name),`${GENERATED_MIGRATION_MARKER}
35
- ${body}`)});trackIgnoredGeneratedMigrations(dir);saveMigrationSnapshot(result.plan,{dialect});return ok({dialect,models:sources.models.length,modelRoots:sources.roots,files,removed,preserved,preservedOutOfScope,unrebuildable,dir})}catch(error){return err(handleError("Migration regeneration failed",error))}}export function createdTableName(statement){return/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`[]?([\w$]+)["'`\]]?/i.exec(statement)?.[1]?.toLowerCase()}const unquoteIdentifiers=(sql)=>sql.replace(/["'`[\]]/g,"");export function indexShape(statement){const head=/^\s*CREATE\s+(UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`[]?([\w$]+)["'`\]]?\s+ON\s+["'`[]?([\w$]+)["'`\]]?\s*\(/i.exec(statement);if(!head?.[2]||!head[3])return;let depth=1,end=head[0].length;for(;end<statement.length&&depth>0;end++)if(statement[end]==="(")depth++;else if(statement[end]===")")depth--;if(depth>0)return;const columns=statement.slice(head[0].length,end-1).split(",").map((column)=>normalizeSqlForComparison(unquoteIdentifiers(column)).toLowerCase().replace(/\s+asc$/,"")),where=normalizeSqlForComparison(unquoteIdentifiers(statement.slice(end)).replace(/;\s*$/,"")).toLowerCase(),table=head[3].toLowerCase();return{name:head[2].toLowerCase(),table,key:`${head[1]?"unique":"index"} ${table} (${columns.join(", ")})${where?` ${where}`:""}`}}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(`
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(`
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
38
  `;writeFileSync(filePath,`${GENERATED_MIGRATION_MARKER}
39
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},
40
40
  ${inline.map((constraint)=>constraint.body).join(`,
41
41
  `)}
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}const alter=stmt.match(/^\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+(?:ADD\s+COLUMN\s+["`]?(\w+)["`]?|DROP\s+COLUMN\s+["`]?(\w+)["`]?|ADD\s+CONSTRAINT)/i),alterTable=alter?.[1];if(alter&&alterTable){const isCreateTimeConstraint=createdTables.has(alterTable)&&!alter[2]&&!alter[3];push(isCreateTimeConstraint?"create-foreign-key-constraints":`alter-${alterTable}-columns`,stmt);continue}const idx=stmt.match(/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?\s+ON\s+["`]?(\w+)["`]?/i),idxName=idx?.[1],idxTable=idx?.[2];if(idxName&&idxTable){push(createdTables.has(idxTable)?`create-${idxTable}-table`:`create-${idxName}-index-in-${idxTable}`,stmt);continue}const drop=stmt.match(/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["`]?(\w+)["`]?/i);if(drop){push(`drop-${drop[1]}-table`,stmt);continue}push("auto-misc",stmt)}const ordered=[...groups.entries()].map(([label,statements])=>({label,statements})).sort((a,b)=>Number(b.label==="create-database-types")-Number(a.label==="create-database-types"));return withTableDropsLast(ordered,previousPlan)}const DROP_TABLE_GROUP_RE=/^drop-(\w+)-table$/;export function withTableDropsLast(groups,previousPlan){const dropOf=(group)=>group.label.match(DROP_TABLE_GROUP_RE)?.[1],recreated=new Set(groups.flatMap((group)=>group.statements.flatMap((statement)=>{const table=createdTableName(statement);return table?[table]:[]}))),movable=groups.filter((group)=>{const table=dropOf(group);return table!==void 0&&!recreated.has(table.toLowerCase())});if(movable.length===0)return groups;const rest=groups.filter((group)=>!movable.includes(group)),drops=[...movable].reverse(),references=new Map;for(const table of previousPlan?.tables??[])references.set(table.table,new Set(table.columns.flatMap((column)=>column.references?.table&&column.references.table!==table.table?[column.references.table]:[])));const sorted=[],visiting=new Set,visit=(group)=>{if(sorted.includes(group)||visiting.has(group))return;visiting.add(group);for(const other of drops)if(other!==group&&references.get(dropOf(other))?.has(dropOf(group)))visit(other);visiting.delete(group);sorted.push(group)};for(const group of drops)visit(group);return[...rest,...sorted]}function nextMigrationNumber(migrationsDir){let max=0;try{for(const f of readdirSync(migrationsDir)){if(isPackageMigration(f))continue;const m=f.match(/^(\d+)-/);if(m?.[1])max=Math.max(max,Number.parseInt(m[1],10))}}catch{}return max+1}export async function generateMigrations2(){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))}}
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))}}
@@ -0,0 +1,147 @@
1
+ import type { MigrationPlan } from '@stacksjs/query-builder';
2
+ /**
3
+ * The generator's identifier rule (bun-query-builder): names past Postgres's
4
+ * 63-character limit keep their head and end in a short FNV-1a hash of the
5
+ * whole name, instead of being truncated by the server to a prefix two long
6
+ * names can share.
7
+ */
8
+ export declare function postgresIdentifier(name: string, max?: number): string;
9
+ /**
10
+ * The column definitions of a CREATE TABLE, by lowercased column name, as
11
+ * written. Table constraints carry no column name and are skipped.
12
+ */
13
+ export declare function columnDefinitionsOf(createStatement: string): Map<string, string>;
14
+ /** What the models want, read off the generator's own SQL and plan. */
15
+ export declare function desiredSchemaFrom(plan: MigrationPlan, statements: readonly string[]): DesiredSchema;
16
+ /** A default as both sides can spell it: no casts, no outer brackets, no quotes. */
17
+ export declare function comparableDefault(value: string | undefined): string | undefined;
18
+ /**
19
+ * Work out what `migrate:align` would do. Pure: reads nothing, writes nothing.
20
+ *
21
+ * The actions come back in the order they must run: labels before the columns
22
+ * that use them, columns before the indexes and keys over them, renames before
23
+ * creates so a rename is never mistaken for a missing index.
24
+ */
25
+ export declare function planSchemaAlignment(desired: DesiredSchema, live: LiveAlignSchema, options?: {
26
+
27
+ widenIntegers?: boolean
28
+
29
+ typeSatisfies?: (declaredType: string, liveSqlType: string) => boolean
30
+ }): AlignmentPlan;
31
+ /** Read the parts of a Postgres schema the planner compares. */
32
+ export declare function readPostgresAlignSchema(run: AlignSqlRunner): Promise<LiveAlignSchema>;
33
+ /**
34
+ * Plan, check and optionally apply an alignment.
35
+ *
36
+ * Checks run before anything is written: a foreign key with orphan rows, or a
37
+ * unique index over duplicate values, moves to `refused` with a count, rather
38
+ * than failing the batch halfway. Everything left then runs as ONE
39
+ * multi-statement string, which Postgres executes as a single implicit
40
+ * transaction - all of it lands, or none of it does.
41
+ */
42
+ export declare function alignSchema(options?: {
43
+ apply?: boolean
44
+ widenIntegers?: boolean
45
+ dialect?: string
46
+
47
+ run?: AlignSqlRunner
48
+ }): Promise<AlignResult>;
49
+ export declare interface DesiredIndex {
50
+ name: string
51
+ table: string
52
+ unique: boolean
53
+ columns?: string[]
54
+ key: string
55
+ where: string
56
+ statement: string
57
+ }
58
+ export declare interface DesiredColumn {
59
+ name: string
60
+ type: string
61
+ maxLength?: number
62
+ isPrimaryKey: boolean
63
+ isNullable: boolean
64
+ hasDefault: boolean
65
+ defaultValue?: unknown
66
+ definition?: string
67
+ references?: { table: string, column: string, onDelete?: string, onUpdate?: string, constraintNames?: string[] }
68
+ }
69
+ export declare interface DesiredSchema {
70
+ tables: Map<string, DesiredColumn[]>
71
+ indexes: DesiredIndex[]
72
+ enums: Map<string, string[]>
73
+ }
74
+ export declare interface LiveColumn {
75
+ name: string
76
+ dataType: string
77
+ udtName: string
78
+ maxLength?: number
79
+ nullable: boolean
80
+ default?: string
81
+ }
82
+ export declare interface LiveIndex {
83
+ name: string
84
+ table: string
85
+ unique: boolean
86
+ primary: boolean
87
+ key?: string
88
+ definition: string
89
+ constraint?: string
90
+ }
91
+ export declare interface LiveForeignKey {
92
+ name: string
93
+ table: string
94
+ column: string
95
+ refTable: string
96
+ refColumn: string
97
+ onDelete: string
98
+ onUpdate: string
99
+ }
100
+ export declare interface LiveAlignSchema {
101
+ tables: Map<string, Map<string, LiveColumn>>
102
+ indexes: LiveIndex[]
103
+ foreignKeys: LiveForeignKey[]
104
+ enums: Map<string, string[]>
105
+ names: Set<string>
106
+ populated?: Set<string>
107
+ }
108
+ export declare interface AlignAction {
109
+ kind: AlignActionKind
110
+ table: string
111
+ name: string
112
+ detail: string
113
+ sql: string
114
+ check?: { kind: 'duplicates', sql: string } | { kind: 'orphans', sql: string }
115
+ }
116
+ export declare interface AlignReport {
117
+ kind: AlignReportKind
118
+ table: string
119
+ name?: string
120
+ detail: string
121
+ }
122
+ export declare interface AlignmentPlan {
123
+ actions: AlignAction[]
124
+ reports: AlignReport[]
125
+ refused: Array<AlignAction & { reason: string }>
126
+ }
127
+ export declare interface AlignResult {
128
+ supported: boolean
129
+ dialect: string
130
+ plan: AlignmentPlan
131
+ applied: string[]
132
+ }
133
+ /** Runs one SQL string and returns its rows (see `migration-ledger.ts`). */
134
+ export type AlignSqlRunner = (sql: string) => Promise<any[]>;
135
+ export type AlignActionKind = 'add-enum-value' | 'add-column' | 'widen-column' | 'rename-index' | 'create-index' | 'add-foreign-key';
136
+ export type AlignReportKind = | 'missing-table'
137
+ | 'missing-column'
138
+ | 'extra-column'
139
+ | 'type'
140
+ | 'optional-widening'
141
+ | 'nullability'
142
+ | 'default'
143
+ | 'index-definition'
144
+ | 'duplicate-index'
145
+ | 'name-conflict'
146
+ | 'enum'
147
+ | 'foreign-key';
@@ -0,0 +1,24 @@
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(`
2
+ SELECT t.relname AS table_name, i.relname AS index_name, ix.indisunique AS is_unique, ix.indisprimary AS is_primary,
3
+ pg_get_indexdef(ix.indexrelid) AS definition, con.conname AS constraint_name
4
+ FROM pg_index ix
5
+ JOIN pg_class i ON i.oid = ix.indexrelid
6
+ JOIN pg_class t ON t.oid = ix.indrelid
7
+ JOIN pg_namespace n ON n.oid = t.relnamespace
8
+ LEFT JOIN pg_constraint con ON con.conindid = ix.indexrelid AND con.conrelid = t.oid
9
+ WHERE n.nspname = current_schema()`))){const definition=text(row,"definition"),key=comparableIndexKey(postgresIndexStatement(definition));indexes.push({name:text(row,"index_name").toLowerCase(),table:text(row,"table_name").toLowerCase(),unique:row.is_unique===!0||row.is_unique==="t",primary:row.is_primary===!0||row.is_primary==="t",...key?{key}:{},definition,...row.constraint_name?{constraint:String(row.constraint_name)}:{}})}const foreignKeys=[];for(const row of rows(await run(`
10
+ SELECT con.conname, cl.relname AS table_name, att.attname AS column_name, rcl.relname AS ref_table, ratt.attname AS ref_column,
11
+ con.confdeltype AS on_delete, con.confupdtype AS on_update
12
+ FROM pg_constraint con
13
+ JOIN pg_class cl ON cl.oid = con.conrelid
14
+ JOIN pg_namespace n ON n.oid = cl.relnamespace
15
+ JOIN pg_class rcl ON rcl.oid = con.confrelid
16
+ JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = con.conkey[1]
17
+ JOIN pg_attribute ratt ON ratt.attrelid = con.confrelid AND ratt.attnum = con.confkey[1]
18
+ WHERE con.contype = 'f' AND n.nspname = current_schema() AND array_length(con.conkey, 1) = 1`)))foreignKeys.push({name:text(row,"conname").toLowerCase(),table:text(row,"table_name").toLowerCase(),column:text(row,"column_name").toLowerCase(),refTable:text(row,"ref_table").toLowerCase(),refColumn:text(row,"ref_column").toLowerCase(),onDelete:PG_ACTION[text(row,"on_delete")]??"NO ACTION",onUpdate:PG_ACTION[text(row,"on_update")]??"NO ACTION"});const enums=new Map;for(const row of rows(await run(`
19
+ SELECT t.typname, e.enumlabel FROM pg_type t
20
+ JOIN pg_enum e ON e.enumtypid = t.oid
21
+ JOIN pg_namespace n ON n.oid = t.typnamespace
22
+ WHERE n.nspname = current_schema()
23
+ ORDER BY t.typname, e.enumsortorder`))){const type=text(row,"typname").toLowerCase();if(!enums.has(type))enums.set(type,[]);enums.get(type).push(text(row,"enumlabel"))}const names=new Set;for(const row of rows(await run("SELECT c.relname AS name FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = current_schema()")))names.add(text(row,"name").toLowerCase());for(const row of rows(await run("SELECT con.conname AS name FROM pg_constraint con JOIN pg_namespace n ON n.oid = con.connamespace WHERE n.nspname = current_schema()")))names.add(text(row,"name").toLowerCase());return{tables,indexes,foreignKeys,enums,names}}async function populatedTables(run,tables){const populated=new Set;for(const table of tables)if(rows(await run(`SELECT 1 AS present FROM ${quote(table)} LIMIT 1`)).length>0)populated.add(table);return populated}export async function alignSchema(options={}){const{ensureDatabaseConfigLoaded,resolvedDatabaseConfig}=await import("./utils");await ensureDatabaseConfigLoaded();const dialect=options.dialect??resolvedDatabaseConfig().driver,empty={actions:[],reports:[],refused:[]};if(dialect!=="postgres")return{supported:!1,dialect,plan:empty,applied:[]};const{generateFullModelMigration}=await import("./migrations"),generated=await generateFullModelMigration({dialect});if(!generated.isOk)throw generated.error;const run=options.run??(async(sql)=>{const{db}=await import("./utils");return rows(await db.unsafe(sql).execute())}),desired=desiredSchemaFrom(generated.value.plan,generated.value.statements),live=await readPostgresAlignSchema(run),candidates=[...desired.tables.keys()].filter((table)=>live.tables.has(table));live.populated=await populatedTables(run,candidates);let typeSatisfies;try{const qb=await import("bun-query-builder");if(qb.familyOfDeclared&&qb.familyOfSqlType&&qb.satisfies){const{familyOfDeclared,familyOfSqlType,satisfies}=qb;typeSatisfies=(declared,sqlType)=>satisfies(familyOfDeclared(declared),familyOfSqlType(sqlType),"postgres")}}catch{}const plan=planSchemaAlignment(desired,live,{widenIntegers:options.widenIntegers,typeSatisfies}),kept=[];for(const action of plan.actions){if(!action.check){kept.push(action);continue}const count=Number(rows(await run(action.check.sql))[0]?.n??0);if(count>0){plan.refused.push({...action,reason:action.check.kind==="orphans"?`${count} row(s) in ${action.table} reference a missing parent - fix or delete them, then re-run`:`${count} duplicate value(s) in ${action.table} would violate it - de-duplicate them, then re-run`});continue}kept.push(action)}plan.actions=kept;if(!options.apply||kept.length===0)return{supported:!0,dialect,plan,applied:[]};const statements=kept.map((action)=>action.sql);await run(`${statements.join(`;
24
+ `)};`);return{supported:!0,dialect,plan,applied:statements}}
package/dist/utils.d.ts CHANGED
@@ -232,7 +232,7 @@ export declare interface InsertReceipt {
232
232
  affectedRows?: number
233
233
  changes?: number
234
234
  }
235
- export declare interface BaseFluentChain<TRow = Record<string, unknown>, TKind extends ChainKind = 'select'> {
235
+ export declare interface BaseFluentChain<TRow = Record<string, unknown>, TKind extends ChainKind = 'select', TWrite = object> {
236
236
  where(callback: (eb: import('./types').StacksExpressionBuilder) => unknown): FluentChain<TRow, TKind>
237
237
  where(...args: unknown[]): FluentChain<TRow, TKind>
238
238
  whereNull: (...args: unknown[]) => FluentChain<TRow, TKind>
@@ -281,8 +281,8 @@ export declare interface BaseFluentChain<TRow = Record<string, unknown>, TKind e
281
281
  with: (...args: unknown[]) => FluentChain<Record<string, unknown>, TKind>
282
282
  union: (...args: unknown[]) => FluentChain<Record<string, unknown>, TKind>
283
283
  unionAll: (...args: unknown[]) => FluentChain<Record<string, unknown>, TKind>
284
- values: (values: Partial<TRow> | ReadonlyArray<Partial<TRow>> | object | readonly object[]) => FluentChain<TRow, TKind>
285
- set: (values: Partial<TRow> | object) => FluentChain<TRow, TKind>
284
+ values: (values: TWrite | ReadonlyArray<TWrite>) => FluentChain<TRow, TKind>
285
+ set: (values: TWrite) => FluentChain<TRow, TKind>
286
286
  returning<K extends KnownKeys<TRow> & string>(columns: readonly K[]): FluentChain<Pick<TRow, K>, 'returning'>
287
287
  returning(...args: unknown[]): FluentChain<Record<string, unknown>, 'returning'>
288
288
  returningAll: () => FluentChain<TRow, 'returning'>
@@ -416,8 +416,8 @@ export declare interface DatabaseSchema {}
416
416
  declare interface Db extends Pick<Required<RawQueryBuilder>, GenericPassthroughKeys> {
417
417
  fn: import('./types').ExpressionFunctions
418
418
  selectFrom: <T extends TableName>(table: T) => FluentChain<RowOf<T>, 'select'>
419
- insertInto: <T extends TableName>(table: T) => FluentChain<RowOf<T>, 'insert'>
420
- updateTable: <T extends TableName>(table: T) => FluentChain<RowOf<T>, 'update'>
419
+ insertInto: <T extends TableName>(table: T) => FluentChain<RowOf<T>, 'insert', WriteOf<T>>
420
+ updateTable: <T extends TableName>(table: T) => FluentChain<RowOf<T>, 'update', WriteOf<T>>
421
421
  deleteFrom: <T extends TableName>(table: T) => FluentChain<RowOf<T>, 'delete'>
422
422
  table: <T extends TableName>(table: T) => FluentChain<RowOf<T>, 'select'>
423
423
  selectFromSub: (sub: unknown, alias: string) => FluentChain<Record<string, unknown>>
@@ -464,6 +464,34 @@ export type UnsafeRowsResult = UnsafeRow[] | { rows?: UnsafeRow[] } | undefined;
464
464
  * key in it is fiction.
465
465
  */
466
466
  export type KnownKeys<T> = string extends keyof T ? never : keyof T;
467
+ /**
468
+ * A value a write may put in a column besides its own type: SQL NULL, a
469
+ * `Date` (bound as a timestamp), or a raw SQL fragment (`sql.raw(...)`,
470
+ * `db.fn.now()`). A numeric column also takes a boolean: generated schemas type
471
+ * a boolean column as `number` where the dialect stores it as 0/1, and every
472
+ * driver binds `true`/`false` correctly.
473
+ */
474
+ export type WriteValue = null | Date | { toSQL: unknown } | { readonly sql: unknown } | { raw: string }
475
+ /**
476
+ * The row a write to `T` takes.
477
+ *
478
+ * One of the application's own tables - in the `DatabaseSchema` that
479
+ * `buddy generate:db-types` writes from `app/Models` - takes its own columns,
480
+ * each optional, each of its own type or a {@link WriteValue}. A misspelt or
481
+ * removed column, or a string where the column is a number, then fails to
482
+ * compile instead of failing at runtime (or succeeding against a database
483
+ * whose column happens to be text).
484
+ *
485
+ * Any other table takes any object: tables outside every model, apps that
486
+ * have not generated types, and the framework's own tables, whose generated
487
+ * `FrameworkSchema` is not yet exact enough to hold writes to. `object` rather
488
+ * than `Record<string, unknown>`, because an *interface* has no index signature
489
+ * and is not assignable to the latter, and callers declaring their own row
490
+ * shape pass exactly that.
491
+ */
492
+ export type WriteOf<T extends TableName> = T extends keyof DatabaseSchema
493
+ ? { [K in keyof Shape<DatabaseSchema[T]>]?: Shape<DatabaseSchema[T]>[K] | WriteValue | (Shape<DatabaseSchema[T]>[K] extends number | null ? boolean : never) }
494
+ : object;
467
495
  /**
468
496
  * Which verb started a chain, so its terminals can answer the right thing.
469
497
  *
@@ -516,7 +544,7 @@ export type DynamicWhereMethods<TRow, TKind extends ChainKind = 'select'> = {
516
544
  * type over `TRow`, and an interface can only extend members TypeScript knows
517
545
  * statically.
518
546
  */
519
- export type FluentChain<TRow = Record<string, unknown>, TKind extends ChainKind = 'select'> = BaseFluentChain<TRow, TKind> & DynamicWhereMethods<TRow, TKind>;
547
+ export type FluentChain<TRow = Record<string, unknown>, TKind extends ChainKind = 'select', TWrite = object> = BaseFluentChain<TRow, TKind, TWrite> & DynamicWhereMethods<TRow, TKind>;
520
548
  /**
521
549
  * Top-level surface of the lazy `db` proxy. Methods that return a chainable
522
550
  * builder are typed as `FluentChain` to flatten the optional-method noise
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.141",
5
+ "version": "0.75.143",
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.141",
70
- "@stacksjs/env": "0.75.141",
71
- "@stacksjs/error-handling": "0.75.141",
72
- "@stacksjs/faker": "0.75.141",
73
- "@stacksjs/features": "0.75.141",
74
- "@stacksjs/logging": "0.75.141",
75
- "@stacksjs/model-meta": "0.75.141",
76
- "@stacksjs/path": "0.75.141",
77
- "@stacksjs/query-builder": "0.75.141",
78
- "@stacksjs/security": "0.75.141",
79
- "@stacksjs/storage": "0.75.141",
80
- "@stacksjs/strings": "0.75.141",
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",
81
81
  "@stacksjs/ts-validation": "^0.5.6",
82
- "bun-query-builder": "^0.3.6",
82
+ "bun-query-builder": "^0.3.8",
83
83
  "dynamodb-tooling": "^0.3.6"
84
84
  },
85
85
  "devDependencies": {
86
- "@stacksjs/cli": "0.75.141",
87
- "@stacksjs/router": "0.75.141",
88
- "@stacksjs/utils": "0.75.141",
86
+ "@stacksjs/cli": "0.75.143",
87
+ "@stacksjs/router": "0.75.143",
88
+ "@stacksjs/utils": "0.75.143",
89
89
  "better-dx": "^0.2.24"
90
90
  }
91
91
  }