@stacksjs/database 0.74.29 → 0.74.31

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.
@@ -42,6 +42,39 @@ export declare function usersTwoFactorColumnsSql(sql: SqlHelpers): string[];
42
42
  * stacksjs/status#1 Phase 9).
43
43
  */
44
44
  export declare function usersStripeIdSql(): string;
45
+ /**
46
+ * The polymorphic owner columns on `oauth_access_tokens`.
47
+ *
48
+ * A token used to belong to a user and only a user: the table's owner column
49
+ * was `user_id NOT NULL`, so `Author` - which declares `useAuth` exactly as
50
+ * `User` does - could not hold one. Sanctum's shape is a `tokenable_type` /
51
+ * `tokenable_id` pair, and that is what every read and write now uses.
52
+ *
53
+ * `tokenable_type` holds the owner's TABLE name (`users`, `authors`), matching
54
+ * what the framework's other polymorphic traits already write
55
+ * (`taggable_type: tableName`) rather than introducing a second convention.
56
+ *
57
+ * Nullable here and backfilled by {@link oauthAccessTokenTokenableBackfillSql},
58
+ * because an ADD COLUMN cannot be NOT NULL on a table that already has rows.
59
+ * A fresh install gets them NOT NULL from the CREATE.
60
+ *
61
+ * Same pure-builder + try/catch-swallow pattern as the device columns above:
62
+ * no generated migration creates this table, so both schema paths have to be
63
+ * able to add to it idempotently.
64
+ */
65
+ export declare function oauthAccessTokenTokenableColumnsSql(): string[];
66
+ /**
67
+ * Give every pre-existing token an owner in the new columns.
68
+ *
69
+ * Every row that predates the pair belonged to a user by construction - there
70
+ * was no other option - so `user_id` is exactly the tokenable id, and the type
71
+ * is the users table. Guarded on `tokenable_id IS NULL` so it is safe to run on
72
+ * every boot, and so it never overwrites a row a newer write already owns.
73
+ *
74
+ * Runs before the reads switch over: a token whose owner columns were empty
75
+ * would otherwise stop authenticating the moment this ships.
76
+ */
77
+ export declare function oauthAccessTokenTokenableBackfillSql(usersTable?: string): string[];
45
78
  /**
46
79
  * Defensive ALTERs guaranteeing the device columns on
47
80
  * `oauth_access_tokens` - `user_agent` and `ip_address`.
@@ -1,4 +1,4 @@
1
- import{randomBytes}from"node:crypto";import{log}from"@stacksjs/logging";import{env as envVars}from"@stacksjs/env";import{db}from"./utils";import{sqlHelpers}from"./sql-helpers";import{indexSqlForDialect}from"./dialect";function getDbDriver(){return envVars.DB_CONNECTION||"sqlite"}export function usersEmailVerifiedAtSql(sql){return`ALTER TABLE users ADD COLUMN email_verified_at ${sql.nullableTimestamp}`}export function usersPasswordChangedAtSql(sql){return`ALTER TABLE users ADD COLUMN password_changed_at ${sql.nullableTimestamp}`}export function usersTwoFactorColumnsSql(sql){return["ALTER TABLE users ADD COLUMN two_factor_secret VARCHAR(255)",`ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN NOT NULL DEFAULT ${sql.boolFalse}`,"ALTER TABLE users ADD COLUMN two_factor_last_used_step BIGINT"]}export function usersStripeIdSql(){return"ALTER TABLE users ADD COLUMN stripe_id VARCHAR(255)"}export function oauthAccessTokenDeviceColumnsSql(){return["ALTER TABLE oauth_access_tokens ADD COLUMN user_agent VARCHAR(255)","ALTER TABLE oauth_access_tokens ADD COLUMN ip_address VARCHAR(45)"]}export function passwordResetsExpiresAtSql(){return["ALTER TABLE password_resets ADD COLUMN expires_at TIMESTAMP"]}export async function ensureUsersAuthColumns(sql,options={}){const alters=[usersEmailVerifiedAtSql(sql),usersPasswordChangedAtSql(sql),...usersTwoFactorColumnsSql(sql),usersStripeIdSql()];for(const alterSql of alters)try{await db.unsafe(alterSql).execute()}catch{if(options.verbose)log.debug(`[auth-tables] Skipped (already applied or users missing): ${alterSql}`)}try{await db.unsafe(indexSqlForDialect("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_stripe_id ON users(stripe_id)",getDbDriver())).execute()}catch{if(options.verbose)log.debug("[auth-tables] Skipped users.stripe_id unique index (already applied or users missing)")}}export async function migrateAuthTables(options={}){const dbDriver=getDbDriver(),sql=sqlHelpers(dbDriver),{isPostgres,boolTrue,now,pkColumn,nullableTimestamp,datetime,utcNow}=sql;if(options.verbose)log.info(`Creating auth tables for ${dbDriver}...`);try{if(options.verbose)log.info("Creating oauth_clients table...");await db.unsafe(`
1
+ import{randomBytes}from"node:crypto";import{log}from"@stacksjs/logging";import{env as envVars}from"@stacksjs/env";import{db}from"./utils";import{sqlHelpers}from"./sql-helpers";import{indexSqlForDialect}from"./dialect";function getDbDriver(){return envVars.DB_CONNECTION||"sqlite"}export function usersEmailVerifiedAtSql(sql){return`ALTER TABLE users ADD COLUMN email_verified_at ${sql.nullableTimestamp}`}export function usersPasswordChangedAtSql(sql){return`ALTER TABLE users ADD COLUMN password_changed_at ${sql.nullableTimestamp}`}export function usersTwoFactorColumnsSql(sql){return["ALTER TABLE users ADD COLUMN two_factor_secret VARCHAR(255)",`ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN NOT NULL DEFAULT ${sql.boolFalse}`,"ALTER TABLE users ADD COLUMN two_factor_last_used_step BIGINT"]}export function usersStripeIdSql(){return"ALTER TABLE users ADD COLUMN stripe_id VARCHAR(255)"}export function oauthAccessTokenTokenableColumnsSql(){return["ALTER TABLE oauth_access_tokens ADD COLUMN tokenable_type VARCHAR(255)","ALTER TABLE oauth_access_tokens ADD COLUMN tokenable_id INTEGER"]}export function oauthAccessTokenTokenableBackfillSql(usersTable="users"){return[`UPDATE oauth_access_tokens SET tokenable_type = '${usersTable}', tokenable_id = user_id WHERE tokenable_id IS NULL`]}export function oauthAccessTokenDeviceColumnsSql(){return["ALTER TABLE oauth_access_tokens ADD COLUMN user_agent VARCHAR(255)","ALTER TABLE oauth_access_tokens ADD COLUMN ip_address VARCHAR(45)"]}export function passwordResetsExpiresAtSql(){return["ALTER TABLE password_resets ADD COLUMN expires_at TIMESTAMP"]}export async function ensureUsersAuthColumns(sql,options={}){const alters=[usersEmailVerifiedAtSql(sql),usersPasswordChangedAtSql(sql),...usersTwoFactorColumnsSql(sql),usersStripeIdSql()];for(const alterSql of alters)try{await db.unsafe(alterSql).execute()}catch{if(options.verbose)log.debug(`[auth-tables] Skipped (already applied or users missing): ${alterSql}`)}try{await db.unsafe(indexSqlForDialect("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_stripe_id ON users(stripe_id)",getDbDriver())).execute()}catch{if(options.verbose)log.debug("[auth-tables] Skipped users.stripe_id unique index (already applied or users missing)")}}export async function migrateAuthTables(options={}){const dbDriver=getDbDriver(),sql=sqlHelpers(dbDriver),{isPostgres,boolTrue,now,pkColumn,nullableTimestamp,datetime,utcNow}=sql;if(options.verbose)log.info(`Creating auth tables for ${dbDriver}...`);try{if(options.verbose)log.info("Creating oauth_clients table...");await db.unsafe(`
2
2
  CREATE TABLE IF NOT EXISTS oauth_clients (
3
3
  ${pkColumn},
4
4
  name VARCHAR(255) NOT NULL,
@@ -14,7 +14,17 @@ import{randomBytes}from"node:crypto";import{log}from"@stacksjs/logging";import{e
14
14
  `).execute();if(options.verbose)log.info("Creating oauth_access_tokens table...");await db.unsafe(`
15
15
  CREATE TABLE IF NOT EXISTS oauth_access_tokens (
16
16
  ${pkColumn},
17
- user_id INTEGER NOT NULL,
17
+ -- The owner, polymorphically: users, authors, any table whose model
18
+ -- declares useAuth. Before this pair, the owner was user_id and a token
19
+ -- could belong to nothing else.
20
+ tokenable_type VARCHAR(255) NOT NULL,
21
+ tokenable_id INTEGER NOT NULL,
22
+ -- Legacy, and deliberately still here. An install that predates the
23
+ -- pair has this column NOT NULL, so an INSERT that omitted it would
24
+ -- fail there - and one INSERT that works on every install is worth more
25
+ -- than a column removed early. It is written as a copy of tokenable_id
26
+ -- and read by nothing; it can go once no install predates the pair.
27
+ user_id INTEGER,
18
28
  oauth_client_id INTEGER NOT NULL,
19
29
  token TEXT NOT NULL,
20
30
  name VARCHAR(255),
@@ -29,7 +39,7 @@ import{randomBytes}from"node:crypto";import{log}from"@stacksjs/logging";import{e
29
39
  created_at ${datetime} DEFAULT ${utcNow},
30
40
  updated_at ${nullableTimestamp}
31
41
  )
32
- `).execute();await createTokenIndex("idx_oauth_access_tokens_token","oauth_access_tokens","token");for(const alterSql of oauthAccessTokenDeviceColumnsSql())try{await db.unsafe(alterSql).execute()}catch{if(options.verbose)log.debug(`[auth-tables] Skipped (already applied): ${alterSql}`)}if(options.verbose)log.info("Creating oauth_refresh_tokens table...");await db.unsafe(`
42
+ `).execute();await createTokenIndex("idx_oauth_access_tokens_token","oauth_access_tokens","token");for(const alterSql of oauthAccessTokenDeviceColumnsSql())try{await db.unsafe(alterSql).execute()}catch{if(options.verbose)log.debug(`[auth-tables] Skipped (already applied): ${alterSql}`)}for(const alterSql of oauthAccessTokenTokenableColumnsSql())try{await db.unsafe(alterSql).execute()}catch{if(options.verbose)log.debug(`[auth-tables] Skipped (already applied): ${alterSql}`)}for(const backfillSql of oauthAccessTokenTokenableBackfillSql())try{await db.unsafe(backfillSql).execute()}catch(err){if(options.verbose)log.debug(`[auth-tables] Tokenable backfill skipped: ${err?.message}`)}if(options.verbose)log.info("Creating oauth_refresh_tokens table...");await db.unsafe(`
33
43
  CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
34
44
  ${pkColumn},
35
45
  access_token_id INTEGER NOT NULL,
@@ -132,7 +132,7 @@ export declare function sqlStatementsOf(content: string): string[];
132
132
  */
133
133
  export declare function orderPostgresColumnTypeChanges(sql: string): string;
134
134
  export declare function guardPostgresEnumTypes(sql: string): string;
135
- export declare function preprocessSqliteMigrations(): void;
135
+ export declare function preprocessSqliteMigrations(): Array<{ original: string, hidden: string, feature: string }>;
136
136
  /**
137
137
  * Public bootstrap entry point.
138
138
  *
@@ -1,21 +1,21 @@
1
- var {require}=import.meta;import{existsSync,mkdirSync,readdirSync,readFileSync,renameSync,unlinkSync,writeFileSync}from"node:fs";import{isPackageMigration,stagePackageMigrations}from"./package-migrations";import{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,generateMigration as qbGenerateMigration,resetDatabase as qbResetDatabase,config as qbConfig,saveMigrationSnapshot,setConfig}from"@stacksjs/query-builder";import{db,qbSnapshotDir,resetDatabaseConnection}from"./utils";import{classifyConnectionError,createDatabase,describeTarget,manualCreateHint,probeTargetDatabase,resolveConnectionTarget}from"./ensure-database";import{resolveModelSources}from"./model-sources";import{findShadowedColumnDrops,shadowDropsAllowed,shadowedDropMessage}from"./shadowed-models";import{frameworkManagedColumns,withoutManagedColumnDrops,withoutManagedColumnDropSql}from"./managed-columns";import{acquireMigrationLock}from"./migration-lock";import{relativeMigrationDirectory,resolveMigrationDirectory}from"./migration-path";import{ensureNotificationForeignKeys,migrateNotificationTables,notificationTablesMissingCreateStatements}from"./notification-tables";import{traitTableNames}from"./trait-tables";import{env as envVars}from"@stacksjs/env";import{getConnectionDefaults}from"./defaults";import{isVitessSharded}from"./dialect";const databaseEnv={DB_CONNECTION:process.env.DB_CONNECTION||envVars.DB_CONNECTION,DB_DATABASE_PATH:process.env.DB_DATABASE_PATH||envVars.DB_DATABASE_PATH,DB_DATABASE:process.env.DB_DATABASE||envVars.DB_DATABASE,DB_HOST:process.env.DB_HOST||envVars.DB_HOST,DB_PORT:process.env.DB_PORT?Number(process.env.DB_PORT):envVars.DB_PORT,DB_USERNAME:process.env.DB_USERNAME||envVars.DB_USERNAME,DB_PASSWORD:process.env.DB_PASSWORD||envVars.DB_PASSWORD,DB_VITESS_SHARDED:process.env.DB_VITESS_SHARDED||envVars.DB_VITESS_SHARDED},dbDriver=databaseEnv.DB_CONNECTION||"sqlite",sqliteDefaults=getConnectionDefaults("sqlite",databaseEnv),mysqlDefaults=getConnectionDefaults("mysql",databaseEnv),singlestoreDefaults=getConnectionDefaults("singlestore",databaseEnv),vitessDefaults=getConnectionDefaults("vitess",databaseEnv),postgresDefaults=getConnectionDefaults("postgres",databaseEnv),dbConfig={default:dbDriver,connections:{sqlite:{database:sqliteDefaults.database,prefix:""},mysql:{name:mysqlDefaults.database,host:mysqlDefaults.host,username:mysqlDefaults.username,password:mysqlDefaults.password,port:mysqlDefaults.port,prefix:""},singlestore:{name:singlestoreDefaults.database,host:singlestoreDefaults.host,username:singlestoreDefaults.username,password:singlestoreDefaults.password,port:singlestoreDefaults.port,prefix:""},vitess:{name:vitessDefaults.database,host:vitessDefaults.host,username:vitessDefaults.username,password:vitessDefaults.password,port:vitessDefaults.port,prefix:"",sharded:isVitessSharded(databaseEnv.DB_VITESS_SHARDED)},postgres:{name:postgresDefaults.database,host:postgresDefaults.host,username:postgresDefaults.username,password:postgresDefaults.password,port:postgresDefaults.port,prefix:""}}};function sqliteDatabasePath(){const configured=dbConfig.connections.sqlite.database||"stacks.db";return isAbsolute(configured)?configured:join(process.cwd(),configured)}function getDriver(){return dbConfig.default||"sqlite"}function getDialect(){const driver=getDriver();if(driver==="sqlite"||driver==="mysql"||driver==="vitess"||driver==="postgres")return driver;if(driver==="singlestore")return"mysql";if(driver==="dynamodb")throw Error("[database] DB_CONNECTION=dynamodb is not compatible with the SQL migration runner. DynamoDB has no schema-migration concept - use the entity-style `dynamo.entity(...)` API from @stacksjs/database directly. To run SQL migrations, set DB_CONNECTION to one of: sqlite, mysql, singlestore, vitess, postgres.");throw Error(`[database] Unknown DB_CONNECTION "${driver}". Allowed values: sqlite, mysql, singlestore, vitess, postgres, dynamodb.`)}function getQbDialect(){return getDriver()==="singlestore"?"singlestore":getDialect()}function migrationDirectory(dialect=getQbDialect()){return resolveMigrationDirectory(dialect,{configured:qbConfig.migrationDir,snapshotDir:qbSnapshotDir()})}function configureQueryBuilder(targetDialect=getQbDialect(),vitessSharded){const connectionConfig=dbConfig.connections[targetDialect];setConfig({dialect:targetDialect,vitess:{sharded:isVitessSharded(vitessSharded??connectionConfig?.sharded)},verbose:!1,snapshotDir:qbSnapshotDir(),migrationDir:relativeMigrationDirectory(migrationDirectory(targetDialect)),database:{database:connectionConfig?.name||connectionConfig?.database||"stacks",host:connectionConfig?.host||"localhost",port:connectionConfig?.port||(targetDialect==="postgres"?5432:targetDialect==="vitess"?15306:targetDialect==="mysql"||targetDialect==="singlestore"?3306:0),username:connectionConfig?.username||"",password:connectionConfig?.password||""}});resetDatabaseConnection()}export function prepareMigrationModelsDir(){const sources=resolveModelSources(),excludedTables=sources?.excludedTables??[];return{modelsDir:sources?.dir??path.userModelsPath(),skip:!sources,excludedTables,protectedTables:[...new Set([...excludedTables,...traitTableNames()])]}}const DROP_TABLE_RE=/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i;export function withoutProtectedTableDropSql(statements,protectedTables,operations){if(protectedTables.length===0)return{statements,removed:[]};const excluded=new Set(protectedTables.map((table)=>table.toLowerCase())),normalize=(sql)=>sql.replace(/\s+/g," ").trim().replace(/;$/,""),dropped=new Set(operations.filter((op)=>op.kind==="drop_table"&&excluded.has(op.table.toLowerCase())).filter((op)=>Boolean(op.sql)).map((op)=>normalize(op.sql))),removed=[];return{statements:statements.filter((statement)=>{if(dropped.has(normalize(statement))){removed.push(statement);return!1}const match=statement.match(DROP_TABLE_RE);if(match?.[1]&&excluded.has(match[1].toLowerCase())){removed.push(statement);return!1}return!0}),removed}}export function sqlStatementsOf(content){const statements=[];let current="",quote=null,dollarTag=null;for(let i=0;i<content.length;i++){const char=content[i];if(dollarTag){current+=char;if(char==="$"&&content.startsWith(dollarTag,i)){current+=content.slice(i+1,i+dollarTag.length);i+=dollarTag.length-1;dollarTag=null}continue}if(quote){current+=char;if(quote==="single"&&char==="'"||quote==="double"&&char==='"')quote=null;continue}if(char==="-"&&content[i+1]==="-"){const newline=content.indexOf(`
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,generateMigration as qbGenerateMigration,resetDatabase as qbResetDatabase,config as qbConfig,saveMigrationSnapshot,setConfig}from"@stacksjs/query-builder";import{db,qbSnapshotDir,resetDatabaseConnection}from"./utils";import{classifyConnectionError,createDatabase,describeTarget,manualCreateHint,probeTargetDatabase,resolveConnectionTarget}from"./ensure-database";import{resolveModelSources}from"./model-sources";import{findShadowedColumnDrops,shadowDropsAllowed,shadowedDropMessage}from"./shadowed-models";import{frameworkManagedColumns,withoutManagedColumnDrops,withoutManagedColumnDropSql}from"./managed-columns";import{acquireMigrationLock}from"./migration-lock";import{relativeMigrationDirectory,resolveMigrationDirectory}from"./migration-path";import{ensureNotificationForeignKeys,migrateNotificationTables,notificationTablesMissingCreateStatements}from"./notification-tables";import{traitTableNames}from"./trait-tables";import{env as envVars}from"@stacksjs/env";import{getConnectionDefaults}from"./defaults";import{isVitessSharded}from"./dialect";const databaseEnv={DB_CONNECTION:process.env.DB_CONNECTION||envVars.DB_CONNECTION,DB_DATABASE_PATH:process.env.DB_DATABASE_PATH||envVars.DB_DATABASE_PATH,DB_DATABASE:process.env.DB_DATABASE||envVars.DB_DATABASE,DB_HOST:process.env.DB_HOST||envVars.DB_HOST,DB_PORT:process.env.DB_PORT?Number(process.env.DB_PORT):envVars.DB_PORT,DB_USERNAME:process.env.DB_USERNAME||envVars.DB_USERNAME,DB_PASSWORD:process.env.DB_PASSWORD||envVars.DB_PASSWORD,DB_VITESS_SHARDED:process.env.DB_VITESS_SHARDED||envVars.DB_VITESS_SHARDED},dbDriver=databaseEnv.DB_CONNECTION||"sqlite",sqliteDefaults=getConnectionDefaults("sqlite",databaseEnv),mysqlDefaults=getConnectionDefaults("mysql",databaseEnv),singlestoreDefaults=getConnectionDefaults("singlestore",databaseEnv),vitessDefaults=getConnectionDefaults("vitess",databaseEnv),postgresDefaults=getConnectionDefaults("postgres",databaseEnv),dbConfig={default:dbDriver,connections:{sqlite:{database:sqliteDefaults.database,prefix:""},mysql:{name:mysqlDefaults.database,host:mysqlDefaults.host,username:mysqlDefaults.username,password:mysqlDefaults.password,port:mysqlDefaults.port,prefix:""},singlestore:{name:singlestoreDefaults.database,host:singlestoreDefaults.host,username:singlestoreDefaults.username,password:singlestoreDefaults.password,port:singlestoreDefaults.port,prefix:""},vitess:{name:vitessDefaults.database,host:vitessDefaults.host,username:vitessDefaults.username,password:vitessDefaults.password,port:vitessDefaults.port,prefix:"",sharded:isVitessSharded(databaseEnv.DB_VITESS_SHARDED)},postgres:{name:postgresDefaults.database,host:postgresDefaults.host,username:postgresDefaults.username,password:postgresDefaults.password,port:postgresDefaults.port,prefix:""}}};function sqliteDatabasePath(){const configured=dbConfig.connections.sqlite.database||"stacks.db";return isAbsolute(configured)?configured:join(process.cwd(),configured)}function getDriver(){return dbConfig.default||"sqlite"}function getDialect(){const driver=getDriver();if(driver==="sqlite"||driver==="mysql"||driver==="vitess"||driver==="postgres")return driver;if(driver==="singlestore")return"mysql";if(driver==="dynamodb")throw Error("[database] DB_CONNECTION=dynamodb is not compatible with the SQL migration runner. DynamoDB has no schema-migration concept - use the entity-style `dynamo.entity(...)` API from @stacksjs/database directly. To run SQL migrations, set DB_CONNECTION to one of: sqlite, mysql, singlestore, vitess, postgres.");throw Error(`[database] Unknown DB_CONNECTION "${driver}". Allowed values: sqlite, mysql, singlestore, vitess, postgres, dynamodb.`)}function getQbDialect(){return getDriver()==="singlestore"?"singlestore":getDialect()}function migrationDirectory(dialect=getQbDialect()){return resolveMigrationDirectory(dialect,{configured:qbConfig.migrationDir,snapshotDir:qbSnapshotDir()})}function configureQueryBuilder(targetDialect=getQbDialect(),vitessSharded){const connectionConfig=dbConfig.connections[targetDialect];setConfig({dialect:targetDialect,vitess:{sharded:isVitessSharded(vitessSharded??connectionConfig?.sharded)},verbose:!1,snapshotDir:qbSnapshotDir(),migrationDir:relativeMigrationDirectory(migrationDirectory(targetDialect)),database:{database:connectionConfig?.name||connectionConfig?.database||"stacks",host:connectionConfig?.host||"localhost",port:connectionConfig?.port||(targetDialect==="postgres"?5432:targetDialect==="vitess"?15306:targetDialect==="mysql"||targetDialect==="singlestore"?3306:0),username:connectionConfig?.username||"",password:connectionConfig?.password||""}});resetDatabaseConnection()}export function prepareMigrationModelsDir(){const sources=resolveModelSources(),excludedTables=sources?.excludedTables??[];return{modelsDir:sources?.dir??path.userModelsPath(),skip:!sources,excludedTables,protectedTables:[...new Set([...excludedTables,...traitTableNames()])]}}const DROP_TABLE_RE=/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i;export function withoutProtectedTableDropSql(statements,protectedTables,operations){if(protectedTables.length===0)return{statements,removed:[]};const excluded=new Set(protectedTables.map((table)=>table.toLowerCase())),normalize=(sql)=>sql.replace(/\s+/g," ").trim().replace(/;$/,""),dropped=new Set(operations.filter((op)=>op.kind==="drop_table"&&excluded.has(op.table.toLowerCase())).filter((op)=>Boolean(op.sql)).map((op)=>normalize(op.sql))),removed=[];return{statements:statements.filter((statement)=>{if(dropped.has(normalize(statement))){removed.push(statement);return!1}const match=statement.match(DROP_TABLE_RE);if(match?.[1]&&excluded.has(match[1].toLowerCase())){removed.push(statement);return!1}return!0}),removed}}export function sqlStatementsOf(content){const statements=[];let current="",quote=null,dollarTag=null;for(let i=0;i<content.length;i++){const char=content[i];if(dollarTag){current+=char;if(char==="$"&&content.startsWith(dollarTag,i)){current+=content.slice(i+1,i+dollarTag.length);i+=dollarTag.length-1;dollarTag=null}continue}if(quote){current+=char;if(quote==="single"&&char==="'"||quote==="double"&&char==='"')quote=null;continue}if(char==="-"&&content[i+1]==="-"){const newline=content.indexOf(`
2
2
  `,i);if(newline===-1)break;i=newline-1;continue}const dollar=char==="$"?/^\$[A-Za-z_]*\$/.exec(content.slice(i)):null;if(dollar){dollarTag=dollar[0];current+=dollarTag;i+=dollarTag.length-1;continue}if(char==="'"){quote="single";current+=char;continue}if(char==='"'){quote="double";current+=char;continue}if(char===";"){const trimmed=current.trim();if(trimmed.length>0)statements.push(trimmed);current="";continue}current+=char}const trailing=current.trim();if(trailing.length>0)statements.push(trailing);return statements}export function orderPostgresColumnTypeChanges(sql){const lines=sql.split(`
3
3
  `),output=[];for(const line of lines){const match=/^(\s*)ALTER\s+TABLE\s+("?[\w.]+"?)\s+ALTER\s+COLUMN\s+("?[\w]+"?)\s+TYPE\s/i.exec(line);if(match){const[,indent,table,column]=match,drop=`${indent}ALTER TABLE ${table} ALTER COLUMN ${column} DROP DEFAULT;`;if((output.length>0?output[output.length-1].trim():"")!==drop.trim())output.push(drop)}output.push(line)}return output.join(`
4
4
  `)}export function guardPostgresEnumTypes(sql){return assertPostgresEnumMembers(wrapPostgresEnumTypes(sql))}function wrapPostgresEnumTypes(sql){return sql.replace(/CREATE\s+TYPE\s+("?[\w.]+"?)\s+AS\s+ENUM\s*\(([^)]*)\)/gi,(match,name,members,offset,whole)=>{if(/\bBEGIN\s*$/i.test(whole.slice(Math.max(0,offset-40),offset)))return match;return`DO $stacks$ BEGIN CREATE TYPE ${name} AS ENUM (${members}); EXCEPTION WHEN duplicate_object THEN null; END $stacks$`})}function assertPostgresEnumMembers(sql){return sql.replace(/DO \$stacks\$[\s\S]*?END \$stacks\$;?/g,(block)=>{const created=/CREATE\s+TYPE\s+("?[\w.]+"?)\s+AS\s+ENUM\s*\(([^)]*)\)/i.exec(block);if(!created)return block;const[,name,members]=created,missing=enumMembers(members).map((member)=>`ALTER TYPE ${name} ADD VALUE IF NOT EXISTS ${member};`).filter((statement)=>!sql.includes(statement));if(missing.length===0)return block;return`${block.endsWith(";")?block:`${block};`}
5
5
  ${missing.join(`
6
- `)}`})}function enumMembers(list){return[...list.matchAll(/'(?:[^']|'')*'/g)].map((match)=>match[0])}export function preprocessSqliteMigrations(){const migrationsDir=migrationDirectory("sqlite");let files;try{files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql"))}catch{return}const droppedMigrations=[],skipMigration=(file,reason)=>{log.info(`Skipping migration on SQLite (${reason}): ${file}`);droppedMigrations.push(file)},deleteMigration=(file,filePath,reason)=>{log.info(`Dropping no-op migration (${reason}): ${file}`);try{unlinkSync(filePath)}catch{}droppedMigrations.push(file)},replayMigrations=[],addConstraintPattern=/^\s*ALTER\s+TABLE\s+.+\s+ADD\s+CONSTRAINT\s+/i,createTypePattern=/^\s*CREATE\s+TYPE\s+/i,createUniqueIndexPattern=/^\s*CREATE\s+UNIQUE\s+INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i,dropColumnPattern=/^\s*ALTER\s+TABLE\s+["']?(\w+)["']?\s+DROP\s+COLUMN\s+["']?(\w+)["']?\s*$/i,addColumnPattern=/^\s*ALTER\s+TABLE\s+["']?(\w+)["']?\s+ADD\s+COLUMN\s+["'`]?(\w+)["'`]?/i,createTablePattern=/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(\w+)["']?/i,createTableEarliest=new Map;for(const file of files){const m=file.match(/^\d+-create-(\w+)-table\.sql$/);if(!m||!m[1])continue;const tableName=m[1],existing=createTableEarliest.get(tableName);if(!existing||file<existing)createTableEarliest.set(tableName,file)}const earlierCreateDefinesColumn=(migrationFile,table,column)=>{const createFile=createTableEarliest.get(table);if(!createFile||createFile>=migrationFile)return!1;try{const createContent=readFileSync(join(migrationsDir,createFile),"utf8"),createStatement=sqlStatementsOf(createContent).find((statement)=>statement.match(createTablePattern)?.[1]===table);if(!createStatement)return!1;const escapedColumn=column.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return new RegExp(`(?:^|[,(])\\s*["'\`]?${escapedColumn}["'\`]?\\s+`,"i").test(createStatement)}catch{return!1}},sqliteDbPath=sqliteDatabasePath();let sqliteDb=null;if(existsSync(sqliteDbPath))try{const{Database}=require("bun:sqlite");sqliteDb=new Database(sqliteDbPath,{readonly:!0})}catch{}const migrationWasRecorded=(file)=>{if(!sqliteDb)return!1;try{return Boolean(sqliteDb.prepare("SELECT 1 FROM migrations WHERE migration = ? LIMIT 1").get(file))}catch{return!1}};for(const file of files){log.debug(`[migration] Running: ${file}`);const filePath=join(migrationsDir,file),content=readFileSync(filePath,"utf-8"),statements=sqlStatementsOf(content);if(statements.length===0)continue;const uniqueIndexNames=statements.map((s)=>s.match(createUniqueIndexPattern)?.[1]).filter((name)=>Boolean(name));if(sqliteDb&&uniqueIndexNames.length===statements.length){const indexExists=sqliteDb.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?");if(uniqueIndexNames.filter((name)=>!indexExists.get(name)).length>0){log.info(`Re-queueing unique-index migration (index missing from database): ${file}`);replayMigrations.push(file)}continue}if(migrationWasRecorded(file))continue;const firstStatement=statements[0],createTableMatch=firstStatement?firstStatement.match(createTablePattern):null;if(createTableMatch&&createTableMatch[1]){const tableName=createTableMatch[1],earliest=createTableEarliest.get(tableName);if(earliest&&earliest!==file&&!isPackageMigration(file)){deleteMigration(file,filePath,`duplicate create-table for "${tableName}" (kept ${earliest})`);continue}}if(statements.every((s)=>addConstraintPattern.test(s))){skipMigration(file,"SQLite does not support ALTER TABLE ADD CONSTRAINT");continue}if(statements.every((s)=>createTypePattern.test(s))){skipMigration(file,"SQLite does not support CREATE TYPE (enum types)");continue}const addColumnTargets=statements.map((s)=>s.match(addColumnPattern)).filter((m)=>Boolean(m?.[1]&&m[2])).map((m)=>({table:m[1],column:m[2]}));if(addColumnTargets.length>0&&addColumnTargets.length===statements.length){const satisfied=addColumnTargets.filter(({table,column})=>{try{if(sqliteDb){const safeTableName=table.replace(/[^a-zA-Z0-9_]/g,""),columns=sqliteDb.prepare(`PRAGMA table_info("${safeTableName}")`).all();if(columns.some((col)=>col.name===column))return!0;if(columns.length>0)return!1}return earlierCreateDefinesColumn(file,table,column)}catch{return!1}});if(satisfied.length===addColumnTargets.length){skipMigration(file,"every column it adds already exists or is defined by an earlier create-table migration");continue}if(satisfied.length>0)log.warn(`[migration] ${file} is partially applied: ${satisfied.map((p)=>`${p.table}.${p.column}`).join(", ")} already exist${satisfied.length===1?"s":""} or will be created earlier, the rest do not. SQLite cannot skip a single ADD COLUMN, so this file will fail. Add the remaining columns by hand, or split the file so the applied statements sit in their own migration.`)}if(statements.some((s)=>dropColumnPattern.test(s))){let modified=!1;const filteredStatements=[];for(const stmt of statements){const dropColMatch=stmt.match(dropColumnPattern);if(dropColMatch&&dropColMatch[1]&&dropColMatch[2]){const tableName=dropColMatch[1],columnName=dropColMatch[2];if(!sqliteDb){if(earlierCreateDefinesColumn(file,tableName,columnName)){filteredStatements.push(stmt);continue}log.info(`Skipping DROP COLUMN "${columnName}" - no database exists yet: ${file}`);modified=!0;continue}try{const safeTableName=tableName.replace(/[^a-zA-Z0-9_]/g,""),columns=sqliteDb.prepare(`PRAGMA table_info("${safeTableName}")`).all();if(columns.length===0){if(earlierCreateDefinesColumn(file,tableName,columnName)){filteredStatements.push(stmt);continue}log.info(`Skipping DROP COLUMN "${columnName}" - table "${tableName}" does not exist yet: ${file}`);modified=!0;continue}if(!columns.some((col)=>col.name===columnName)){log.info(`Skipping DROP COLUMN "${columnName}" from "${tableName}" - column does not exist: ${file}`);modified=!0;continue}}catch{if(earlierCreateDefinesColumn(file,tableName,columnName)){filteredStatements.push(stmt);continue}log.info(`Skipping DROP COLUMN "${columnName}" - table "${tableName}" not found: ${file}`);modified=!0;continue}}filteredStatements.push(stmt)}if(modified){if(filteredStatements.length===0)deleteMigration(file,filePath,"columns already absent from table");else writeFileSync(filePath,`${filteredStatements.join(`;
6
+ `)}`})}function enumMembers(list){return[...list.matchAll(/'(?:[^']|'')*'/g)].map((match)=>match[0])}export function preprocessSqliteMigrations(){const migrationsDir=migrationDirectory("sqlite"),quarantined=[];try{for(const stale of readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql.unsupported"))){const original=join(migrationsDir,stale.slice(0,-12));try{if(!existsSync(original))renameSync(join(migrationsDir,stale),original);else unlinkSync(join(migrationsDir,stale))}catch{}}}catch{}let files;try{files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql"))}catch{return quarantined}const droppedMigrations=[],skipMigration=(file,reason)=>{log.info(`Skipping migration on SQLite (${reason}): ${file}`);droppedMigrations.push(file)},partitionUnsupported=(file,filePath,statements,pattern,feature)=>{const unsupported=statements.filter((s)=>pattern.test(s));if(unsupported.length===statements.length){skipMigration(file,`SQLite does not support ${feature}`);return"skipped"}const hiddenPath=`${filePath}.unsupported`;try{renameSync(filePath,hiddenPath)}catch(error){log.error(`[migration] Could not quarantine ${file}: ${error instanceof Error?error.message:String(error)}`);return"runnable"}quarantined.push({original:filePath,hidden:hiddenPath,feature});log.warn(`[migration] ${file} mixes ${unsupported.length} ${feature} statement(s) SQLite cannot run with ${statements.length-unsupported.length} it can, so none of it was applied. Split them into separate migrations, or run this app on MySQL/Postgres. The rest of the corpus was applied.`);return"quarantined"},deleteMigration=(file,filePath,reason)=>{log.info(`Dropping no-op migration (${reason}): ${file}`);try{unlinkSync(filePath)}catch{}droppedMigrations.push(file)},replayMigrations=[],addConstraintPattern=/^\s*ALTER\s+TABLE\s+.+\s+ADD\s+CONSTRAINT\s+/i,createTypePattern=/^\s*CREATE\s+TYPE\s+/i,createUniqueIndexPattern=/^\s*CREATE\s+UNIQUE\s+INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i,dropColumnPattern=/^\s*ALTER\s+TABLE\s+["']?(\w+)["']?\s+DROP\s+COLUMN\s+["']?(\w+)["']?\s*$/i,addColumnPattern=/^\s*ALTER\s+TABLE\s+["']?(\w+)["']?\s+ADD\s+COLUMN\s+["'`]?(\w+)["'`]?/i,createTablePattern=/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(\w+)["']?/i,createTableEarliest=new Map;for(const file of files){const m=file.match(/^\d+-create-(\w+)-table\.sql$/);if(!m||!m[1])continue;const tableName=m[1],existing=createTableEarliest.get(tableName);if(!existing||file<existing)createTableEarliest.set(tableName,file)}const earlierCreateDefinesColumn=(migrationFile,table,column)=>{const createFile=createTableEarliest.get(table);if(!createFile||createFile>=migrationFile)return!1;try{const createContent=readFileSync(join(migrationsDir,createFile),"utf8"),createStatement=sqlStatementsOf(createContent).find((statement)=>statement.match(createTablePattern)?.[1]===table);if(!createStatement)return!1;const escapedColumn=column.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return new RegExp(`(?:^|[,(])\\s*["'\`]?${escapedColumn}["'\`]?\\s+`,"i").test(createStatement)}catch{return!1}},sqliteDbPath=sqliteDatabasePath();let sqliteDb=null;if(existsSync(sqliteDbPath))try{const{Database}=require("bun:sqlite");sqliteDb=new Database(sqliteDbPath,{readonly:!0})}catch{}const migrationWasRecorded=(file)=>{if(!sqliteDb)return!1;try{return Boolean(sqliteDb.prepare("SELECT 1 FROM migrations WHERE migration = ? LIMIT 1").get(file))}catch{return!1}};for(const file of files){log.debug(`[migration] Running: ${file}`);const filePath=join(migrationsDir,file),content=readFileSync(filePath,"utf-8"),statements=sqlStatementsOf(content);if(statements.length===0)continue;const uniqueIndexNames=statements.map((s)=>s.match(createUniqueIndexPattern)?.[1]).filter((name)=>Boolean(name));if(sqliteDb&&uniqueIndexNames.length===statements.length){const indexExists=sqliteDb.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?");if(uniqueIndexNames.filter((name)=>!indexExists.get(name)).length>0){log.info(`Re-queueing unique-index migration (index missing from database): ${file}`);replayMigrations.push(file)}continue}if(migrationWasRecorded(file))continue;const firstStatement=statements[0],createTableMatch=firstStatement?firstStatement.match(createTablePattern):null;if(createTableMatch&&createTableMatch[1]){const tableName=createTableMatch[1],earliest=createTableEarliest.get(tableName);if(earliest&&earliest!==file&&!isPackageMigration(file)){deleteMigration(file,filePath,`duplicate create-table for "${tableName}" (kept ${earliest})`);continue}}if(statements.some((s)=>addConstraintPattern.test(s))){if(partitionUnsupported(file,filePath,statements,addConstraintPattern,"ALTER TABLE ADD CONSTRAINT")!=="runnable")continue}if(statements.some((s)=>createTypePattern.test(s))){if(partitionUnsupported(file,filePath,statements,createTypePattern,"CREATE TYPE (enum types)")!=="runnable")continue}const addColumnTargets=statements.map((s)=>s.match(addColumnPattern)).filter((m)=>Boolean(m?.[1]&&m[2])).map((m)=>({table:m[1],column:m[2]}));if(addColumnTargets.length>0&&addColumnTargets.length===statements.length){const satisfied=addColumnTargets.filter(({table,column})=>{try{if(sqliteDb){const safeTableName=table.replace(/[^a-zA-Z0-9_]/g,""),columns=sqliteDb.prepare(`PRAGMA table_info("${safeTableName}")`).all();if(columns.some((col)=>col.name===column))return!0;if(columns.length>0)return!1}return earlierCreateDefinesColumn(file,table,column)}catch{return!1}});if(satisfied.length===addColumnTargets.length){skipMigration(file,"every column it adds already exists or is defined by an earlier create-table migration");continue}if(satisfied.length>0)log.warn(`[migration] ${file} is partially applied: ${satisfied.map((p)=>`${p.table}.${p.column}`).join(", ")} already exist${satisfied.length===1?"s":""} or will be created earlier, the rest do not. SQLite cannot skip a single ADD COLUMN, so this file will fail. Add the remaining columns by hand, or split the file so the applied statements sit in their own migration.`)}if(statements.some((s)=>dropColumnPattern.test(s))){let modified=!1;const filteredStatements=[];for(const stmt of statements){const dropColMatch=stmt.match(dropColumnPattern);if(dropColMatch&&dropColMatch[1]&&dropColMatch[2]){const tableName=dropColMatch[1],columnName=dropColMatch[2];if(!sqliteDb){if(earlierCreateDefinesColumn(file,tableName,columnName)){filteredStatements.push(stmt);continue}log.info(`Skipping DROP COLUMN "${columnName}" - no database exists yet: ${file}`);modified=!0;continue}try{const safeTableName=tableName.replace(/[^a-zA-Z0-9_]/g,""),columns=sqliteDb.prepare(`PRAGMA table_info("${safeTableName}")`).all();if(columns.length===0){if(earlierCreateDefinesColumn(file,tableName,columnName)){filteredStatements.push(stmt);continue}log.info(`Skipping DROP COLUMN "${columnName}" - table "${tableName}" does not exist yet: ${file}`);modified=!0;continue}if(!columns.some((col)=>col.name===columnName)){log.info(`Skipping DROP COLUMN "${columnName}" from "${tableName}" - column does not exist: ${file}`);modified=!0;continue}}catch{if(earlierCreateDefinesColumn(file,tableName,columnName)){filteredStatements.push(stmt);continue}log.info(`Skipping DROP COLUMN "${columnName}" - table "${tableName}" not found: ${file}`);modified=!0;continue}}filteredStatements.push(stmt)}if(modified){if(filteredStatements.length===0)deleteMigration(file,filePath,"columns already absent from table");else writeFileSync(filePath,`${filteredStatements.join(`;
7
7
  `)};
8
8
  `);continue}}}if(sqliteDb)try{sqliteDb.close()}catch{}if(droppedMigrations.length>0||replayMigrations.length>0)try{const dbPath=sqliteDatabasePath();mkdirSync(dirname(dbPath),{recursive:!0});const{Database}=require("bun:sqlite"),writeDb=new Database(dbPath);try{writeDb.exec(`CREATE TABLE IF NOT EXISTS migrations (
9
9
  id INTEGER PRIMARY KEY AUTOINCREMENT,
10
10
  migration TEXT NOT NULL UNIQUE,
11
11
  executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
12
- )`);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}`)}}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
+ )`);const insert=writeDb.prepare("INSERT OR IGNORE INTO migrations (migration) VALUES (?)");for(const migration of droppedMigrations)insert.run(migration);const unrecord=writeDb.prepare("DELETE FROM migrations WHERE migration = ?");for(const migration of replayMigrations)unrecord.run(migration)}finally{writeDb.close()}}catch(e){log.debug(`[migration] Could not record dropped migrations as executed: ${e}`)}return quarantined}function mayCreateMissingDatabase(){const signal=process.env.STACKS_CREATE_DATABASE;if(signal==="1")return!0;if(signal==="0")return!1;const policy=String(process.env.DB_CREATE_DATABASE||"").toLowerCase();return!(policy==="never"||policy==="false"||policy==="0")}function describeProbeFailure(target,kind,error){const where=describeTarget(target),detail=error instanceof Error?error.message:String(error??"");switch(kind){case"missing-role":return`The user "${target.username}" does not exist on ${where}. Set DB_USERNAME to a role that exists, or create it with: createuser -s ${target.username}`;case"auth-failed":return`Authentication failed for user "${target.username}" on ${where}. Check DB_USERNAME and DB_PASSWORD.`;case"server-unreachable":return`Could not reach the database server at ${target.host}:${target.port}. Check that it is running and that DB_HOST and DB_PORT are correct.`;case"timeout":return`Timed out connecting to the database server at ${target.host}:${target.port}. ${detail}`;case"permission-denied":return`The user "${target.username}" is not allowed to connect to "${target.database}" on ${where}.`;default:return`Could not connect to the database "${target.database}" on ${where}. ${detail}`}}async function ensureDatabaseExists(){const target=resolveConnectionTarget();if(!target)return;const probe=await probeTargetDatabase(target);if(probe.ok)return;if(probe.kind!=="missing-database")throw Error(describeProbeFailure(target,probe.kind,probe.error));if(!mayCreateMissingDatabase())throw Error(`The database "${target.database}" does not exist on ${describeTarget(target)}. Create it with: ${manualCreateHint(target)}`);const result=await createDatabase(target);if(!result.created&&result.error)throw Error(`The database "${target.database}" does not exist on ${describeTarget(target)} and it could not be created automatically. ${describeProbeFailure(target,result.kind,result.error)}
13
13
  Create it yourself with: ${manualCreateHint(target)}`);if(result.created)log.success(`Created database "${target.database}" on ${target.host}:${target.port}`)}let databaseBootstrapped=!1;export async function ensureDatabaseReady(){if(databaseBootstrapped)return;await ensureDatabaseExists();databaseBootstrapped=!0}export function resetDatabaseBootstrapCache(){databaseBootstrapped=!1}async function hideDisabledFeatureMigrations(){const hidden=[];try{const{appModelClaimsTable,FEATURE_NAMES,migrationFeature,migrationTable}=await import("@stacksjs/features"),{feature:isFeatureEnabled}=await import("@stacksjs/config"),fs=await import("node:fs/promises"),migrationsDir=migrationDirectory();if(!existsSync(migrationsDir))return hidden;const disabledFeatures=new Set(FEATURE_NAMES.filter((f)=>!isFeatureEnabled(f)));if(disabledFeatures.size===0)return hidden;const files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql")),gatedTables=new Set;for(const file of files){const owner=migrationFeature(file);if(!owner||!disabledFeatures.has(owner))continue;const table=migrationTable(file);if(table&&appModelClaimsTable(table))continue;if(table)gatedTables.add(table.toLowerCase());const original=join(migrationsDir,file),hiddenPath=`${original}.disabled`;await fs.rename(original,hiddenPath);hidden.push({original,hidden:hiddenPath,feature:owner})}for(const file of files){const filePath=join(migrationsDir,file);if(!existsSync(filePath))continue;const sql=readFileSync(filePath,"utf8"),filtered=withoutGatedStatements(sql,gatedTables);if(filtered===sql)continue;const backup=`${filePath}.ungated`;await fs.rename(filePath,backup);writeFileSync(filePath,filtered);hidden.push({original:filePath,hidden:backup,feature:"mixed"})}if(hidden.length>0){const summary=Object.entries(hidden.reduce((acc,h)=>{acc[h.feature]=(acc[h.feature]??0)+1;return acc},{})).map(([f,n])=>`${f}: ${n}`).join(", ");log.info(`[migration] Skipping ${hidden.length} migration(s) for disabled features (${summary}). Run \`./buddy <feature>:install\` to enable.`)}}catch{}return hidden}function afterCommonTableExpressions(statement){if(!/^\s*WITH\b/i.test(statement))return statement;let depth=0;for(let index=0;index<statement.length;index++){const char=statement[index];if(char==="("){depth++;continue}if(char!==")")continue;depth--;if(depth!==0)continue;const rest=statement.slice(index+1);if(/^\s*,/.test(rest))continue;return rest}return statement}export function statementTable(statement){const body=afterCommonTableExpressions(statement),patterns=[/^\s*ALTER\s+TABLE\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?([a-z0-9_]+)["`]?/i,/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["`]?([a-z0-9_]+)["`]?/i,/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?[a-z0-9_]+["`]?\s+ON\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*UPDATE\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*INSERT\s+(?:OR\s+\w+\s+)?INTO\s+["`]?([a-z0-9_]+)["`]?/i,/^\s*DELETE\s+FROM\s+["`]?([a-z0-9_]+)["`]?/i];for(const pattern of patterns){const match=pattern.exec(body);if(match)return match[1].toLowerCase()}return null}export function statementReferencesTable(statement,table){const escaped=table.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return new RegExp(`\\b(?:FROM|JOIN|UPDATE|INTO)\\s+["\`\\[]?${escaped}["\`\\]]?\\b`,"i").test(statement)}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=statementTable(statement);if(table&&gated.has(table))return!1;for(const candidate of gated)if(statementReferencesTable(statement,candidate))return!1;return!0});if(kept.length===statements.length)return sql;return kept.length===0?"":`${kept.join(`;
14
14
  `)};
15
15
  `}async function restoreHiddenMigrations(hidden){const fs=await import("node:fs/promises");for(const{original,hidden:h}of hidden)try{if(h.endsWith(".ungated"))await fs.rm(original,{force:!0});await fs.rename(h,original)}catch{}}export async function countAppliedMigrations(){try{const row=await db.selectFrom("migrations").select((eb)=>eb.fn.count("id").as("n")).executeTakeFirst();if(!row)return 0;const n=Number(row.n??row.N??0);return Number.isFinite(n)?n:0}catch{return 0}}async function writeMigrateMarker(appliedCount){try{const fs=await import("node:fs/promises"),dir=path.frameworkRuntimePath();await fs.mkdir(dir,{recursive:!0});const file=`${dir}/last-migrate-result.json`,body=JSON.stringify({appliedCount,completedAt:new Date().toISOString()});await fs.writeFile(file,body,"utf8")}catch{}}export function idempotentSql(sql){const header=/^(?:[^\S\n]*--[^\n]*\n)+/.exec(sql)?.[0]??"",stmts=sql.slice(header.length).split(";").map((s)=>s.trim()).filter(Boolean);if(stmts.length===0)return sql;const out=[];for(const raw of stmts){const stmt=raw.replace(/\bADD\s+COLUMN\s+(?!IF\s+NOT\s+EXISTS\b)/gi,"ADD COLUMN IF NOT EXISTS "),m=/^ALTER\s+TABLE\s+("?\w+"?)\s+ADD\s+CONSTRAINT\s+("?\w+"?)/i.exec(stmt);if(m){const drops=[`ALTER TABLE ${m[1]} DROP CONSTRAINT IF EXISTS ${m[2]}`],fk=/\bFOREIGN\s+KEY\s*\(\s*"?(\w+)"?\s*\)/i.exec(stmt);if(fk){const table=m[1].replace(/"/g,"");drops.push(`ALTER TABLE ${m[1]} DROP CONSTRAINT IF EXISTS "${table}_${fk[1]}_fkey"`)}const already=new Set;for(let i=out.length-1;i>=0;i--){const previous=out[i];if(!/^ALTER\s+TABLE\s+"?\w+"?\s+DROP\s+CONSTRAINT\b/i.test(previous))break;already.add(previous.toUpperCase())}for(const drop of drops)if(!already.has(drop.toUpperCase()))out.push(drop)}out.push(stmt)}return`${header}${out.join(`;
16
16
  `)};
17
- `}function makeMigrationsIdempotent(){const rewritten=[],migrationsDir=migrationDirectory("postgres");let files;try{files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql"))}catch{return}for(const f of files){const p=join(migrationsDir,f);let sql;try{sql=readFileSync(p,"utf8")}catch{continue}if(!(/\bADD\s+(?:COLUMN|CONSTRAINT)\b/i.test(sql)||/\bCREATE\s+TYPE\b/i.test(sql)||/\bALTER\s+COLUMN\b[^\n]*\bTYPE\b/i.test(sql)))continue;const next=orderPostgresColumnTypeChanges(guardPostgresEnumTypes(idempotentSql(sql)));if(next!==sql)try{writeFileSync(p,next);rewritten.push(f)}catch{}}if(rewritten.length>0)log.warn(`[migration] Rewrote ${rewritten.length} migration file(s) on disk to be idempotent: ${rewritten.slice(0,3).join(", ")}${rewritten.length>3?`, +${rewritten.length-3} more`:""}. These files are tracked in git, so this shows up as a working-tree change.`)}export async function runDatabaseMigration(){const startedAt=Date.now(),hidden=await hideDisabledFeatureMigrations();let lockHandle=null;try{log.debug("Migrating database...");await ensureDatabaseReady();configureQueryBuilder();const dialect=getDialect(),lockDb=dialect==="sqlite"?null:createQueryBuilder();lockHandle=await acquireMigrationLock(dialect,lockDb);try{const{packageMigrationRoots}=await import("@stacksjs/config"),staged=stagePackageMigrations({roots:packageMigrationRoots(),corpusDir:migrationDirectory(dialect)});if(staged.length>0){const packages=[...new Set(staged.map((s)=>s.package))].join(", ");log.info(`Staged ${staged.length} migration(s) from ${packages}`)}}catch(error){log.warn(`Could not stage package migrations: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")preprocessSqliteMigrations();else if(dialect==="postgres")makeMigrationsIdempotent();const corpusDir=migrationDirectory(dialect),migrationsDir=migrationDirectory();let migrationSql="";try{migrationSql=readdirSync(migrationsDir).filter((file)=>file.endsWith(".sql")).sort().map((file)=>readFileSync(join(migrationsDir,file),"utf8")).join(`
18
- `)}catch{}if(migrationSql){const preflightTables=notificationTablesMissingCreateStatements(migrationSql);if(preflightTables.length>0){const preflight=await migrateNotificationTables({tables:preflightTables});if(!preflight.success)throw Error(preflight.error||"Failed to prepare notification tables before migrations")}}const appliedBefore=await countAppliedMigrations();log.debug(`[migration] Running migrations from: ${corpusDir}`);await qbExecuteMigration(corpusDir);const notificationTables=await migrateNotificationTables();if(!notificationTables.success)throw Error(notificationTables.error||"Failed to prepare notification tables");await ensureNotificationForeignKeys();const appliedAfter=await countAppliedMigrations(),appliedCount=Math.max(0,appliedAfter-appliedBefore);await writeMigrateMarker(appliedCount);log.debug(`Database migration completed in ${Date.now()-startedAt}ms (applied ${appliedCount}).`);return ok(appliedCount===0?"Nothing to migrate.":`Applied ${appliedCount} migration${appliedCount===1?"":"s"}.`)}catch(error){const detail=error instanceof Error?error.message:String(error);log.error(`[migration] Failed after ${Date.now()-startedAt}ms: ${detail}`);if(/UNIQUE constraint failed/i.test(detail)){const viaIndex=/index\s+'/i.test(detail);log.info("[migration] This is a data conflict, not a schema one - `migrate:fresh` replays the same statements against the same rows and fails the same way. Clear or de-duplicate the offending rows first.");if(viaIndex)log.info("[migration] The error names an index rather than a column, which SQLite only does for an expression index or a table rebuild - so the conflict is arising while rows are being copied, not from a bare CREATE UNIQUE INDEX.")}else log.info("[migration] Run `./buddy migrate:fresh` to drop and recreate the schema if state is partial.");return err(handleError("Migration failed",error))}finally{if(lockHandle)try{await lockHandle.release()}catch{}await restoreHiddenMigrations(hidden)}}const FRAMEWORK_TABLES=["oauth_refresh_tokens","oauth_access_tokens","oauth_clients","passkeys","failed_jobs","jobs","notifications","password_reset_tokens"];export async function resetDatabase(){try{await ensureDatabaseReady();configureQueryBuilder();const modelsDir=path.userModelsPath(),dialect=getDialect();await dropFrameworkTables(dialect);if(existsSync(modelsDir))await qbResetDatabase(modelsDir,{dialect,preserveMigrationState:!0});else log.debug(`No models directory at ${modelsDir}; skipping model table drops.`);const defaultsDir=defaultModelsPath();if(existsSync(defaultsDir))await qbResetDatabase(defaultsDir,{dialect,preserveMigrationState:!0});else log.debug(`No framework default models directory at ${defaultsDir}; skipping.`);if(dialect==="postgres")await dropOrphanedEnumTypes();return ok("All tables dropped successfully!")}catch(error){return err(handleError("Database reset failed",error))}}async function dropOrphanedEnumTypes(){try{const raw=await db.unsafe(`
17
+ `}function makeMigrationsIdempotent(){const rewritten=[],migrationsDir=migrationDirectory("postgres");let files;try{files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql"))}catch{return}for(const f of files){const p=join(migrationsDir,f);let sql;try{sql=readFileSync(p,"utf8")}catch{continue}if(!(/\bADD\s+(?:COLUMN|CONSTRAINT)\b/i.test(sql)||/\bCREATE\s+TYPE\b/i.test(sql)||/\bALTER\s+COLUMN\b[^\n]*\bTYPE\b/i.test(sql)))continue;const next=orderPostgresColumnTypeChanges(guardPostgresEnumTypes(idempotentSql(sql)));if(next!==sql)try{writeFileSync(p,next);rewritten.push(f)}catch{}}if(rewritten.length>0)log.warn(`[migration] Rewrote ${rewritten.length} migration file(s) on disk to be idempotent: ${rewritten.slice(0,3).join(", ")}${rewritten.length>3?`, +${rewritten.length-3} more`:""}. These files are tracked in git, so this shows up as a working-tree change.`)}export async function runDatabaseMigration(){const startedAt=Date.now(),hidden=await hideDisabledFeatureMigrations();let quarantinedMigrations=[],lockHandle=null;try{log.debug("Migrating database...");await ensureDatabaseReady();configureQueryBuilder();const dialect=getDialect(),lockDb=dialect==="sqlite"?null:createQueryBuilder();lockHandle=await acquireMigrationLock(dialect,lockDb);try{const{packageMigrationRoots}=await import("@stacksjs/config"),staged=stagePackageMigrations({roots:packageMigrationRoots(),corpusDir:migrationDirectory(dialect)});if(staged.length>0){const packages=[...new Set(staged.map((s)=>s.package))].join(", ");log.info(`Staged ${staged.length} migration(s) from ${packages}`)}}catch(error){log.warn(`Could not stage package migrations: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite"){quarantinedMigrations=preprocessSqliteMigrations();hidden.push(...quarantinedMigrations)}else if(dialect==="postgres")makeMigrationsIdempotent();const corpusDir=migrationDirectory(dialect),migrationsDir=migrationDirectory();let migrationSql="";try{migrationSql=readdirSync(migrationsDir).filter((file)=>file.endsWith(".sql")).sort().map((file)=>readFileSync(join(migrationsDir,file),"utf8")).join(`
18
+ `)}catch{}if(migrationSql){const preflightTables=notificationTablesMissingCreateStatements(migrationSql);if(preflightTables.length>0){const preflight=await migrateNotificationTables({tables:preflightTables});if(!preflight.success)throw Error(preflight.error||"Failed to prepare notification tables before migrations")}}const appliedBefore=await countAppliedMigrations();log.debug(`[migration] Running migrations from: ${corpusDir}`);await qbExecuteMigration(corpusDir);const notificationTables=await migrateNotificationTables();if(!notificationTables.success)throw Error(notificationTables.error||"Failed to prepare notification tables");await ensureNotificationForeignKeys();const appliedAfter=await countAppliedMigrations(),appliedCount=Math.max(0,appliedAfter-appliedBefore);await writeMigrateMarker(appliedCount);log.debug(`Database migration completed in ${Date.now()-startedAt}ms (applied ${appliedCount}).`);if(quarantinedMigrations.length>0&&process.env.STACKS_ALLOW_PENDING_MIGRATIONS!=="1"){const names=quarantinedMigrations.map((q)=>basename(q.original));return err(Error(`Applied ${appliedCount} migration${appliedCount===1?"":"s"}, but ${names.length} could not run on SQLite and ${names.length===1?"is":"are"} still pending: ${names.join(", ")}. ${names.length===1?"It mixes":"They mix"} statements SQLite cannot execute with statements it can, so splitting them into separate migrations lets the runnable half apply. Set STACKS_ALLOW_PENDING_MIGRATIONS=1 to proceed anyway.`))}return ok(appliedCount===0?"Nothing to migrate.":`Applied ${appliedCount} migration${appliedCount===1?"":"s"}.`)}catch(error){const detail=error instanceof Error?error.message:String(error);log.error(`[migration] Failed after ${Date.now()-startedAt}ms: ${detail}`);if(/UNIQUE constraint failed/i.test(detail)){const viaIndex=/index\s+'/i.test(detail);log.info("[migration] This is a data conflict, not a schema one - `migrate:fresh` replays the same statements against the same rows and fails the same way. Clear or de-duplicate the offending rows first.");if(viaIndex)log.info("[migration] The error names an index rather than a column, which SQLite only does for an expression index or a table rebuild - so the conflict is arising while rows are being copied, not from a bare CREATE UNIQUE INDEX.")}else log.info("[migration] Run `./buddy migrate:fresh` to drop and recreate the schema if state is partial.");return err(handleError("Migration failed",error))}finally{if(lockHandle)try{await lockHandle.release()}catch{}await restoreHiddenMigrations(hidden)}}const FRAMEWORK_TABLES=["oauth_refresh_tokens","oauth_access_tokens","oauth_clients","passkeys","failed_jobs","jobs","notifications","password_reset_tokens"];export async function resetDatabase(){try{await ensureDatabaseReady();configureQueryBuilder();const modelsDir=path.userModelsPath(),dialect=getDialect();await dropFrameworkTables(dialect);if(existsSync(modelsDir))await qbResetDatabase(modelsDir,{dialect,preserveMigrationState:!0});else log.debug(`No models directory at ${modelsDir}; skipping model table drops.`);const defaultsDir=defaultModelsPath();if(existsSync(defaultsDir))await qbResetDatabase(defaultsDir,{dialect,preserveMigrationState:!0});else log.debug(`No framework default models directory at ${defaultsDir}; skipping.`);if(dialect==="postgres")await dropOrphanedEnumTypes();return ok("All tables dropped successfully!")}catch(error){return err(handleError("Database reset failed",error))}}async function dropOrphanedEnumTypes(){try{const raw=await db.unsafe(`
19
19
  SELECT t.typname AS name
20
20
  FROM pg_type t
21
21
  JOIN pg_namespace n ON n.oid = t.typnamespace
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/database",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.74.29",
5
+ "version": "0.74.31",
6
6
  "description": "The Stacks database integration.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -60,26 +60,26 @@
60
60
  "prepublishOnly": "bun run build"
61
61
  },
62
62
  "dependencies": {
63
- "@stacksjs/config": "0.74.29",
64
- "@stacksjs/env": "0.74.29",
65
- "@stacksjs/error-handling": "0.74.29",
66
- "@stacksjs/faker": "^0.74.29",
67
- "@stacksjs/features": "0.74.29",
68
- "@stacksjs/logging": "0.74.29",
69
- "@stacksjs/model-meta": "0.74.29",
70
- "@stacksjs/path": "0.74.29",
71
- "@stacksjs/query-builder": "^0.74.29",
72
- "@stacksjs/security": "0.74.29",
73
- "@stacksjs/storage": "0.74.29",
74
- "@stacksjs/strings": "0.74.29",
63
+ "@stacksjs/config": "0.74.31",
64
+ "@stacksjs/env": "0.74.31",
65
+ "@stacksjs/error-handling": "0.74.31",
66
+ "@stacksjs/faker": "^0.74.31",
67
+ "@stacksjs/features": "0.74.31",
68
+ "@stacksjs/logging": "0.74.31",
69
+ "@stacksjs/model-meta": "0.74.31",
70
+ "@stacksjs/path": "0.74.31",
71
+ "@stacksjs/query-builder": "^0.74.31",
72
+ "@stacksjs/security": "0.74.31",
73
+ "@stacksjs/storage": "0.74.31",
74
+ "@stacksjs/strings": "0.74.31",
75
75
  "@stacksjs/ts-validation": "^0.5.6",
76
- "bun-query-builder": "^0.2.68",
76
+ "bun-query-builder": "^0.2.69",
77
77
  "dynamodb-tooling": "^0.3.2"
78
78
  },
79
79
  "devDependencies": {
80
- "@stacksjs/cli": "0.74.29",
81
- "@stacksjs/router": "0.74.29",
82
- "@stacksjs/utils": "0.74.29",
80
+ "@stacksjs/cli": "0.74.31",
81
+ "@stacksjs/router": "0.74.31",
82
+ "@stacksjs/utils": "0.74.31",
83
83
  "better-dx": "^0.2.24"
84
84
  }
85
85
  }