@stacksjs/database 0.70.294 → 0.70.297
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.d.ts +109 -7
- package/dist/migrations.js +6 -4
- package/dist/model-sources.d.ts +24 -0
- package/dist/model-sources.js +1 -1
- package/package.json +11 -11
package/dist/migrations.d.ts
CHANGED
|
@@ -1,10 +1,33 @@
|
|
|
1
1
|
import type { MigrationOperation, MigrationPlan } from '@stacksjs/query-builder';
|
|
2
2
|
import type { Result } from '@stacksjs/error-handling';
|
|
3
3
|
export type { MigrationResult as MigrationResultType };
|
|
4
|
-
export declare function prepareMigrationModelsDir(): {
|
|
4
|
+
export declare function prepareMigrationModelsDir(): {
|
|
5
|
+
modelsDir: string
|
|
6
|
+
skip: boolean
|
|
7
|
+
/**
|
|
8
|
+
* Tables owned by framework default models that are out of scope. Reported to
|
|
9
|
+
* the user, so it stays limited to the models — see {@link protectedTables}
|
|
10
|
+
* for what actually suppresses a drop.
|
|
11
|
+
*/
|
|
12
|
+
excludedTables: string[]
|
|
13
|
+
/**
|
|
14
|
+
* Every table the generator has no authority to drop.
|
|
15
|
+
*
|
|
16
|
+
* {@link excludedTables} plus the polymorphic trait tables. The trait tables
|
|
17
|
+
* need saying separately because `declaredTableName` reads a model's OWN
|
|
18
|
+
* table and nothing else: the framework's `Post` declares `taggable_models`
|
|
19
|
+
* and `categorizable_models` as `belongsToMany` pivots, so when `Post` leaves
|
|
20
|
+
* scope those two enter the diff as tables the app deleted, while the models
|
|
21
|
+
* exclusion set never hears about them (stacksjs/stacks#2255).
|
|
22
|
+
*
|
|
23
|
+
* They would be dropped and then recreated empty by `migrateTraitTables()` in
|
|
24
|
+
* the same `buddy migrate` — data loss with no schema change to show for it.
|
|
25
|
+
*/
|
|
26
|
+
protectedTables: string[]
|
|
27
|
+
};
|
|
5
28
|
/**
|
|
6
|
-
* Drop the generated `DROP TABLE`s for
|
|
7
|
-
*
|
|
29
|
+
* Drop the generated `DROP TABLE`s for tables that are simply out of the
|
|
30
|
+
* generator's scope, rather than deleted.
|
|
8
31
|
*
|
|
9
32
|
* The generator diffs the model set against the stored snapshot, so the first
|
|
10
33
|
* run after framework defaults stop being merged (stacksjs/stacks#2220) sees
|
|
@@ -13,11 +36,18 @@ export declare function prepareMigrationModelsDir(): { modelsDir: string, skip:
|
|
|
13
36
|
* table" is not "destroy this table"; the same distinction
|
|
14
37
|
* `withoutManagedColumnDropSql` draws for framework-managed columns.
|
|
15
38
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
39
|
+
* Two kinds of table qualify, both supplied via `prepareMigrationModelsDir`'s
|
|
40
|
+
* `protectedTables`: framework defaults left out of scope, and the polymorphic
|
|
41
|
+
* trait tables, which arrive in the snapshot as pivots of a framework model and
|
|
42
|
+
* would be recreated empty moments later by `migrateTraitTables`
|
|
43
|
+
* (stacksjs/stacks#2255).
|
|
44
|
+
*
|
|
45
|
+
* Self-limiting for the first kind: the snapshot advances to the narrowed model
|
|
46
|
+
* set at the end of the same run, so those drops are never proposed again. A
|
|
47
|
+
* table an app really does want gone is still dropped by hand, the way it
|
|
48
|
+
* always was.
|
|
19
49
|
*/
|
|
20
|
-
export declare function
|
|
50
|
+
export declare function withoutProtectedTableDropSql(statements: string[], protectedTables: readonly string[], operations: readonly MigrationOperation[]): { statements: string[], removed: string[] };
|
|
21
51
|
/**
|
|
22
52
|
* SQLite compatibility preprocessing for migrations.
|
|
23
53
|
*
|
|
@@ -214,6 +244,45 @@ export declare function createMissingEnumTypes(dangling: string[], plan: Migrati
|
|
|
214
244
|
* declare it inline while the column is added.
|
|
215
245
|
*/
|
|
216
246
|
export declare function inlineSqliteAddedColumnReferences(statements: string[], plan: MigrationPlanLike | undefined): string[];
|
|
247
|
+
/** Whether a migration file was emitted by the corpus generator. */
|
|
248
|
+
export declare function isGeneratedMigration(dir: string, file: string): boolean;
|
|
249
|
+
/**
|
|
250
|
+
* The tables a statement ACTS ON — the target of a CREATE/ALTER/DROP TABLE or
|
|
251
|
+
* a CREATE INDEX.
|
|
252
|
+
*
|
|
253
|
+
* Deliberately not "every table the statement mentions". A `REFERENCES users`
|
|
254
|
+
* in a foreign key makes `users` a table the statement depends on, not one it
|
|
255
|
+
* changes, and counting it would tie half the corpus to whatever table the app
|
|
256
|
+
* happens to point its foreign keys at.
|
|
257
|
+
*/
|
|
258
|
+
export declare function tablesOperatedOn(sql: string): string[];
|
|
259
|
+
/** The tables a set of generated statements creates, lowercased. */
|
|
260
|
+
export declare function createdTablesOf(statements: readonly string[]): string[];
|
|
261
|
+
/**
|
|
262
|
+
* Which existing migrations describe tables the incoming corpus does NOT
|
|
263
|
+
* rebuild.
|
|
264
|
+
*
|
|
265
|
+
* This is the predicate `regenerateMigrationCorpus` needs and, until
|
|
266
|
+
* stacksjs/stacks#2255, did not have. #2234 established the rule — never
|
|
267
|
+
* delete a migration this run cannot recreate — and implemented it as "does
|
|
268
|
+
* the file carry the `@generated` marker". That was a sound proxy while the
|
|
269
|
+
* generator's model scope was fixed. Once #2220 made framework defaults a
|
|
270
|
+
* fallback rather than a merge, it stopped being one: in an app with its own
|
|
271
|
+
* `app/Models`, the migrations for `users`, `jobs`, `failed_jobs`, `payments`,
|
|
272
|
+
* `subscribers` and `teams` all carry the marker and are all outside the
|
|
273
|
+
* scope of any rerun. Regenerating deleted them, and the result looked clean —
|
|
274
|
+
* one tidy file per model — right up until the next deploy dropped the users
|
|
275
|
+
* table.
|
|
276
|
+
*
|
|
277
|
+
* So ask the question directly instead of by proxy: does the corpus about to
|
|
278
|
+
* be written contain every table this file touches? A file is recreatable only
|
|
279
|
+
* if it does. Anything else is preserved, whoever wrote it.
|
|
280
|
+
*
|
|
281
|
+
* A file whose SQL names no table at all (a bare `SELECT 1;` stub, a
|
|
282
|
+
* `CREATE TYPE`) is preserved too — the same "cannot prove it is ours" bias
|
|
283
|
+
* `isGeneratedMigration` takes, for the same reason.
|
|
284
|
+
*/
|
|
285
|
+
export declare function migrationsOutsideCorpus(dir: string, files: readonly string[], corpusTables: readonly string[]): string[];
|
|
217
286
|
/**
|
|
218
287
|
* Rebuild the whole migration corpus for one dialect, from the models.
|
|
219
288
|
*
|
|
@@ -228,11 +297,27 @@ export declare function inlineSqliteAddedColumnReferences(statements: string[],
|
|
|
228
297
|
* replaces what is there. Callers are responsible for confirming that with the
|
|
229
298
|
* user, and for refusing when the target database already has migrations
|
|
230
299
|
* recorded against the old filenames.
|
|
300
|
+
*
|
|
301
|
+
* It replaces only files it can recreate — those carrying
|
|
302
|
+
* {@link GENERATED_MIGRATION_MARKER}. Anything else is returned in
|
|
303
|
+
* `preserved` and left on disk. That rule is not advisory: a hand-authored
|
|
304
|
+
* migration has no model behind it, so a rerun can never re-emit it, and
|
|
305
|
+
* deleting one is unrecoverable. Callers wanting the old indiscriminate sweep
|
|
306
|
+
* pass `replaceUnmarked`.
|
|
231
307
|
*/
|
|
232
308
|
export declare function regenerateMigrationCorpus(options?: {
|
|
233
309
|
dialect?: string
|
|
234
310
|
dir?: string
|
|
235
311
|
dryRun?: boolean
|
|
312
|
+
/**
|
|
313
|
+
* Also delete files carrying no `@generated` marker.
|
|
314
|
+
*
|
|
315
|
+
* For a corpus written before the marker existed and known to be entirely
|
|
316
|
+
* generator output — otherwise the first run after upgrading preserves
|
|
317
|
+
* everything and writes the new corpus alongside it. Never the default: it
|
|
318
|
+
* is the behaviour that lost hand-authored migrations.
|
|
319
|
+
*/
|
|
320
|
+
replaceUnmarked?: boolean
|
|
236
321
|
}): Promise<Result<RegeneratedCorpus, Error>>;
|
|
237
322
|
/**
|
|
238
323
|
* Group generated SQL by the migration filename style the runner already
|
|
@@ -245,6 +330,20 @@ export declare function groupGeneratedStatements(sqlStatements: string[]): Gener
|
|
|
245
330
|
* Generate fresh migrations (full regeneration, ignoring previous state)
|
|
246
331
|
*/
|
|
247
332
|
export declare function generateMigrations2(): Promise<Result<string, Error>>;
|
|
333
|
+
/**
|
|
334
|
+
* Header stamped onto every file `regenerateMigrationCorpus` writes.
|
|
335
|
+
*
|
|
336
|
+
* It is the only thing that distinguishes a file the generator can recreate
|
|
337
|
+
* from one a person wrote. Without it, "rebuild the corpus from the models"
|
|
338
|
+
* deleted migrations that no model describes and no rerun could ever bring
|
|
339
|
+
* back — a `DROP COLUMN` enforcing a privacy invariant, say
|
|
340
|
+
* (stacksjs/stacks#2234).
|
|
341
|
+
*
|
|
342
|
+
* A leading comment is safe to prepend: `sqlStatementsOf` strips comments
|
|
343
|
+
* before splitting on `;`, precisely so a file that opens with a header does
|
|
344
|
+
* not glue it onto the first statement.
|
|
345
|
+
*/
|
|
346
|
+
export declare const GENERATED_MIGRATION_MARKER: '-- @generated by `buddy migrate:regenerate` — edits will be overwritten';
|
|
248
347
|
/*` definitions to the stored snapshot
|
|
249
348
|
* (`.qb/model-snapshot.<dialect>.json`) via bun-query-builder, then — if
|
|
250
349
|
* there are changes — writes the resulting ALTER/CREATE/DROP statements
|
|
@@ -279,8 +378,11 @@ declare interface MigrationPlanLike {
|
|
|
279
378
|
export declare interface RegeneratedCorpus {
|
|
280
379
|
dialect: string
|
|
281
380
|
models: number
|
|
381
|
+
modelRoots: string[]
|
|
282
382
|
files: Array<{ name: string, statements: number }>
|
|
283
383
|
removed: string[]
|
|
384
|
+
preserved: string[]
|
|
385
|
+
preservedOutOfScope: string[]
|
|
284
386
|
dir: string
|
|
285
387
|
}
|
|
286
388
|
declare interface GeneratedGroup {
|
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{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(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();return{modelsDir:sources?.dir??path.userModelsPath(),skip:!sources,excludedTables:
|
|
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{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(`
|
|
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,12 +25,14 @@ 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,excludedTables}=prepareMigrationModelsDir();if(skip)return[];const{applyRenames,fromDb}=resolveGenerateOptions(options),qbDialect=getQbDialect();let operations=(await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,dryRun:!0,applyRenames,fromDb})).operations??[];if(excludedTables.length>0){const excluded=new Set(excludedTables.map((table)=>table.toLowerCase()));operations=operations.filter((op)=>!(op.kind==="drop_table"&&excluded.has(op.table.toLowerCase())))}if(!operations.some((op)=>op.kind==="drop_column"))return operations;return withoutManagedColumnDrops(operations,await frameworkManagedColumns())}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),flatMigrationDir=join(process.cwd(),"database","migrations");if(mismatch&&migrationDirectory(dialect)===flatMigrationDir){const snapshotDir=snapshotDirLabel();return err(Error(`Refusing to generate migrations: resolved dialect "${dialect}" has no snapshot in ${snapshotDir}/, but "${mismatch}" does. DB_CONNECTION is likely unset or wrong `+"(missing .env?) \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,excludedTables}=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}`);if(excludedTables.length>0)log.debug(`[migration] ${excludedTables.length} framework default model(s) out of scope because app/Models defines this app's schema. Enable database.models.includeFrameworkDefaults (or STACKS_INCLUDE_FRAMEWORK_MODELS=1) to generate them too.`);const qbDialect=getQbDialect(),result=await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,dryRun:!0,applyRenames,fromDb});let sqlStatements=result.sqlStatements??[];if(dialect==="sqlite")sqlStatements=inlineSqliteAddedColumnReferences(sqlStatements,result.plan);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&&excludedTables.length>0){const filtered=withoutExcludedTableDropSql(sqlStatements,excludedTables,result.operations??[]);if(filtered.removed.length>0)log.info(`[migration] Left ${filtered.removed.length} framework-default table(s) in place rather than dropping them. They are no longer generated because app/Models defines this app's schema; the tables and their data are untouched. Set database.models.includeFrameworkDefaults to keep generating them (stacksjs/stacks#2220).`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0){const dangling=findDanglingTypeReferences(sqlStatements);if(dangling.length>0){const created=createMissingEnumTypes(dangling,result.plan);if(created.statements.length>0){sqlStatements=[...created.statements,...sqlStatements];log.debug(`[migration] Created ${created.statements.length} enum type(s) an ALTER needed: ${created.defined.join(", ")}`)}const unresolved=dangling.filter((name)=>!created.defined.includes(name));if(unresolved.length>0){const before=sqlStatements.length;sqlStatements=sqlStatements.filter((statement)=>!referencesUndefinedType(statement,unresolved));log.warn(`[migration] Skipped ${before-sqlStatements.length} generated statement(s) referencing enum type(s) nothing creates and no model defines values for (${unresolved.slice(0,3).join(", ")}${unresolved.length>3?", \u2026":""}).`)}}}if(result.hasChanges&&sqlStatements.length>0&&!shadowDropsAllowed()){const shadowed=resolveModelSources()?.shadowed??[],drops=findShadowedColumnDrops(sqlStatements,shadowed);if(drops.length>0)return err(Error(shadowedDropMessage(drops)))}if(result.hasChanges){const written=persistGeneratedMigrations(sqlStatements);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{const dialect=options.dialect??getQbDialect();let requestedVitessSharded;if(dialect==="vitess")try{const{config,overridesReady}=await import("@stacksjs/config");await overridesReady;requestedVitessSharded=isVitessSharded(config?.database?.connections?.vitess?.sharded)}catch{requestedVitessSharded=isVitessSharded(dbConfig.connections.vitess.sharded)}configureQueryBuilder(dialect,requestedVitessSharded);const dir=options.dir??migrationDirectory(dialect),sources=resolveModelSources();if(!sources)return err(Error("No models found. Define models in app/Models, or ensure the framework defaults at storage/framework/defaults/app/Models are present."));try{writeFileSync(join(sources.dir,`.qb-migrations.${dialect}.json`),JSON.stringify({plan:{dialect,tables:[]}}))}catch{}const snapshotPath=join(resolveSnapshotDir(),`model-snapshot.${dialect}.json`),parkedSnapshot=`${snapshotPath}.regenerating`;let snapshotParked=!1;if(existsSync(snapshotPath))try{renameSync(snapshotPath,parkedSnapshot);snapshotParked=!0}catch{}let result;try{result=await qbGenerateMigration(sources.dir,{dialect,vitessSharded:requestedVitessSharded,dryRun:!0})}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,protectedTables}=prepareMigrationModelsDir();if(skip)return[];const{applyRenames,fromDb}=resolveGenerateOptions(options),qbDialect=getQbDialect();let operations=(await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,dryRun:!0,applyRenames,fromDb})).operations??[];if(protectedTables.length>0){const excluded=new Set(protectedTables.map((table)=>table.toLowerCase()));operations=operations.filter((op)=>!(op.kind==="drop_table"&&excluded.has(op.table.toLowerCase())))}if(!operations.some((op)=>op.kind==="drop_column"))return operations;return withoutManagedColumnDrops(operations,await frameworkManagedColumns())}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),flatMigrationDir=join(process.cwd(),"database","migrations");if(mismatch&&migrationDirectory(dialect)===flatMigrationDir){const snapshotDir=snapshotDirLabel();return err(Error(`Refusing to generate migrations: resolved dialect "${dialect}" has no snapshot in ${snapshotDir}/, but "${mismatch}" does. DB_CONNECTION is likely unset or wrong `+"(missing .env?) \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,excludedTables,protectedTables}=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}`);if(excludedTables.length>0)log.debug(`[migration] ${excludedTables.length} framework default model(s) out of scope because app/Models defines this app's schema. Enable database.models.includeFrameworkDefaults (or STACKS_INCLUDE_FRAMEWORK_MODELS=1) to generate them too.`);const qbDialect=getQbDialect(),result=await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,dryRun:!0,applyRenames,fromDb});let sqlStatements=result.sqlStatements??[];if(dialect==="sqlite")sqlStatements=inlineSqliteAddedColumnReferences(sqlStatements,result.plan);if(result.hasChanges&&sqlStatements.length>0){const filtered=withoutManagedColumnDropSql(sqlStatements,await frameworkManagedColumns(),result.operations??[]);if(filtered.removed.length>0)log.debug(`[migration] Skipped ${filtered.removed.length} generated drop(s) of framework-managed column(s) (stacksjs/stacks#2075)`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0&&protectedTables.length>0){const filtered=withoutProtectedTableDropSql(sqlStatements,protectedTables,result.operations??[]);if(filtered.removed.length>0)log.info(`[migration] Left ${filtered.removed.length} framework-owned table(s) in place rather than dropping them. They are no longer generated because app/Models defines this app's schema; the tables and their data are untouched. Set database.models.includeFrameworkDefaults to keep generating them (stacksjs/stacks#2220).`);sqlStatements=filtered.statements}if(result.hasChanges&&sqlStatements.length>0){const dangling=findDanglingTypeReferences(sqlStatements);if(dangling.length>0){const created=createMissingEnumTypes(dangling,result.plan);if(created.statements.length>0){sqlStatements=[...created.statements,...sqlStatements];log.debug(`[migration] Created ${created.statements.length} enum type(s) an ALTER needed: ${created.defined.join(", ")}`)}const unresolved=dangling.filter((name)=>!created.defined.includes(name));if(unresolved.length>0){const before=sqlStatements.length;sqlStatements=sqlStatements.filter((statement)=>!referencesUndefinedType(statement,unresolved));log.warn(`[migration] Skipped ${before-sqlStatements.length} generated statement(s) referencing enum type(s) nothing creates and no model defines values for (${unresolved.slice(0,3).join(", ")}${unresolved.length>3?", \u2026":""}).`)}}}if(result.hasChanges&&sqlStatements.length>0&&!shadowDropsAllowed()){const shadowed=resolveModelSources()?.shadowed??[],drops=findShadowedColumnDrops(sqlStatements,shadowed);if(drops.length>0)return err(Error(shadowedDropMessage(drops)))}if(result.hasChanges){const written=persistGeneratedMigrations(sqlStatements);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 const GENERATED_MIGRATION_MARKER="-- @generated by `buddy migrate:regenerate` \u2014 edits will be overwritten";export function isGeneratedMigration(dir,file){try{return readFileSync(join(dir,file),"utf8").slice(0,200).includes("@generated by `buddy migrate:regenerate`")}catch{return!1}}export function tablesOperatedOn(sql){const tables=new Set;for(const statement of sqlStatementsOf(sql)){const stmt=statement.trim(),direct=stmt.match(/^(?:CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?|ALTER\s+TABLE|DROP\s+TABLE(?:\s+IF\s+EXISTS)?|TRUNCATE\s+TABLE)\s+["'`]?(\w+)["'`]?/i);if(direct?.[1]){tables.add(direct[1].toLowerCase());continue}const index=stmt.match(/^CREATE\s+(?:UNIQUE\s+)?INDEX(?:\s+IF\s+NOT\s+EXISTS)?\s+\S+\s+ON\s+["'`]?(\w+)["'`]?/i);if(index?.[1])tables.add(index[1].toLowerCase())}return[...tables]}export function createdTablesOf(statements){const tables=new Set;for(const statement of statements){const match=statement.trim().match(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i);if(match?.[1])tables.add(match[1].toLowerCase())}return[...tables]}export function migrationsOutsideCorpus(dir,files,corpusTables){const rebuilt=new Set(corpusTables.map((table)=>table.toLowerCase()));return files.filter((file)=>{let contents;try{contents=readFileSync(join(dir,file),"utf8")}catch{return!0}const touched=tablesOperatedOn(contents);if(touched.length===0)return!0;return touched.some((table)=>!rebuilt.has(table))})}function migrationOrdinal(file){const match=file.match(/^(\d+)/);return match?Number(match[1]):0}export async function regenerateMigrationCorpus(options={}){try{const dialect=options.dialect??getQbDialect();let requestedVitessSharded;if(dialect==="vitess")try{const{config,overridesReady}=await import("@stacksjs/config");await overridesReady;requestedVitessSharded=isVitessSharded(config?.database?.connections?.vitess?.sharded)}catch{requestedVitessSharded=isVitessSharded(dbConfig.connections.vitess.sharded)}configureQueryBuilder(dialect,requestedVitessSharded);const dir=options.dir??migrationDirectory(dialect),sources=resolveModelSources({forceStage:!0});if(!sources)return err(Error("No models found. Define models in app/Models, or ensure the framework defaults at storage/framework/defaults/app/Models are present."));try{writeFileSync(join(sources.dir,`.qb-migrations.${dialect}.json`),JSON.stringify({plan:{dialect,tables:[]}}))}catch{}const snapshotPath=join(resolveSnapshotDir(),`model-snapshot.${dialect}.json`),parkedSnapshot=`${snapshotPath}.regenerating`;let snapshotParked=!1;if(existsSync(snapshotPath))try{renameSync(snapshotPath,parkedSnapshot);snapshotParked=!0}catch{}let result;try{result=await qbGenerateMigration(sources.dir,{dialect,vitessSharded:requestedVitessSharded,dryRun:!0})}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 outOfScope=new Set(migrationsOutsideCorpus(dir,existing,createdTablesOf(statements))),removed=(options.replaceUnmarked?existing:existing.filter((file)=>isGeneratedMigration(dir,file))).filter((file)=>!outOfScope.has(file)),preserved=existing.filter((file)=>!removed.includes(file)),preservedOutOfScope=preserved.filter((file)=>outOfScope.has(file)),startAt=preserved.reduce((max,file)=>Math.max(max,migrationOrdinal(file)),0)+1,files=groups.map((group,index)=>({name:`${String(startAt+index).padStart(10,"0")}-${group.label}.sql`,statements:group.statements.length}));if(options.dryRun)return ok({dialect,models:sources.models.length,modelRoots:sources.roots,files,removed,preserved,preservedOutOfScope,dir});mkdirSync(dir,{recursive:!0});for(const file of removed)unlinkSync(join(dir,file));groups.forEach((group,index)=>{const body=`${group.statements.map((s)=>s.trim().replace(/;\s*$/,"")).join(`;
|
|
29
29
|
`)};
|
|
30
|
-
`;writeFileSync(join(dir,files[index].name)
|
|
30
|
+
`;writeFileSync(join(dir,files[index].name),`${GENERATED_MIGRATION_MARKER}
|
|
31
|
+
${body}`)});return ok({dialect,models:sources.models.length,modelRoots:sources.roots,files,removed,preserved,preservedOutOfScope,dir})}catch(error){return err(handleError("Migration regeneration failed",error))}}function persistGeneratedMigrations(sqlStatements){if(!sqlStatements?.length)return 0;const migrationsDir=migrationDirectory();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
32
|
${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(`;
|
|
32
33
|
`)};
|
|
33
|
-
`;writeFileSync(filePath
|
|
34
|
+
`;writeFileSync(filePath,`${GENERATED_MIGRATION_MARKER}
|
|
35
|
+
${body}`);log.debug(`[migration] Wrote ${filename} (${fresh.length} stmt${fresh.length===1?"":"s"})`);written+=1;cursor+=1}return written}function normalizeCreateStatements(sqlStatements){const creates=[],constraints=[],passthrough=[];for(const raw of sqlStatements){const statement=raw.trim();if(!statement)continue;const create=statement.match(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/i);if(create?.[1]){creates.push({statement,table:create[1]});continue}const constraint=statement.match(/^ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+CONSTRAINT\s+([\s\S]+?);?$/i);if(constraint?.[1]&&constraint[2]){const references=[...constraint[2].matchAll(/REFERENCES\s+["`]?(\w+)["`]?/gi)].flatMap((match)=>match[1]?[match[1]]:[]);constraints.push({body:`CONSTRAINT ${constraint[2].replace(/;\s*$/,"")}`,references,statement,table:constraint[1]});continue}passthrough.push(statement)}if(creates.length===0)return sqlStatements.map((statement)=>statement.trim()).filter(Boolean);const createdTables=new Set(creates.map((create)=>create.table)),createOrder=new Map(creates.map((create,index)=>[create.table,index])),relevantConstraints=constraints.filter((constraint)=>createdTables.has(constraint.table)),unrelatedConstraints=constraints.filter((constraint)=>!createdTables.has(constraint.table)),dependencies=new Map(creates.map((create)=>[create.table,new Set(relevantConstraints.filter((constraint)=>constraint.table===create.table).flatMap((constraint)=>constraint.references).filter((reference)=>reference!==create.table&&createdTables.has(reference)))])),sortTables=(ignoredEdges=new Set)=>{const remaining=new Set(createdTables),sorted=[];while(remaining.size>0){const ready=[...remaining].filter((table)=>[...dependencies.get(table)??[]].every((dependency)=>{return!remaining.has(dependency)||ignoredEdges.has(`${table}->${dependency}`)})).sort((a,b)=>(createOrder.get(a)??0)-(createOrder.get(b)??0));if(ready.length===0)break;for(const table of ready){remaining.delete(table);sorted.push(table)}}return sorted},initiallySorted=sortTables(),cyclicTables=new Set([...createdTables].filter((table)=>!initiallySorted.includes(table))),deferred=relevantConstraints.filter((constraint)=>constraint.references.some((reference)=>{return reference!==constraint.table&&cyclicTables.has(constraint.table)&&cyclicTables.has(reference)})),deferredStatements=new Set(deferred.map((constraint)=>constraint.statement)),ignoredEdges=new Set(deferred.flatMap((constraint)=>constraint.references.map((reference)=>`${constraint.table}->${reference}`))),orderedTables=sortTables(ignoredEdges),byTable=new Map(creates.map((create)=>[create.table,create]));return[...orderedTables.map((table)=>{const create=byTable.get(table),inline=relevantConstraints.filter((constraint)=>constraint.table===table&&!deferredStatements.has(constraint.statement));if(inline.length===0)return create.statement;const closing=create.statement.lastIndexOf(")");if(closing<0)return create.statement;const before=create.statement.slice(0,closing).trimEnd(),after=create.statement.slice(closing);return`${before},
|
|
34
36
|
${inline.map((constraint)=>constraint.body).join(`,
|
|
35
37
|
`)}
|
|
36
38
|
${after}`}),...passthrough,...unrelatedConstraints.map((constraint)=>constraint.statement),...deferred.map((constraint)=>constraint.statement)]}export function groupGeneratedStatements(sqlStatements){const normalizedStatements=normalizeCreateStatements(sqlStatements),groups=new Map,push=(label,stmt)=>{const list=groups.get(label)??[];list.push(stmt);groups.set(label,list)},createdTables=new Set(normalizedStatements.flatMap((raw)=>{const match=raw.trim().match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/i);return match?.[1]?[match[1]]:[]}));for(const raw of normalizedStatements){const stmt=raw.trim();if(!stmt)continue;const create=stmt.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/i);if(create){push(`create-${create[1]}-table`,stmt);continue}if(stmt.match(/^\s*CREATE\s+TYPE\s+/i)){push("create-database-types",stmt);continue}const alter=stmt.match(/^\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+(?:ADD\s+COLUMN\s+["`]?(\w+)["`]?|DROP\s+COLUMN\s+["`]?(\w+)["`]?|ADD\s+CONSTRAINT)/i),alterTable=alter?.[1];if(alter&&alterTable){const isCreateTimeConstraint=createdTables.has(alterTable)&&!alter[2]&&!alter[3];push(isCreateTimeConstraint?"create-foreign-key-constraints":`alter-${alterTable}-columns`,stmt);continue}const idx=stmt.match(/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?\s+ON\s+["`]?(\w+)["`]?/i),idxName=idx?.[1],idxTable=idx?.[2];if(idxName&&idxTable){push(createdTables.has(idxTable)?`create-${idxTable}-table`:`create-${idxName}-index-in-${idxTable}`,stmt);continue}const drop=stmt.match(/^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["`]?(\w+)["`]?/i);if(drop){push(`drop-${drop[1]}-table`,stmt);continue}push("auto-misc",stmt)}return[...groups.entries()].map(([label,statements])=>({label,statements})).sort((a,b)=>Number(b.label==="create-database-types")-Number(a.label==="create-database-types"))}function nextMigrationNumber(migrationsDir){let max=0;try{for(const f of readdirSync(migrationsDir)){const m=f.match(/^(\d+)-/);if(m?.[1])max=Math.max(max,Number.parseInt(m[1],10))}}catch{}return max+1}export async function generateMigrations2(){try{log.info("Generating fresh migrations...");configureQueryBuilder();const dialect=getDialect(),{modelsDir,skip}=prepareMigrationModelsDir();if(skip){log.info("No app/Models directory found; using committed framework migrations");return ok("Migrations generated")}const qbDialect=getQbDialect();await qbGenerateMigration(modelsDir,{dialect:qbDialect,vitessSharded:qbDialect==="vitess"?isVitessSharded(dbConfig.connections.vitess.sharded):void 0,full:!0,dryRun:!0});log.success("Migrations generated");return ok("Migrations generated")}catch(error){return err(handleError("Fresh migration generation failed",error))}}
|
package/dist/model-sources.d.ts
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { config } from '@stacksjs/config';
|
|
2
|
+
import { path } from '@stacksjs/path';
|
|
2
3
|
/**
|
|
3
4
|
* Where the flattened copy lives. Under the framework runtime directory rather
|
|
4
5
|
* than the OS temp dir so it is inspectable when a generation goes wrong, and
|
|
5
6
|
* so it lands on the same filesystem as the source.
|
|
6
7
|
*/
|
|
7
8
|
export declare function modelStagingDir(): string;
|
|
9
|
+
/**
|
|
10
|
+
* Build the flat directory. Symlinks where the platform allows it (cheap, and
|
|
11
|
+
* keeps the file identity obvious when debugging), falling back to a real copy
|
|
12
|
+
* on platforms or filesystems that refuse symlinks.
|
|
13
|
+
*
|
|
14
|
+
* Model files import only from packages (verified across all 62 defaults), so a
|
|
15
|
+
* copy cannot break relative specifiers.
|
|
16
|
+
*/
|
|
17
|
+
declare function stage(models: ModelSource[]): string;
|
|
8
18
|
/**
|
|
9
19
|
* The table a model file declares, matching how the generator derives it:
|
|
10
20
|
* an explicit `table:` wins, otherwise the pluralised snake_case model name.
|
|
@@ -29,6 +39,20 @@ export declare function resolveModelSources(options?: {
|
|
|
29
39
|
frameworkRoot?: string
|
|
30
40
|
/** Force the merge on or off, bypassing config and env. */
|
|
31
41
|
includeFrameworkDefaults?: boolean
|
|
42
|
+
/**
|
|
43
|
+
* Always stage, even when a single flat root could be read directly.
|
|
44
|
+
*
|
|
45
|
+
* For callers that WRITE into the resolved directory. bun-query-builder reads
|
|
46
|
+
* `.qb-migrations.<dialect>.json` from the models directory as a previous
|
|
47
|
+
* state, and `regenerateMigrationCorpus` writes one there to force a full
|
|
48
|
+
* emit. On the fast path that directory is the app's own `app/Models`, so the
|
|
49
|
+
* sentinel landed in the user's source tree and stayed — and a later generate
|
|
50
|
+
* with no model snapshot would read it back as "no tables exist" and re-emit
|
|
51
|
+
* the entire schema. Unreachable before #2220, because an app with models of
|
|
52
|
+
* its own always had two roots to merge and therefore always staged
|
|
53
|
+
* (stacksjs/stacks#2255).
|
|
54
|
+
*/
|
|
55
|
+
forceStage?: boolean
|
|
32
56
|
}): ResolvedModelSources | null;
|
|
33
57
|
/** Remove the staging directory. Safe to call when it was never created. */
|
|
34
58
|
export declare function cleanupModelStaging(): void;
|
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 process from"node:process";import{config}from"@stacksjs/config";import{path}from"@stacksjs/path";import{plural,snakeCase}from"@stacksjs/strings";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 declaredTableName(source){let contents="";try{contents=readFileSync(source.file,"utf8")}catch{}const declared=contents.match(/^\s*table:\s*['"`]([^'"`]+)['"`]/m);if(declared?.[1])return declared[1].toLowerCase();const named=contents.match(/^\s*name:\s*['"`]([^'"`]+)['"`]/m);return plural(snakeCase(named?.[1]??source.name)).toLowerCase()}function shouldIncludeFrameworkDefaults(){const flag=process.env.STACKS_INCLUDE_FRAMEWORK_MODELS;if(flag==="1"||flag==="true")return!0;if(flag==="0"||flag==="false")return!1;return config?.database?.models?.includeFrameworkDefaults===!0}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 useFramework=(options.includeFrameworkDefaults??shouldIncludeFrameworkDefaults())||user.length===0,contributing=useFramework?framework:[],excluded=useFramework?[]:framework,frameworkByName=new Map;for(const model of framework)frameworkByName.set(model.name,model);const shadowed=[];for(const model of user){const replaced=frameworkByName.get(model.name);if(replaced)shadowed.push({name:model.name,userFile:model.file,frameworkFile:replaced.file})}const byName=new Map;for(const model of contributing)byName.set(model.name,model);for(const model of user)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(contributing.length>0)roots.push(frameworkRoot);const excludedTables=[...new Set(excluded.map(declaredTableName))].sort(),onlyUser=contributing.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,excluded,excludedTables};return{dir:stage(models),models,roots,staged:!0,shadowed,excluded,excludedTables}}export function cleanupModelStaging(){const dir=modelStagingDir();try{if(existsSync(dir)&&lstatSync(dir).isDirectory())rmSync(dir,{recursive:!0,force:!0})}catch{}}
|
|
1
|
+
import{existsSync,lstatSync,mkdirSync,readdirSync,readFileSync,rmSync,symlinkSync,writeFileSync}from"node:fs";import{basename,join}from"node:path";import process from"node:process";import{config}from"@stacksjs/config";import{path}from"@stacksjs/path";import{plural,snakeCase}from"@stacksjs/strings";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 declaredTableName(source){let contents="";try{contents=readFileSync(source.file,"utf8")}catch{}const declared=contents.match(/^\s*table:\s*['"`]([^'"`]+)['"`]/m);if(declared?.[1])return declared[1].toLowerCase();const named=contents.match(/^\s*name:\s*['"`]([^'"`]+)['"`]/m);return plural(snakeCase(named?.[1]??source.name)).toLowerCase()}function shouldIncludeFrameworkDefaults(){const flag=process.env.STACKS_INCLUDE_FRAMEWORK_MODELS;if(flag==="1"||flag==="true")return!0;if(flag==="0"||flag==="false")return!1;return config?.database?.models?.includeFrameworkDefaults===!0}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 useFramework=(options.includeFrameworkDefaults??shouldIncludeFrameworkDefaults())||user.length===0,contributing=useFramework?framework:[],excluded=useFramework?[]:framework,frameworkByName=new Map;for(const model of framework)frameworkByName.set(model.name,model);const shadowed=[];for(const model of user){const replaced=frameworkByName.get(model.name);if(replaced)shadowed.push({name:model.name,userFile:model.file,frameworkFile:replaced.file})}const byName=new Map;for(const model of contributing)byName.set(model.name,model);for(const model of user)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(contributing.length>0)roots.push(frameworkRoot);const excludedTables=[...new Set(excluded.map(declaredTableName))].sort(),onlyUser=contributing.length===0,allFlat=models.every((m)=>basename(join(m.file,".."))===basename(onlyUser?userRoot:frameworkRoot));if(!options.forceStage&&roots.length===1&&allFlat)return{dir:roots[0],models,roots,staged:!1,shadowed,excluded,excludedTables};return{dir:stage(models),models,roots,staged:!0,shadowed,excluded,excludedTables}}export function cleanupModelStaging(){const dir=modelStagingDir();try{if(existsSync(dir)&&lstatSync(dir).isDirectory())rmSync(dir,{recursive:!0,force:!0})}catch{}}
|
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.297",
|
|
6
6
|
"description": "The Stacks database integration.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -61,19 +61,19 @@
|
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
63
|
"@stacksjs/ts-validation": "^0.5.0",
|
|
64
|
-
"bun-query-builder": "^0.2.
|
|
64
|
+
"bun-query-builder": "^0.2.23",
|
|
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.297",
|
|
69
|
+
"@stacksjs/config": "0.70.297",
|
|
70
|
+
"@stacksjs/logging": "0.70.297",
|
|
71
|
+
"@stacksjs/router": "0.70.297",
|
|
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.297",
|
|
74
|
+
"@stacksjs/query-builder": "0.70.297",
|
|
75
|
+
"@stacksjs/storage": "0.70.297",
|
|
76
|
+
"@stacksjs/strings": "0.70.297",
|
|
77
|
+
"@stacksjs/utils": "0.70.297"
|
|
78
78
|
}
|
|
79
79
|
}
|