@stacksjs/database 0.70.345 → 0.70.347
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.
- package/dist/migrations.js +3 -2
- package/dist/notification-tables.d.ts +14 -1
- package/dist/notification-tables.js +1 -1
- package/package.json +10 -10
package/dist/migrations.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
var {require}=import.meta;import{existsSync,mkdirSync,readdirSync,readFileSync,renameSync,unlinkSync,writeFileSync}from"node:fs";import{dirname,isAbsolute,join}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,resetConnection,resetDatabase as qbResetDatabase,config as qbConfig,saveMigrationSnapshot,setConfig}from"@stacksjs/query-builder";import{db,QB_SNAPSHOT_DIR}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}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 \u2014 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:QB_SNAPSHOT_DIR})}function configureQueryBuilder(targetDialect=getQbDialect(),vitessSharded){const connectionConfig=dbConfig.connections[targetDialect];setConfig({dialect:targetDialect,vitess:{sharded:isVitessSharded(vitessSharded??connectionConfig?.sharded)},verbose:!1,snapshotDir:QB_SNAPSHOT_DIR,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||""}});resetConnection()}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{dirname,isAbsolute,join}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,resetConnection,resetDatabase as qbResetDatabase,config as qbConfig,saveMigrationSnapshot,setConfig}from"@stacksjs/query-builder";import{db,QB_SNAPSHOT_DIR}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 \u2014 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:QB_SNAPSHOT_DIR})}function configureQueryBuilder(targetDialect=getQbDialect(),vitessSharded){const connectionConfig=dbConfig.connections[targetDialect];setConfig({dialect:targetDialect,vitess:{sharded:isVitessSharded(vitessSharded??connectionConfig?.sharded)},verbose:!1,snapshotDir:QB_SNAPSHOT_DIR,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||""}});resetConnection()}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};`}
|
|
@@ -14,7 +14,8 @@ Create it yourself with: ${manualCreateHint(target)}`);if(result.created)log.su
|
|
|
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);if(dialect==="sqlite")preprocessSqliteMigrations();else if(dialect==="postgres")makeMigrationsIdempotent();const modelsDir=path.userModelsPath(),
|
|
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);if(dialect==="sqlite")preprocessSqliteMigrations();else if(dialect==="postgres")makeMigrationsIdempotent();const modelsDir=path.userModelsPath(),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: ${modelsDir}`);await qbExecuteMigration(modelsDir);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 \u2014 `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 \u2014 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});else log.debug(`No models directory at ${modelsDir}; skipping model table drops.`);const defaultsDir=defaultModelsPath();if(existsSync(defaultsDir))await qbResetDatabase(defaultsDir,{dialect});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 rows=await db.unsafe(`
|
|
18
19
|
SELECT t.typname AS name
|
|
19
20
|
FROM pg_type t
|
|
20
21
|
JOIN pg_namespace n ON n.oid = t.typnamespace
|
|
@@ -18,11 +18,22 @@ export declare function notificationPreferencesTableSql(sql: SqlHelpers): string
|
|
|
18
18
|
* state into the database inbox table.
|
|
19
19
|
*/
|
|
20
20
|
export declare function notificationDeliveriesTableSql(sql: SqlHelpers): string;
|
|
21
|
+
/**
|
|
22
|
+
* Return framework notification tables that a migration corpus does not
|
|
23
|
+
* create itself.
|
|
24
|
+
*
|
|
25
|
+
* Old generated corpora can contain UPDATE or SQLite rebuild statements for
|
|
26
|
+
* these tables without containing their original CREATE TABLE migrations. A
|
|
27
|
+
* fresh database therefore needs the framework guarantee before that corpus
|
|
28
|
+
* runs. When the corpus does declare a table, the model-owned CREATE remains
|
|
29
|
+
* authoritative and the framework must not pre-create a narrower shape.
|
|
30
|
+
*/
|
|
31
|
+
export declare function notificationTablesMissingCreateStatements(sql: string): NotificationTableName[];
|
|
21
32
|
/**
|
|
22
33
|
* Create the notification + notification_preferences tables. Idempotent
|
|
23
34
|
* (`IF NOT EXISTS`), so it's safe to run on every `buddy migrate`.
|
|
24
35
|
*/
|
|
25
|
-
export declare function migrateNotificationTables(options?: { verbose?: boolean }): Promise<{ success: boolean, error?: string }>;
|
|
36
|
+
export declare function migrateNotificationTables(options?: { verbose?: boolean, tables?: readonly NotificationTableName[] }): Promise<{ success: boolean, error?: string }>;
|
|
26
37
|
/**
|
|
27
38
|
* The foreign keys on `notifications.user_id` and
|
|
28
39
|
* `notification_deliveries.user_id`, added after the model batch has run.
|
|
@@ -53,4 +64,6 @@ export declare function migrateNotificationTables(options?: { verbose?: boolean
|
|
|
53
64
|
* every disagreement.
|
|
54
65
|
*/
|
|
55
66
|
export declare function ensureNotificationForeignKeys(options?: { verbose?: boolean }): Promise<void>;
|
|
67
|
+
export declare const notificationTableNames: readonly ['notifications', 'notification_preferences', 'notification_deliveries'];
|
|
56
68
|
declare type SqlHelpers = ReturnType<typeof sqlHelpers>;
|
|
69
|
+
export type NotificationTableName = typeof notificationTableNames[number];
|
|
@@ -29,4 +29,4 @@ import{log}from"@stacksjs/logging";import{env as envVars}from"@stacksjs/env";imp
|
|
|
29
29
|
sent_at ${nullableTimestamp},
|
|
30
30
|
created_at ${datetime} DEFAULT CURRENT_TIMESTAMP,
|
|
31
31
|
updated_at ${nullableTimestamp}
|
|
32
|
-
)`}async function createIndex(statement,dialect){try{await db.unsafe(indexSqlForDialect(statement,dialect)).execute()}catch(error){if(!isDuplicateIndexError(error))throw error}}const REQUIRED_COLUMNS={notifications:["id","user_id","type","data","read_at"],notification_preferences:["id","user_id","channel","enabled","category"],notification_deliveries:["id","user_id","channel","recipient","body","status"]};async function warnOnShapeMismatch(table){const required=REQUIRED_COLUMNS[table];if(!required)return;try{const rows=await db.unsafe(`SELECT * FROM ${table} WHERE 1 = 0`).execute(),known=Array.isArray(rows)&&rows.length>0?Object.keys(rows[0]):null,missing=[];for(const column of required){if(known){if(!known.includes(column))missing.push(column);continue}try{await db.unsafe(`SELECT ${column} FROM ${table} WHERE 1 = 0`).execute()}catch{missing.push(column)}}if(missing.length>0)log.warn(`[notifications] "${table}" already exists without ${missing.join(", ")}. CREATE TABLE IF NOT EXISTS left it untouched, so the notification driver will fail on those columns. Something else owns this table - either rename it, or stop using the framework's notification tables.`)}catch{}}export async function migrateNotificationTables(options={}){const dbDriver=getDbDriver(),sql=sqlHelpers(dbDriver);if(options.verbose)log.info(`Creating notification tables for ${dbDriver}...`);try{if(options.verbose)log.info("Creating notifications table...");await warnOnShapeMismatch("notifications");await db.unsafe(notificationsTableSql(sql)).execute();await createIndex("CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_id)",dbDriver)
|
|
32
|
+
)`}async function createIndex(statement,dialect){try{await db.unsafe(indexSqlForDialect(statement,dialect)).execute()}catch(error){if(!isDuplicateIndexError(error))throw error}}const REQUIRED_COLUMNS={notifications:["id","user_id","type","data","read_at"],notification_preferences:["id","user_id","channel","enabled","category"],notification_deliveries:["id","user_id","channel","recipient","body","status"]};export const notificationTableNames=["notifications","notification_preferences","notification_deliveries"];export function notificationTablesMissingCreateStatements(sql){return notificationTableNames.filter((table)=>{const escaped=table.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return!new RegExp(`\\bCREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?["\\x60\\[]?${escaped}["\\x60\\]]?\\s*\\(`,"i").test(sql)})}async function warnOnShapeMismatch(table){const required=REQUIRED_COLUMNS[table];if(!required)return;try{const rows=await db.unsafe(`SELECT * FROM ${table} WHERE 1 = 0`).execute(),known=Array.isArray(rows)&&rows.length>0?Object.keys(rows[0]):null,missing=[];for(const column of required){if(known){if(!known.includes(column))missing.push(column);continue}try{await db.unsafe(`SELECT ${column} FROM ${table} WHERE 1 = 0`).execute()}catch{missing.push(column)}}if(missing.length>0)log.warn(`[notifications] "${table}" already exists without ${missing.join(", ")}. CREATE TABLE IF NOT EXISTS left it untouched, so the notification driver will fail on those columns. Something else owns this table - either rename it, or stop using the framework's notification tables.`)}catch{}}export async function migrateNotificationTables(options={}){const dbDriver=getDbDriver(),sql=sqlHelpers(dbDriver),tables=new Set(options.tables??notificationTableNames);if(options.verbose)log.info(`Creating notification tables for ${dbDriver}...`);try{if(tables.has("notifications")){if(options.verbose)log.info("Creating notifications table...");await warnOnShapeMismatch("notifications");await db.unsafe(notificationsTableSql(sql)).execute();await createIndex("CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_id)",dbDriver)}if(tables.has("notification_preferences")){if(options.verbose)log.info("Creating notification_preferences table...");await warnOnShapeMismatch("notification_preferences");await db.unsafe(notificationPreferencesTableSql(sql)).execute();await createIndex("CREATE INDEX IF NOT EXISTS idx_notification_preferences_user ON notification_preferences (user_id)",dbDriver)}if(tables.has("notification_deliveries")){if(options.verbose)log.info("Creating notification deliveries table...");await warnOnShapeMismatch("notification_deliveries");await db.unsafe(notificationDeliveriesTableSql(sql)).execute();await createIndex("CREATE INDEX IF NOT EXISTS idx_notification_deliveries_channel ON notification_deliveries (channel)",dbDriver);await createIndex("CREATE INDEX IF NOT EXISTS idx_notification_deliveries_status ON notification_deliveries (status)",dbDriver)}if(options.verbose)log.success("Notification tables created");return{success:!0}}catch(error){const message=error instanceof Error?error.message:String(error);log.error(`Failed to create notification tables: ${message}`);return{success:!1,error:message}}}export async function ensureNotificationForeignKeys(options={}){if(getDbDriver()==="sqlite")return;for(const table of["notifications","notification_deliveries"]){const statements=[`ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS "${table}_user_id_fk"`,`ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS "${table}_user_id_fkey"`,`ALTER TABLE ${table} ADD CONSTRAINT "${table}_user_id_fk" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE`];for(const statement of statements)try{await db.unsafe(statement).execute()}catch(error){if(options.verbose)log.debug(`[notification-tables] Skipped: ${statement} (${error instanceof Error?error.message:String(error)})`)}}}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/database",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.70.
|
|
5
|
+
"version": "0.70.347",
|
|
6
6
|
"description": "The Stacks database integration.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -65,15 +65,15 @@
|
|
|
65
65
|
"dynamodb-tooling": "^0.3.2"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
|
-
"@stacksjs/cli": "0.70.
|
|
69
|
-
"@stacksjs/config": "0.70.
|
|
70
|
-
"@stacksjs/logging": "0.70.
|
|
71
|
-
"@stacksjs/router": "0.70.
|
|
68
|
+
"@stacksjs/cli": "0.70.347",
|
|
69
|
+
"@stacksjs/config": "0.70.347",
|
|
70
|
+
"@stacksjs/logging": "0.70.347",
|
|
71
|
+
"@stacksjs/router": "0.70.347",
|
|
72
72
|
"better-dx": "^0.2.17",
|
|
73
|
-
"@stacksjs/path": "0.70.
|
|
74
|
-
"@stacksjs/query-builder": "0.70.
|
|
75
|
-
"@stacksjs/storage": "0.70.
|
|
76
|
-
"@stacksjs/strings": "0.70.
|
|
77
|
-
"@stacksjs/utils": "0.70.
|
|
73
|
+
"@stacksjs/path": "0.70.347",
|
|
74
|
+
"@stacksjs/query-builder": "0.70.347",
|
|
75
|
+
"@stacksjs/storage": "0.70.347",
|
|
76
|
+
"@stacksjs/strings": "0.70.347",
|
|
77
|
+
"@stacksjs/utils": "0.70.347"
|
|
78
78
|
}
|
|
79
79
|
}
|