@stacksjs/database 0.75.180 → 0.75.182

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import{randomBytes}from"node:crypto";import{log}from"@stacksjs/logging";import{makeHash}from"@stacksjs/security";import{db}from"./utils";import{sqlHelpers}from"./sql-helpers";import{indexSqlForDialect,isDuplicateColumnError,isDuplicateIndexError,resolvedDatabaseDriver}from"./dialect";export const AUTH_TABLES=["oauth_clients","oauth_grants","oauth_auth_codes","oauth_authorization_requests","oauth_access_tokens","oauth_refresh_tokens","password_resets","email_verifications","passkeys","webauthn_challenges","two_factor_challenges","two_factor_pending_secrets"];export function usersEmailVerifiedAtSql(sql){return`ALTER TABLE users ADD COLUMN email_verified_at ${sql.nullableTimestamp}`}export function usersPasswordChangedAtSql(sql){return`ALTER TABLE users ADD COLUMN password_changed_at ${sql.nullableTimestamp}`}export function usersTwoFactorColumnsSql(sql){return["ALTER TABLE users ADD COLUMN two_factor_secret VARCHAR(255)",`ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN NOT NULL DEFAULT ${sql.boolFalse}`,"ALTER TABLE users ADD COLUMN two_factor_last_used_step BIGINT"]}export function usersStripeIdSql(){return"ALTER TABLE users ADD COLUMN stripe_id VARCHAR(255)"}export function oauthClientProviderColumnsSql(){return["ALTER TABLE oauth_clients ADD COLUMN client_type VARCHAR(20)","ALTER TABLE oauth_clients ADD COLUMN redirect_uris TEXT","ALTER TABLE oauth_clients ADD COLUMN grant_types TEXT","ALTER TABLE oauth_clients ADD COLUMN token_endpoint_auth_method VARCHAR(32)","ALTER TABLE oauth_clients ADD COLUMN allowed_scopes TEXT","ALTER TABLE oauth_clients ADD COLUMN allowed_resources TEXT"]}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 oauthAccessTokenDelegationColumnsSql(){return["ALTER TABLE oauth_access_tokens ADD COLUMN oauth_grant_id VARCHAR(32)","ALTER TABLE oauth_access_tokens ADD COLUMN resources TEXT","ALTER TABLE oauth_access_tokens ADD COLUMN audiences TEXT","ALTER TABLE oauth_access_tokens ADD COLUMN workspace_id VARCHAR(255)"]}export function oauthRefreshTokenDelegationColumnsSql(){return["ALTER TABLE oauth_refresh_tokens ADD COLUMN family_id VARCHAR(64)","ALTER TABLE oauth_refresh_tokens ADD COLUMN parent_id BIGINT"]}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)",resolvedDatabaseDriver())).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=resolvedDatabaseDriver(),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{makeHash}from"@stacksjs/security";import{db}from"./utils";import{sqlHelpers}from"./sql-helpers";import{indexSqlForDialect,isDuplicateColumnError,isDuplicateIndexError,resolvedDatabaseDriver}from"./dialect";export const AUTH_TABLES=["oauth_clients","oauth_grants","oauth_auth_codes","oauth_authorization_requests","oauth_access_tokens","oauth_refresh_tokens","password_resets","email_verifications","passkeys","webauthn_challenges","passkey_ceremonies","two_factor_challenges","two_factor_pending_secrets"];export function usersEmailVerifiedAtSql(sql){return`ALTER TABLE users ADD COLUMN email_verified_at ${sql.nullableTimestamp}`}export function usersPasswordChangedAtSql(sql){return`ALTER TABLE users ADD COLUMN password_changed_at ${sql.nullableTimestamp}`}export function usersTwoFactorColumnsSql(sql){return["ALTER TABLE users ADD COLUMN two_factor_secret VARCHAR(255)",`ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN NOT NULL DEFAULT ${sql.boolFalse}`,"ALTER TABLE users ADD COLUMN two_factor_last_used_step BIGINT"]}export function usersStripeIdSql(){return"ALTER TABLE users ADD COLUMN stripe_id VARCHAR(255)"}export function oauthClientProviderColumnsSql(){return["ALTER TABLE oauth_clients ADD COLUMN client_type VARCHAR(20)","ALTER TABLE oauth_clients ADD COLUMN redirect_uris TEXT","ALTER TABLE oauth_clients ADD COLUMN grant_types TEXT","ALTER TABLE oauth_clients ADD COLUMN token_endpoint_auth_method VARCHAR(32)","ALTER TABLE oauth_clients ADD COLUMN allowed_scopes TEXT","ALTER TABLE oauth_clients ADD COLUMN allowed_resources TEXT"]}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 oauthAccessTokenDelegationColumnsSql(){return["ALTER TABLE oauth_access_tokens ADD COLUMN oauth_grant_id VARCHAR(32)","ALTER TABLE oauth_access_tokens ADD COLUMN resources TEXT","ALTER TABLE oauth_access_tokens ADD COLUMN audiences TEXT","ALTER TABLE oauth_access_tokens ADD COLUMN workspace_id VARCHAR(255)"]}export function oauthRefreshTokenDelegationColumnsSql(){return["ALTER TABLE oauth_refresh_tokens ADD COLUMN family_id VARCHAR(64)","ALTER TABLE oauth_refresh_tokens ADD COLUMN parent_id BIGINT"]}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)",resolvedDatabaseDriver())).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=resolvedDatabaseDriver(),sql=sqlHelpers(dbDriver),{isPostgres,boolTrue,now,pkColumn,nullableTimestamp,datetime,utcNow}=sql;if(options.verbose)log.info(`Creating auth tables for ${dbDriver}...`);try{if(options.verbose)log.info("Creating oauth_clients table...");await db.unsafe(`
2
2
  CREATE TABLE IF NOT EXISTS oauth_clients (
3
3
  ${pkColumn},
4
4
  user_id BIGINT,
@@ -147,7 +147,14 @@ import{randomBytes}from"node:crypto";import{log}from"@stacksjs/logging";import{m
147
147
  created_at ${datetime} DEFAULT ${utcNow},
148
148
  last_used_at ${nullableTimestamp}
149
149
  )
150
- `).execute();try{await db.unsafe(indexSqlForDialect("CREATE INDEX IF NOT EXISTS idx_passkeys_user_id ON passkeys(user_id)",dbDriver)).execute()}catch{}if(options.verbose)log.info("Creating webauthn_challenges table...");await db.unsafe(`
150
+ `).execute();try{await db.unsafe(indexSqlForDialect("CREATE INDEX IF NOT EXISTS idx_passkeys_user_id ON passkeys(user_id)",dbDriver)).execute()}catch{}await db.unsafe(`CREATE TABLE IF NOT EXISTS passkey_ceremonies (
151
+ id VARCHAR(64) PRIMARY KEY,
152
+ user_id INTEGER,
153
+ purpose VARCHAR(20) NOT NULL,
154
+ challenge TEXT NOT NULL,
155
+ origin VARCHAR(255) NOT NULL,
156
+ expires_at ${datetime} NOT NULL
157
+ )`).execute();try{await db.unsafe(indexSqlForDialect("CREATE INDEX IF NOT EXISTS idx_passkey_ceremonies_expiry ON passkey_ceremonies(expires_at)",dbDriver)).execute()}catch(error){if(!isDuplicateIndexError(error))throw error}if(options.verbose)log.info("Creating webauthn_challenges table...");await db.unsafe(`
151
158
  CREATE TABLE IF NOT EXISTS webauthn_challenges (
152
159
  ${pkColumn},
153
160
  user_id INTEGER NOT NULL,
@@ -18,7 +18,7 @@ Create it yourself with: ${manualCreateHint(target)}`);if(result.created)log.su
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 preflight=notificationTablePreflight(migrationSql);if(preflight.length>0){const prepared=await migrateNotificationTables({tables:preflight});if(!prepared.success)throw Error(prepared.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(`
21
+ `)}catch{}if(migrationSql){const preflight=notificationTablePreflight(migrationSql);if(preflight.length>0){const prepared=await migrateNotificationTables({tables:preflight});if(!prepared.success)throw Error(prepared.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","passkey_ceremonies","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
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.180",
5
+ "version": "0.75.182",
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.180",
70
- "@stacksjs/env": "0.75.180",
71
- "@stacksjs/error-handling": "0.75.180",
72
- "@stacksjs/faker": "0.75.180",
73
- "@stacksjs/features": "0.75.180",
74
- "@stacksjs/logging": "0.75.180",
75
- "@stacksjs/model-meta": "0.75.180",
76
- "@stacksjs/path": "0.75.180",
77
- "@stacksjs/query-builder": "0.75.180",
78
- "@stacksjs/security": "0.75.180",
79
- "@stacksjs/storage": "0.75.180",
80
- "@stacksjs/strings": "0.75.180",
69
+ "@stacksjs/config": "0.75.182",
70
+ "@stacksjs/env": "0.75.182",
71
+ "@stacksjs/error-handling": "0.75.182",
72
+ "@stacksjs/faker": "0.75.182",
73
+ "@stacksjs/features": "0.75.182",
74
+ "@stacksjs/logging": "0.75.182",
75
+ "@stacksjs/model-meta": "0.75.182",
76
+ "@stacksjs/path": "0.75.182",
77
+ "@stacksjs/query-builder": "0.75.182",
78
+ "@stacksjs/security": "0.75.182",
79
+ "@stacksjs/storage": "0.75.182",
80
+ "@stacksjs/strings": "0.75.182",
81
81
  "@stacksjs/ts-validation": "^0.5.6",
82
82
  "bun-query-builder": "^0.3.9",
83
83
  "dynamodb-tooling": "^0.3.6"
84
84
  },
85
85
  "devDependencies": {
86
- "@stacksjs/cli": "0.75.180",
87
- "@stacksjs/router": "0.75.180",
88
- "@stacksjs/utils": "0.75.180",
86
+ "@stacksjs/cli": "0.75.182",
87
+ "@stacksjs/router": "0.75.182",
88
+ "@stacksjs/utils": "0.75.182",
89
89
  "better-dx": "^0.2.24"
90
90
  }
91
91
  }