@stacksjs/database 0.70.263 → 0.70.264
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/index.d.ts +1 -0
- package/dist/index.js +1 -1
- package/dist/migrations.js +2 -2
- package/dist/model-sources.d.ts +7 -0
- package/dist/model-sources.js +1 -1
- package/dist/shadowed-models.d.ts +38 -0
- package/dist/shadowed-models.js +2 -0
- package/package.json +10 -10
package/dist/index.d.ts
CHANGED
|
@@ -134,6 +134,7 @@ export * from './migration-ledger';
|
|
|
134
134
|
// Model resolution for the generator: userland + framework defaults, flattened
|
|
135
135
|
// because bun-query-builder's loadModels reads only the top level of a dir.
|
|
136
136
|
export * from './model-sources';
|
|
137
|
+
export * from './shadowed-models';
|
|
137
138
|
// Database bootstrap: probe the target, and create it over a maintenance
|
|
138
139
|
// connection we open ourselves rather than through bun-query-builder, whose
|
|
139
140
|
// connection string is rebuilt from process.env and cannot be redirected.
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export{Database,createDatabase,createMysqlDatabase,createPostgresDatabase,createSqliteDatabase}from"./database";export{detectDriver,driverDefaults,getConfigFromEnv,getConnectionString,mergeWithDefaults,validateDriverConfig}from"./driver-config";export*from"./utils";export*from"./types";export*from"./migrations";export{setQueryTracker,logQuery}from"./query-logger";export{addColumnSafely,backfillInBatches,renameColumnSafely}from"./safe-migrations";export*from"./seeder";export*from"./drivers";export*from"./custom";export*from"./auth-tables";export*from"./uuid-columns";export*from"./managed-columns";export*from"./relation-columns";export{migrateNotificationTables}from"./notification-tables";export{migrateRbacTables}from"./rbac-tables";export*from"./trait-tables";export*from"./datetime-columns";export*from"./dialect";export*from"./replicas";export*from"./ddl-constraints";export*from"./vschema";export*from"./sql-helpers";export*from"./defaults";export*from"./migration-dialect";export*from"./migration-ledger";export*from"./model-sources";export*from"./ensure-database";export{auditForeignKeys,findFkOrphans,getDeclaredFKs,getLiveFKs}from"./fk-audit";export{auditUniqueIndexes,getDeclaredUniques,getLiveUniqueIndexes}from"./unique-audit";export{__flushAfterCommitNow,__pendingAfterCommitCount,enqueueAfterCommit,isInTransaction,runInTransactionScope}from"./transaction-context";export{createQueryBuilder,setConfig}from"@stacksjs/query-builder";export{createDynamo,dynamo,EntityQueryBuilder,generateKeyPattern,parseKeyPattern,buildKey,marshall,unmarshall}from"./drivers/dynamodb";
|
|
1
|
+
export{Database,createDatabase,createMysqlDatabase,createPostgresDatabase,createSqliteDatabase}from"./database";export{detectDriver,driverDefaults,getConfigFromEnv,getConnectionString,mergeWithDefaults,validateDriverConfig}from"./driver-config";export*from"./utils";export*from"./types";export*from"./migrations";export{setQueryTracker,logQuery}from"./query-logger";export{addColumnSafely,backfillInBatches,renameColumnSafely}from"./safe-migrations";export*from"./seeder";export*from"./drivers";export*from"./custom";export*from"./auth-tables";export*from"./uuid-columns";export*from"./managed-columns";export*from"./relation-columns";export{migrateNotificationTables}from"./notification-tables";export{migrateRbacTables}from"./rbac-tables";export*from"./trait-tables";export*from"./datetime-columns";export*from"./dialect";export*from"./replicas";export*from"./ddl-constraints";export*from"./vschema";export*from"./sql-helpers";export*from"./defaults";export*from"./migration-dialect";export*from"./migration-ledger";export*from"./model-sources";export*from"./shadowed-models";export*from"./ensure-database";export{auditForeignKeys,findFkOrphans,getDeclaredFKs,getLiveFKs}from"./fk-audit";export{auditUniqueIndexes,getDeclaredUniques,getLiveUniqueIndexes}from"./unique-audit";export{__flushAfterCommitNow,__pendingAfterCommitCount,enqueueAfterCommit,isInTransaction,runInTransactionScope}from"./transaction-context";export{createQueryBuilder,setConfig}from"@stacksjs/query-builder";export{createDynamo,dynamo,EntityQueryBuilder,generateKeyPattern,parseKeyPattern,buildKey,marshall,unmarshall}from"./drivers/dynamodb";
|
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{frameworkManagedColumns,withoutManagedColumnDrops,withoutManagedColumnDropSql}from"./managed-columns";import{acquireMigrationLock}from"./migration-lock";import{migrateNotificationTables}from"./notification-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()},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 configureQueryBuilder(){const dialect=getDialect(),connectionConfig=dbConfig.connections[dialect];setConfig({dialect:getQbDialect(),vitess:{sharded:isVitessSharded(connectionConfig?.sharded)},verbose:!1,snapshotDir:QB_SNAPSHOT_DIR,database:{database:connectionConfig?.name||connectionConfig?.database||"stacks",host:connectionConfig?.host||"localhost",port:connectionConfig?.port||(dialect==="postgres"?5432:dialect==="mysql"?3306:0),username:connectionConfig?.username||"",password:connectionConfig?.password||""}});resetConnection()}export function prepareMigrationModelsDir(){const sources=resolveModelSources();return{modelsDir:sources?.dir??path.userModelsPath(),skip:!sources}}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{migrateNotificationTables}from"./notification-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()},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 configureQueryBuilder(){const dialect=getDialect(),connectionConfig=dbConfig.connections[dialect];setConfig({dialect:getQbDialect(),vitess:{sharded:isVitessSharded(connectionConfig?.sharded)},verbose:!1,snapshotDir:QB_SNAPSHOT_DIR,database:{database:connectionConfig?.name||connectionConfig?.database||"stacks",host:connectionConfig?.host||"localhost",port:connectionConfig?.port||(dialect==="postgres"?5432:dialect==="mysql"?3306:0),username:connectionConfig?.username||"",password:connectionConfig?.password||""}});resetConnection()}export function prepareMigrationModelsDir(){const sources=resolveModelSources();return{modelsDir:sources?.dir??path.userModelsPath(),skip:!sources}}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};`}
|
|
@@ -25,7 +25,7 @@ Create it yourself with: ${manualCreateHint(target)}`);if(result.created)log.su
|
|
|
25
25
|
JOIN pg_class c ON c.oid = a.attrelid
|
|
26
26
|
WHERE a.atttypid = t.oid AND c.relkind = 'r' AND NOT a.attisdropped
|
|
27
27
|
)
|
|
28
|
-
`).execute(),names=(Array.isArray(rows)?rows:rows?.rows??[]).map((row)=>String(row.name??row.typname??"")).filter(Boolean);for(const name of names)try{await db.unsafe(`DROP TYPE IF EXISTS "${name}" CASCADE`).execute();log.debug(`Dropped orphaned enum type: ${name}`)}catch(error){log.warn(`Could not drop enum type ${name}: ${error instanceof Error?error.message:String(error)}`)}}catch(error){log.warn(`Could not list enum types to drop: ${error instanceof Error?error.message:String(error)}`)}}async function dropFrameworkTables(dialect){if(dialect==="mysql"||dialect==="vitess")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 0").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = OFF").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}for(const tableName of FRAMEWORK_TABLES)try{let dropSql;if(dialect==="postgres")dropSql=`DROP TABLE IF EXISTS "${tableName}" CASCADE`;else if(dialect==="mysql"||dialect==="vitess")dropSql=`DROP TABLE IF EXISTS \`${tableName}\``;else dropSql=`DROP TABLE IF EXISTS "${tableName}"`;log.info(`Dropping framework table: ${tableName}`);await db.unsafe(dropSql).execute();log.info(`Dropped framework table: ${tableName}`)}catch(error){const kind=classifyConnectionError(error);if(kind==="missing-database"||kind==="missing-role"||kind==="auth-failed"||kind==="server-unreachable"||kind==="timeout")throw error;log.warn(`Could not drop table ${tableName}: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="mysql"||dialect==="vitess")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 1").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = ON").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}}function resolveGenerateOptions(options){const applyRenames=options.applyRenames??(process.env.STACKS_MIGRATE_NO_RENAME==="1"?!1:void 0),fromDb=options.fromDb??(process.env.STACKS_MIGRATE_FROM_DB==="1"?!0:void 0);return{applyRenames,fromDb}}export async function previewPendingMigrations(options={}){try{configureQueryBuilder();const dialect=getDialect(),{modelsDir,skip}=prepareMigrationModelsDir();if(skip)return[];const{applyRenames,fromDb}=resolveGenerateOptions(options),operations=(await qbGenerateMigration(modelsDir,{dialect:getQbDialect(),dryRun:!0,applyRenames,fromDb})).operations??[];if(!operations.some((op)=>op.kind==="drop_column"))return operations;return withoutManagedColumnDrops(operations,await frameworkManagedColumns())}catch(error){log.debug(`[migration] preview failed: ${error instanceof Error?error.message:String(error)}`);return[]}}function snapshotDirLabel(){return qbConfig?.snapshotDir||QB_SNAPSHOT_DIR}function resolveSnapshotDir(){return join(process.cwd(),snapshotDirLabel())}function snapshotPathFor(dialect){return join(resolveSnapshotDir(),`model-snapshot.${dialect}.json`)}function readStoredMigrationPlan(dialect){const snapshotPath=snapshotPathFor(dialect);if(!existsSync(snapshotPath))return;let parsed;try{parsed=JSON.parse(readFileSync(snapshotPath,"utf8"))}catch(error){throw Error(`The migration snapshot is not valid JSON: ${snapshotPath}. Repair or regenerate it before creating migrations.`,{cause:error})}const candidate=parsed&&typeof parsed==="object"&&"plan"in parsed?parsed.plan:parsed;if(!candidate||typeof candidate!=="object"||!Array.isArray(candidate.tables))throw TypeError(`The migration snapshot has an invalid structure: ${snapshotPath}. Repair or regenerate it before creating migrations.`);return candidate}export function preserveMigrationPlanTableOrder(next,previous){const orderedByPrevious=(values,previousValues,keyFor)=>{if(!previousValues)return[...values];const previousOrder=new Map(previousValues.map((value,index)=>[keyFor(value),index])),nextOrder=new Map(values.map((value,index)=>[keyFor(value),index])),previousCount=previousValues.length;return[...values].sort((left,right)=>{const leftKey=keyFor(left),rightKey=keyFor(right),leftOrder=previousOrder.get(leftKey)??previousCount+nextOrder.get(leftKey),rightOrder=previousOrder.get(rightKey)??previousCount+nextOrder.get(rightKey);return leftOrder-rightOrder})},previousTables=new Map(previous?.tables.map((table)=>[table.table,table])??[]),tables=next.tables.map((table)=>{const previousTable=previousTables.get(table.table),previousColumns=new Map(previousTable?.columns.map((column)=>[column.name,column])??[]);return{...table,columns:orderedByPrevious(table.columns.map((column)=>{if(next.dialect!=="sqlite"||!column.enumTypeName||previousColumns.get(column.name)?.enumTypeName)return column;const portableColumn={...column};delete portableColumn.enumTypeName;return portableColumn}),previousTable?.columns,(column)=>column.name),indexes:orderedByPrevious(table.indexes,previousTable?.indexes,(index)=>index.name)}});return{...next,tables:orderedByPrevious(tables,previous?.tables,(table)=>table.table)}}function detectSnapshotDialectMismatch(dialect){const qbDir=resolveSnapshotDir();let files;try{files=readdirSync(qbDir)}catch{return null}const snapshotFor=(d)=>`model-snapshot.${d}.json`;if(files.includes(snapshotFor(dialect)))return null;for(const f of files){const m=/^model-snapshot\.(\w+)\.json$/.exec(f);if(m?.[1]&&m[1]!==dialect)return m[1]}return null}export async function generateMigrations(options={}){try{log.debug("Generating migrations...");configureQueryBuilder();const dialect=getDialect(),mismatch=detectSnapshotDialectMismatch(dialect);if(mismatch){const snapshotDir=snapshotDirLabel();return err(Error(`Refusing to generate migrations: resolved dialect "${dialect}" has no snapshot in ${snapshotDir}/, but "${mismatch}" does. DB_CONNECTION is likely unset or wrong `+"(missing .env?) \u2014 generating now would write a full duplicate migration set in the "+`wrong dialect. Set DB_CONNECTION=${mismatch} (or your intended dialect) and retry. To intentionally start a new dialect from scratch, remove ${snapshotDir}/model-snapshot.${mismatch}.json first.`))}const storedPlan=readStoredMigrationPlan(getQbDialect()),{modelsDir,skip}=prepareMigrationModelsDir();if(skip){log.debug("No app/Models directory found; using committed framework migrations");return ok("Migrations generated")}const{applyRenames,fromDb}=resolveGenerateOptions(options);log.debug(`[migration] Generating migrations for dialect: ${dialect}, models: ${modelsDir}`);const result=await qbGenerateMigration(modelsDir,{dialect:getQbDialect(),dryRun:!0,applyRenames,fromDb});let sqlStatements=result.sqlStatements??[];if(dialect==="sqlite")sqlStatements=inlineSqliteAddedColumnReferences(sqlStatements,result.plan);if(result.hasChanges&&sqlStatements.length>0){const filtered=withoutManagedColumnDropSql(sqlStatements,await frameworkManagedColumns(),result.operations??[]);if(filtered.removed.length>0)log.debug(`[migration] Skipped ${filtered.removed.length} generated drop(s) of framework-managed column(s) (stacksjs/stacks#2075)`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0){const dangling=findDanglingTypeReferences(sqlStatements);if(dangling.length>0){const created=createMissingEnumTypes(dangling,result.plan);if(created.statements.length>0){sqlStatements=[...created.statements,...sqlStatements];log.debug(`[migration] Created ${created.statements.length} enum type(s) an ALTER needed: ${created.defined.join(", ")}`)}const unresolved=dangling.filter((name)=>!created.defined.includes(name));if(unresolved.length>0){const before=sqlStatements.length;sqlStatements=sqlStatements.filter((statement)=>!referencesUndefinedType(statement,unresolved));log.warn(`[migration] Skipped ${before-sqlStatements.length} generated statement(s) referencing enum type(s) nothing creates and no model defines values for (${unresolved.slice(0,3).join(", ")}${unresolved.length>3?", \u2026":""}).`)}}}if(result.hasChanges){const written=persistGeneratedMigrations(sqlStatements);if(written>0)log.success(`Generated ${written} migration file${written===1?"":"s"}`);else log.debug("Migration generation produced no new files (already up to date)")}else log.debug("No changes detected");const stablePlan=preserveMigrationPlanTableOrder(result.plan,storedPlan);if(!storedPlan||JSON.stringify(stablePlan)!==JSON.stringify(storedPlan))saveMigrationSnapshot(stablePlan,{dialect:getQbDialect()});else log.debug("Model snapshot unchanged");return ok("Migrations generated")}catch(error){return err(handleError("Migration generation failed",error))}}export function referencesUndefinedType(statement,dangling){return dangling.some((name)=>statement.includes(`"${name}"`))}export function findDanglingTypeReferences(statements){const defined=new Set,referenced=new Set;for(const statement of statements){for(const match of statement.matchAll(/CREATE\s+TYPE\s+"([^"]+)"/gi))defined.add(match[1]);for(const match of statement.matchAll(/\bTYPE\s+"([^"]+)"/gi))referenced.add(match[1])}return[...referenced].filter((name)=>!defined.has(name)).sort()}export function createMissingEnumTypes(dangling,plan){if(dangling.length===0)return{statements:[],defined:[]};const values=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){if(!column.name||!column.enumValues?.length)continue;values.set(`${table.table}_${column.name}_type`,column.enumValues)}}const statements=[],defined=[];for(const name of dangling){const members=values.get(name);if(!members?.length)continue;const literals=members.map((member)=>`'${String(member).replaceAll("'","''")}'`).join(", "),guarded=guardPostgresEnumTypes(`CREATE TYPE "${name}" AS ENUM (${literals})`);statements.push(guarded.endsWith(";")?guarded:`${guarded};`);defined.push(name)}return{statements,defined}}export function inlineSqliteAddedColumnReferences(statements,plan){const references=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){const reference=column.references;if(!column.name||!reference?.table||!reference.column)continue;references.set(`${table.table}.${column.name}`,{table:reference.table,column:reference.column})}}if(references.size===0)return statements;return statements.map((statement)=>{if(/\bREFERENCES\b/i.test(statement))return statement;const match=statement.match(/^(\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+COLUMN\s+["`]?(\w+)["`]?\s+)([\s\S]*?)(;?\s*)$/i),reference=match?.[2]&&match[3]?references.get(`${match[2]}.${match[3]}`):void 0;if(!match||!reference)return statement;return`${match[1]}${match[4].trimEnd()} REFERENCES "${reference.table}"("${reference.column}")${match[5]}`})}export async function regenerateMigrationCorpus(options={}){try{configureQueryBuilder();const dialect=options.dialect??getQbDialect(),dir=options.dir??join(process.cwd(),"database","migrations"),sources=resolveModelSources();if(!sources)return err(Error("No models found. Define models in app/Models, or ensure the framework defaults at storage/framework/defaults/app/Models are present."));try{writeFileSync(join(sources.dir,`.qb-migrations.${dialect}.json`),JSON.stringify({plan:{dialect,tables:[]}}))}catch{}const snapshotPath=join(resolveSnapshotDir(),`model-snapshot.${dialect}.json`),parkedSnapshot=`${snapshotPath}.regenerating`;let snapshotParked=!1;if(existsSync(snapshotPath))try{renameSync(snapshotPath,parkedSnapshot);snapshotParked=!0}catch{}let result;try{result=await qbGenerateMigration(sources.dir,{dialect,dryRun:!0})}finally{if(snapshotParked)try{renameSync(parkedSnapshot,snapshotPath)}catch{}}const statements=result.sqlStatements??[];if(statements.length===0)return err(Error(`The generator produced no SQL for dialect "${dialect}".`));const dangling=findDanglingTypeReferences(statements);if(dangling.length>0)return err(Error(`The generator emitted ${dangling.length} reference(s) to enum type(s) it never creates: ${dangling.slice(0,5).join(", ")}${dangling.length>5?`, +${dangling.length-5} more`:""}. Writing this corpus would fail partway through a migration. This is a bug in the migration generator, not in your models.`));const groups=groupGeneratedStatements(statements);let existing=[];try{existing=readdirSync(dir).filter((f)=>f.endsWith(".sql")).sort()}catch{}const files=groups.map((group,index)=>({name:`${String(index+1).padStart(10,"0")}-${group.label}.sql`,statements:group.statements.length}));if(options.dryRun)return ok({dialect,models:sources.models.length,files,removed:existing,dir});mkdirSync(dir,{recursive:!0});for(const file of existing)unlinkSync(join(dir,file));groups.forEach((group,index)=>{const body=`${group.statements.map((s)=>s.trim().replace(/;\s*$/,"")).join(`;
|
|
28
|
+
`).execute(),names=(Array.isArray(rows)?rows:rows?.rows??[]).map((row)=>String(row.name??row.typname??"")).filter(Boolean);for(const name of names)try{await db.unsafe(`DROP TYPE IF EXISTS "${name}" CASCADE`).execute();log.debug(`Dropped orphaned enum type: ${name}`)}catch(error){log.warn(`Could not drop enum type ${name}: ${error instanceof Error?error.message:String(error)}`)}}catch(error){log.warn(`Could not list enum types to drop: ${error instanceof Error?error.message:String(error)}`)}}async function dropFrameworkTables(dialect){if(dialect==="mysql"||dialect==="vitess")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 0").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = OFF").execute()}catch(error){log.warn(`Could not disable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}for(const tableName of FRAMEWORK_TABLES)try{let dropSql;if(dialect==="postgres")dropSql=`DROP TABLE IF EXISTS "${tableName}" CASCADE`;else if(dialect==="mysql"||dialect==="vitess")dropSql=`DROP TABLE IF EXISTS \`${tableName}\``;else dropSql=`DROP TABLE IF EXISTS "${tableName}"`;log.info(`Dropping framework table: ${tableName}`);await db.unsafe(dropSql).execute();log.info(`Dropped framework table: ${tableName}`)}catch(error){const kind=classifyConnectionError(error);if(kind==="missing-database"||kind==="missing-role"||kind==="auth-failed"||kind==="server-unreachable"||kind==="timeout")throw error;log.warn(`Could not drop table ${tableName}: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="mysql"||dialect==="vitess")try{await db.unsafe("SET FOREIGN_KEY_CHECKS = 1").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}if(dialect==="sqlite")try{await db.unsafe("PRAGMA foreign_keys = ON").execute()}catch(error){log.warn(`Could not re-enable foreign key checks: ${error instanceof Error?error.message:String(error)}`)}}function resolveGenerateOptions(options){const applyRenames=options.applyRenames??(process.env.STACKS_MIGRATE_NO_RENAME==="1"?!1:void 0),fromDb=options.fromDb??(process.env.STACKS_MIGRATE_FROM_DB==="1"?!0:void 0);return{applyRenames,fromDb}}export async function previewPendingMigrations(options={}){try{configureQueryBuilder();const dialect=getDialect(),{modelsDir,skip}=prepareMigrationModelsDir();if(skip)return[];const{applyRenames,fromDb}=resolveGenerateOptions(options),operations=(await qbGenerateMigration(modelsDir,{dialect:getQbDialect(),dryRun:!0,applyRenames,fromDb})).operations??[];if(!operations.some((op)=>op.kind==="drop_column"))return operations;return withoutManagedColumnDrops(operations,await frameworkManagedColumns())}catch(error){log.debug(`[migration] preview failed: ${error instanceof Error?error.message:String(error)}`);return[]}}function snapshotDirLabel(){return qbConfig?.snapshotDir||QB_SNAPSHOT_DIR}function resolveSnapshotDir(){return join(process.cwd(),snapshotDirLabel())}function snapshotPathFor(dialect){return join(resolveSnapshotDir(),`model-snapshot.${dialect}.json`)}function readStoredMigrationPlan(dialect){const snapshotPath=snapshotPathFor(dialect);if(!existsSync(snapshotPath))return;let parsed;try{parsed=JSON.parse(readFileSync(snapshotPath,"utf8"))}catch(error){throw Error(`The migration snapshot is not valid JSON: ${snapshotPath}. Repair or regenerate it before creating migrations.`,{cause:error})}const candidate=parsed&&typeof parsed==="object"&&"plan"in parsed?parsed.plan:parsed;if(!candidate||typeof candidate!=="object"||!Array.isArray(candidate.tables))throw TypeError(`The migration snapshot has an invalid structure: ${snapshotPath}. Repair or regenerate it before creating migrations.`);return candidate}export function preserveMigrationPlanTableOrder(next,previous){const orderedByPrevious=(values,previousValues,keyFor)=>{if(!previousValues)return[...values];const previousOrder=new Map(previousValues.map((value,index)=>[keyFor(value),index])),nextOrder=new Map(values.map((value,index)=>[keyFor(value),index])),previousCount=previousValues.length;return[...values].sort((left,right)=>{const leftKey=keyFor(left),rightKey=keyFor(right),leftOrder=previousOrder.get(leftKey)??previousCount+nextOrder.get(leftKey),rightOrder=previousOrder.get(rightKey)??previousCount+nextOrder.get(rightKey);return leftOrder-rightOrder})},previousTables=new Map(previous?.tables.map((table)=>[table.table,table])??[]),tables=next.tables.map((table)=>{const previousTable=previousTables.get(table.table),previousColumns=new Map(previousTable?.columns.map((column)=>[column.name,column])??[]);return{...table,columns:orderedByPrevious(table.columns.map((column)=>{if(next.dialect!=="sqlite"||!column.enumTypeName||previousColumns.get(column.name)?.enumTypeName)return column;const portableColumn={...column};delete portableColumn.enumTypeName;return portableColumn}),previousTable?.columns,(column)=>column.name),indexes:orderedByPrevious(table.indexes,previousTable?.indexes,(index)=>index.name)}});return{...next,tables:orderedByPrevious(tables,previous?.tables,(table)=>table.table)}}function detectSnapshotDialectMismatch(dialect){const qbDir=resolveSnapshotDir();let files;try{files=readdirSync(qbDir)}catch{return null}const snapshotFor=(d)=>`model-snapshot.${d}.json`;if(files.includes(snapshotFor(dialect)))return null;for(const f of files){const m=/^model-snapshot\.(\w+)\.json$/.exec(f);if(m?.[1]&&m[1]!==dialect)return m[1]}return null}export async function generateMigrations(options={}){try{log.debug("Generating migrations...");configureQueryBuilder();const dialect=getDialect(),mismatch=detectSnapshotDialectMismatch(dialect);if(mismatch){const snapshotDir=snapshotDirLabel();return err(Error(`Refusing to generate migrations: resolved dialect "${dialect}" has no snapshot in ${snapshotDir}/, but "${mismatch}" does. DB_CONNECTION is likely unset or wrong `+"(missing .env?) \u2014 generating now would write a full duplicate migration set in the "+`wrong dialect. Set DB_CONNECTION=${mismatch} (or your intended dialect) and retry. To intentionally start a new dialect from scratch, remove ${snapshotDir}/model-snapshot.${mismatch}.json first.`))}const storedPlan=readStoredMigrationPlan(getQbDialect()),{modelsDir,skip}=prepareMigrationModelsDir();if(skip){log.debug("No app/Models directory found; using committed framework migrations");return ok("Migrations generated")}const{applyRenames,fromDb}=resolveGenerateOptions(options);log.debug(`[migration] Generating migrations for dialect: ${dialect}, models: ${modelsDir}`);const result=await qbGenerateMigration(modelsDir,{dialect:getQbDialect(),dryRun:!0,applyRenames,fromDb});let sqlStatements=result.sqlStatements??[];if(dialect==="sqlite")sqlStatements=inlineSqliteAddedColumnReferences(sqlStatements,result.plan);if(result.hasChanges&&sqlStatements.length>0){const filtered=withoutManagedColumnDropSql(sqlStatements,await frameworkManagedColumns(),result.operations??[]);if(filtered.removed.length>0)log.debug(`[migration] Skipped ${filtered.removed.length} generated drop(s) of framework-managed column(s) (stacksjs/stacks#2075)`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0){const dangling=findDanglingTypeReferences(sqlStatements);if(dangling.length>0){const created=createMissingEnumTypes(dangling,result.plan);if(created.statements.length>0){sqlStatements=[...created.statements,...sqlStatements];log.debug(`[migration] Created ${created.statements.length} enum type(s) an ALTER needed: ${created.defined.join(", ")}`)}const unresolved=dangling.filter((name)=>!created.defined.includes(name));if(unresolved.length>0){const before=sqlStatements.length;sqlStatements=sqlStatements.filter((statement)=>!referencesUndefinedType(statement,unresolved));log.warn(`[migration] Skipped ${before-sqlStatements.length} generated statement(s) referencing enum type(s) nothing creates and no model defines values for (${unresolved.slice(0,3).join(", ")}${unresolved.length>3?", \u2026":""}).`)}}}if(result.hasChanges&&sqlStatements.length>0&&!shadowDropsAllowed()){const shadowed=resolveModelSources()?.shadowed??[],drops=findShadowedColumnDrops(sqlStatements,shadowed);if(drops.length>0)return err(Error(shadowedDropMessage(drops)))}if(result.hasChanges){const written=persistGeneratedMigrations(sqlStatements);if(written>0)log.success(`Generated ${written} migration file${written===1?"":"s"}`);else log.debug("Migration generation produced no new files (already up to date)")}else log.debug("No changes detected");const stablePlan=preserveMigrationPlanTableOrder(result.plan,storedPlan);if(!storedPlan||JSON.stringify(stablePlan)!==JSON.stringify(storedPlan))saveMigrationSnapshot(stablePlan,{dialect:getQbDialect()});else log.debug("Model snapshot unchanged");return ok("Migrations generated")}catch(error){return err(handleError("Migration generation failed",error))}}export function referencesUndefinedType(statement,dangling){return dangling.some((name)=>statement.includes(`"${name}"`))}export function findDanglingTypeReferences(statements){const defined=new Set,referenced=new Set;for(const statement of statements){for(const match of statement.matchAll(/CREATE\s+TYPE\s+"([^"]+)"/gi))defined.add(match[1]);for(const match of statement.matchAll(/\bTYPE\s+"([^"]+)"/gi))referenced.add(match[1])}return[...referenced].filter((name)=>!defined.has(name)).sort()}export function createMissingEnumTypes(dangling,plan){if(dangling.length===0)return{statements:[],defined:[]};const values=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){if(!column.name||!column.enumValues?.length)continue;values.set(`${table.table}_${column.name}_type`,column.enumValues)}}const statements=[],defined=[];for(const name of dangling){const members=values.get(name);if(!members?.length)continue;const literals=members.map((member)=>`'${String(member).replaceAll("'","''")}'`).join(", "),guarded=guardPostgresEnumTypes(`CREATE TYPE "${name}" AS ENUM (${literals})`);statements.push(guarded.endsWith(";")?guarded:`${guarded};`);defined.push(name)}return{statements,defined}}export function inlineSqliteAddedColumnReferences(statements,plan){const references=new Map;for(const table of plan?.tables??[]){if(!table.table)continue;for(const column of table.columns??[]){const reference=column.references;if(!column.name||!reference?.table||!reference.column)continue;references.set(`${table.table}.${column.name}`,{table:reference.table,column:reference.column})}}if(references.size===0)return statements;return statements.map((statement)=>{if(/\bREFERENCES\b/i.test(statement))return statement;const match=statement.match(/^(\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+COLUMN\s+["`]?(\w+)["`]?\s+)([\s\S]*?)(;?\s*)$/i),reference=match?.[2]&&match[3]?references.get(`${match[2]}.${match[3]}`):void 0;if(!match||!reference)return statement;return`${match[1]}${match[4].trimEnd()} REFERENCES "${reference.table}"("${reference.column}")${match[5]}`})}export async function regenerateMigrationCorpus(options={}){try{configureQueryBuilder();const dialect=options.dialect??getQbDialect(),dir=options.dir??join(process.cwd(),"database","migrations"),sources=resolveModelSources();if(!sources)return err(Error("No models found. Define models in app/Models, or ensure the framework defaults at storage/framework/defaults/app/Models are present."));try{writeFileSync(join(sources.dir,`.qb-migrations.${dialect}.json`),JSON.stringify({plan:{dialect,tables:[]}}))}catch{}const snapshotPath=join(resolveSnapshotDir(),`model-snapshot.${dialect}.json`),parkedSnapshot=`${snapshotPath}.regenerating`;let snapshotParked=!1;if(existsSync(snapshotPath))try{renameSync(snapshotPath,parkedSnapshot);snapshotParked=!0}catch{}let result;try{result=await qbGenerateMigration(sources.dir,{dialect,dryRun:!0})}finally{if(snapshotParked)try{renameSync(parkedSnapshot,snapshotPath)}catch{}}const statements=result.sqlStatements??[];if(statements.length===0)return err(Error(`The generator produced no SQL for dialect "${dialect}".`));const dangling=findDanglingTypeReferences(statements);if(dangling.length>0)return err(Error(`The generator emitted ${dangling.length} reference(s) to enum type(s) it never creates: ${dangling.slice(0,5).join(", ")}${dangling.length>5?`, +${dangling.length-5} more`:""}. Writing this corpus would fail partway through a migration. This is a bug in the migration generator, not in your models.`));const groups=groupGeneratedStatements(statements);let existing=[];try{existing=readdirSync(dir).filter((f)=>f.endsWith(".sql")).sort()}catch{}const files=groups.map((group,index)=>({name:`${String(index+1).padStart(10,"0")}-${group.label}.sql`,statements:group.statements.length}));if(options.dryRun)return ok({dialect,models:sources.models.length,files,removed:existing,dir});mkdirSync(dir,{recursive:!0});for(const file of existing)unlinkSync(join(dir,file));groups.forEach((group,index)=>{const body=`${group.statements.map((s)=>s.trim().replace(/;\s*$/,"")).join(`;
|
|
29
29
|
`)};
|
|
30
30
|
`;writeFileSync(join(dir,files[index].name),body)});return ok({dialect,models:sources.models.length,files,removed:existing,dir})}catch(error){return err(handleError("Migration regeneration failed",error))}}function persistGeneratedMigrations(sqlStatements){if(!sqlStatements?.length)return 0;const migrationsDir=join(process.cwd(),"database","migrations");try{require("node:fs").mkdirSync(migrationsDir,{recursive:!0})}catch{}let existingSql="";try{for(const f of readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql")))existingSql+=`
|
|
31
31
|
${readFileSync(join(migrationsDir,f),"utf8")}`}catch{}const normalize=(s)=>s.replace(/\s+/g," ").trim(),haystack=normalize(existingSql),groups=groupGeneratedStatements(sqlStatements);let written=0,cursor=nextMigrationNumber(migrationsDir);for(const group of groups){const fresh=group.statements.filter((stmt)=>!haystack.includes(normalize(stmt)));if(fresh.length===0)continue;const filename=`${String(cursor).padStart(10,"0")}-${group.label}.sql`,filePath=join(migrationsDir,filename),body=`${fresh.map((s)=>s.trim().replace(/;\s*$/,"")).join(`;
|
package/dist/model-sources.d.ts
CHANGED
|
@@ -19,9 +19,16 @@ export declare interface ModelSource {
|
|
|
19
19
|
name: string
|
|
20
20
|
origin: 'user' | 'framework'
|
|
21
21
|
}
|
|
22
|
+
/** A userland model that replaced a framework default of the same name. */
|
|
23
|
+
export declare interface ShadowedModel {
|
|
24
|
+
name: string
|
|
25
|
+
userFile: string
|
|
26
|
+
frameworkFile: string
|
|
27
|
+
}
|
|
22
28
|
export declare interface ResolvedModelSources {
|
|
23
29
|
dir: string
|
|
24
30
|
models: ModelSource[]
|
|
25
31
|
roots: string[]
|
|
26
32
|
staged: boolean
|
|
33
|
+
shadowed: ShadowedModel[]
|
|
27
34
|
}
|
package/dist/model-sources.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{existsSync,lstatSync,mkdirSync,readdirSync,readFileSync,rmSync,symlinkSync,writeFileSync}from"node:fs";import{basename,join}from"node:path";import{path}from"@stacksjs/path";function collectModels(root,origin){if(!existsSync(root))return[];const out=[],walk=(dir)=>{let entries;try{entries=readdirSync(dir,{withFileTypes:!0})}catch{return}for(const entry of entries){const full=join(dir,entry.name);if(entry.isDirectory()){walk(full);continue}if(!entry.name.endsWith(".ts"))continue;if(entry.name.startsWith(".")||entry.name.startsWith("index"))continue;out.push({file:full,name:entry.name.replace(/\.ts$/,""),origin})}};walk(root);return out}export function modelStagingDir(){return path.frameworkRuntimePath("model-sources")}function stage(models){const dir=modelStagingDir();try{rmSync(dir,{recursive:!0,force:!0})}catch{}mkdirSync(dir,{recursive:!0});for(const model of models){const target=join(dir,`${model.name}.ts`);try{symlinkSync(model.file,target)}catch{try{writeFileSync(target,readFileSync(model.file))}catch{}}}return dir}export function resolveModelSources(options={}){const userRoot=options.userRoot??path.userModelsPath(),frameworkRoot=options.frameworkRoot??path.frameworkPath("defaults/app/Models"),user=collectModels(userRoot,"user"),framework=collectModels(frameworkRoot,"framework");if(user.length===0&&framework.length===0)return null;const byName=new Map;for(const model of framework)byName.set(model.name,model);for(const model of user)byName.set(model.name,model)
|
|
1
|
+
import{existsSync,lstatSync,mkdirSync,readdirSync,readFileSync,rmSync,symlinkSync,writeFileSync}from"node:fs";import{basename,join}from"node:path";import{path}from"@stacksjs/path";function collectModels(root,origin){if(!existsSync(root))return[];const out=[],walk=(dir)=>{let entries;try{entries=readdirSync(dir,{withFileTypes:!0})}catch{return}for(const entry of entries){const full=join(dir,entry.name);if(entry.isDirectory()){walk(full);continue}if(!entry.name.endsWith(".ts"))continue;if(entry.name.startsWith(".")||entry.name.startsWith("index"))continue;out.push({file:full,name:entry.name.replace(/\.ts$/,""),origin})}};walk(root);return out}export function modelStagingDir(){return path.frameworkRuntimePath("model-sources")}function stage(models){const dir=modelStagingDir();try{rmSync(dir,{recursive:!0,force:!0})}catch{}mkdirSync(dir,{recursive:!0});for(const model of models){const target=join(dir,`${model.name}.ts`);try{symlinkSync(model.file,target)}catch{try{writeFileSync(target,readFileSync(model.file))}catch{}}}return dir}export function resolveModelSources(options={}){const userRoot=options.userRoot??path.userModelsPath(),frameworkRoot=options.frameworkRoot??path.frameworkPath("defaults/app/Models"),user=collectModels(userRoot,"user"),framework=collectModels(frameworkRoot,"framework");if(user.length===0&&framework.length===0)return null;const byName=new Map;for(const model of framework)byName.set(model.name,model);const shadowed=[];for(const model of user){const replaced=byName.get(model.name);if(replaced?.origin==="framework")shadowed.push({name:model.name,userFile:model.file,frameworkFile:replaced.file});byName.set(model.name,model)}const models=[...byName.values()].sort((a,b)=>a.name.localeCompare(b.name)),roots=[];if(user.length>0)roots.push(userRoot);if(framework.length>0)roots.push(frameworkRoot);const onlyUser=framework.length===0,allFlat=models.every((m)=>basename(join(m.file,".."))===basename(onlyUser?userRoot:frameworkRoot));if(roots.length===1&&allFlat)return{dir:roots[0],models,roots,staged:!1,shadowed};return{dir:stage(models),models,roots,staged:!0,shadowed}}export function cleanupModelStaging(){const dir=modelStagingDir();try{if(existsSync(dir)&&lstatSync(dir).isDirectory())rmSync(dir,{recursive:!0,force:!0})}catch{}}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ShadowedModel } from './model-sources';
|
|
2
|
+
export declare function shadowDropsAllowed(): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* The table a model declares.
|
|
5
|
+
*
|
|
6
|
+
* Read out of the source rather than by importing it. Importing a model at
|
|
7
|
+
* generate time means executing arbitrary userland code to answer a question
|
|
8
|
+
* about a string literal, and the failure mode - a model that throws on import
|
|
9
|
+
* silently disabling the guard - is exactly the sort of quiet hole this exists
|
|
10
|
+
* to close.
|
|
11
|
+
*/
|
|
12
|
+
export declare function declaredTable(file: string): string | null;
|
|
13
|
+
/**
|
|
14
|
+
* Which of the generated statements drop a column from a table that a userland
|
|
15
|
+
* model took over from a framework default.
|
|
16
|
+
*
|
|
17
|
+
* Matched on the *table*, not on the model name. A userland `Release` on its own
|
|
18
|
+
* `repo_releases` table shares nothing with the framework's `releases` and is
|
|
19
|
+
* not this function's business; a userland model that happens to be called
|
|
20
|
+
* something else but lands on `releases` very much is - which is why the
|
|
21
|
+
* framework side is what supplies the table.
|
|
22
|
+
*/
|
|
23
|
+
export declare function findShadowedColumnDrops(statements: readonly string[], shadowed: readonly ShadowedModel[]): ShadowedDrop[];
|
|
24
|
+
/**
|
|
25
|
+
* The message somebody sees, which is the whole point of the guard.
|
|
26
|
+
*
|
|
27
|
+
* It names the columns, because "a destructive change was detected" sends
|
|
28
|
+
* people to read generated SQL, and it names both ways out, because the right
|
|
29
|
+
* one depends on what they were trying to do and only they know that.
|
|
30
|
+
*/
|
|
31
|
+
export declare function shadowedDropMessage(drops: readonly ShadowedDrop[]): string;
|
|
32
|
+
/** The escape hatch, for somebody who means it. */
|
|
33
|
+
export declare const ALLOW_SHADOW_DROPS_ENV: 'STACKS_ALLOW_SHADOW_DROPS';
|
|
34
|
+
export declare interface ShadowedDrop {
|
|
35
|
+
model: string
|
|
36
|
+
table: string
|
|
37
|
+
columns: string[]
|
|
38
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{readFileSync}from"node:fs";import process from"node:process";export const ALLOW_SHADOW_DROPS_ENV="STACKS_ALLOW_SHADOW_DROPS";export function shadowDropsAllowed(){return process.env[ALLOW_SHADOW_DROPS_ENV]==="1"}export function declaredTable(file){let source;try{source=readFileSync(file,"utf8")}catch{return null}const match=/\btable\s*:\s*['"`]([^'"`]+)['"`]/.exec(source);return match?match[1]:null}const DROP_COLUMN=/^\s*ALTER\s+TABLE\s+["`']?([\w.$]+)["`']?\s+DROP\s+(?:COLUMN\s+)?["`']?([\w$]+)["`']?/i;export function findShadowedColumnDrops(statements,shadowed){if(shadowed.length===0||statements.length===0)return[];const byTable=new Map;for(const model of shadowed){const frameworkTable=declaredTable(model.frameworkFile),userTable=declaredTable(model.userFile);if(!frameworkTable||frameworkTable!==userTable)continue;byTable.set(frameworkTable,{model:model.name,columns:[]})}if(byTable.size===0)return[];for(const statement of statements){const match=DROP_COLUMN.exec(statement);if(!match)continue;const entry=byTable.get(match[1]);if(entry&&!entry.columns.includes(match[2]))entry.columns.push(match[2])}return[...byTable.entries()].filter(([,entry])=>entry.columns.length>0).map(([table,entry])=>({model:entry.model,table,columns:entry.columns}))}export function shadowedDropMessage(drops){const lines=drops.map((drop)=>{const columns=drop.columns.map((column)=>`\`${column}\``).join(", ");return` - \`app/Models/${drop.model}.ts\` overrides the framework's \`${drop.model}\` model on the \`${drop.table}\` table, and would drop ${columns}.`}),[first]=drops;return["Refusing to generate migrations: a model in app/Models/ would drop columns from a framework table.","",...lines,"","A userland model replaces a framework default entirely rather than extending it, so every column","the framework declared and yours does not is dropped - while the framework's own code goes on","reading them. The migration applies cleanly and nothing fails until a page that has always worked","stops finding a column.","","Two ways out:","",` 1. Start from the default and add to it: buddy publish:model ${first?.model??"<Name>"}`," That copies the framework's definition into app/Models/, so nothing is lost by writing yours.",""," 2. Give your model its own table, if it is a different thing that happens to share a name.","",`If you truly mean to drop those columns, re-run with ${ALLOW_SHADOW_DROPS_ENV}=1.`].join(`
|
|
2
|
+
`)}
|
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.264",
|
|
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.264",
|
|
69
|
+
"@stacksjs/config": "0.70.264",
|
|
70
|
+
"@stacksjs/logging": "0.70.264",
|
|
71
|
+
"@stacksjs/router": "0.70.264",
|
|
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.264",
|
|
74
|
+
"@stacksjs/query-builder": "0.70.264",
|
|
75
|
+
"@stacksjs/storage": "0.70.264",
|
|
76
|
+
"@stacksjs/strings": "0.70.264",
|
|
77
|
+
"@stacksjs/utils": "0.70.264"
|
|
78
78
|
}
|
|
79
79
|
}
|