velocious 1.0.579 → 1.0.581

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.
Files changed (49) hide show
  1. package/README.md +25 -0
  2. package/build/cli/command-arguments.js +45 -0
  3. package/build/cli/index.js +10 -0
  4. package/build/configuration-types.js +3 -0
  5. package/build/database/drivers/base.js +92 -2
  6. package/build/database/drivers/sqlite/base.js +7 -2
  7. package/build/database/generation-context.js +121 -0
  8. package/build/database/initializer-from-require-context.js +7 -0
  9. package/build/environment-handlers/node/cli/commands/db/schema/dump.js +25 -0
  10. package/build/environment-handlers/node/cli/commands/db/schema/load.js +37 -8
  11. package/build/environment-handlers/node/cli/commands/generate/base-models.js +82 -13
  12. package/build/src/cli/command-arguments.d.ts +27 -0
  13. package/build/src/cli/command-arguments.d.ts.map +1 -0
  14. package/build/src/cli/command-arguments.js +38 -0
  15. package/build/src/cli/index.d.ts.map +1 -1
  16. package/build/src/cli/index.js +9 -1
  17. package/build/src/configuration-types.d.ts +18 -0
  18. package/build/src/configuration-types.d.ts.map +1 -1
  19. package/build/src/configuration-types.js +4 -1
  20. package/build/src/database/drivers/base.d.ts +38 -0
  21. package/build/src/database/drivers/base.d.ts.map +1 -1
  22. package/build/src/database/drivers/base.js +82 -3
  23. package/build/src/database/drivers/sqlite/base.d.ts.map +1 -1
  24. package/build/src/database/drivers/sqlite/base.js +6 -3
  25. package/build/src/database/generation-context.d.ts +62 -0
  26. package/build/src/database/generation-context.d.ts.map +1 -0
  27. package/build/src/database/generation-context.js +102 -0
  28. package/build/src/database/initializer-from-require-context.d.ts.map +1 -1
  29. package/build/src/database/initializer-from-require-context.js +6 -1
  30. package/build/src/environment-handlers/node/cli/commands/db/schema/dump.d.ts.map +1 -1
  31. package/build/src/environment-handlers/node/cli/commands/db/schema/dump.js +22 -1
  32. package/build/src/environment-handlers/node/cli/commands/db/schema/load.d.ts +11 -0
  33. package/build/src/environment-handlers/node/cli/commands/db/schema/load.d.ts.map +1 -1
  34. package/build/src/environment-handlers/node/cli/commands/db/schema/load.js +33 -7
  35. package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts +14 -0
  36. package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts.map +1 -1
  37. package/build/src/environment-handlers/node/cli/commands/generate/base-models.js +371 -308
  38. package/build/tsconfig.tsbuildinfo +1 -1
  39. package/package.json +1 -1
  40. package/src/cli/command-arguments.js +45 -0
  41. package/src/cli/index.js +10 -0
  42. package/src/configuration-types.js +3 -0
  43. package/src/database/drivers/base.js +92 -2
  44. package/src/database/drivers/sqlite/base.js +7 -2
  45. package/src/database/generation-context.js +121 -0
  46. package/src/database/initializer-from-require-context.js +7 -0
  47. package/src/environment-handlers/node/cli/commands/db/schema/dump.js +25 -0
  48. package/src/environment-handlers/node/cli/commands/db/schema/load.js +37 -8
  49. package/src/environment-handlers/node/cli/commands/generate/base-models.js +82 -13
package/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
  * Built-in record auditing for model lifecycle changes (see [docs/auditing.md](docs/auditing.md))
9
9
  * Declarative state machines for models, with typed event methods generated into the base model (see [docs/state-machine.md](docs/state-machine.md))
10
10
  * Migrations for schema changes and UTC datetime storage (see [docs/database-migrations.md](docs/database-migrations.md))
11
+ * Tenant-selected base-model and structure generation with one immutable, fail-closed physical database context; tenant-only model metadata initializes only after that context is active (see [docs/tenant-selected-database-generation.md](docs/tenant-selected-database-generation.md))
11
12
  * External packages (engines) that contribute data models, frontend-model resources and migrations to a consuming app (see [docs/packages.md](docs/packages.md))
12
13
  * Optional Rampway-owned durable deployment control plane mounted through the standard routes DSL on Velocious 1.0.577 or newer (see [docs/rampway-integration.md](docs/rampway-integration.md))
13
14
  * Controllers and views for HTTP endpoints
@@ -1178,6 +1179,30 @@ const results = await Task.insertMultiple(
1178
1179
  console.log(results.succeededRows, results.failedRows, results.errors)
1179
1180
  ```
1180
1181
 
1182
+ Large batches are split into multiple `INSERT ... VALUES` statements so each
1183
+ statement stays within database limits. Two database-configuration keys control
1184
+ the splitting:
1185
+
1186
+ - `maxRowsPerInsert` — maximum rows per statement (default: `500`).
1187
+ - `maxInsertSqlBytes` — maximum serialized SQL size in bytes per statement
1188
+ (default: `1048576`, i.e. 1 MiB).
1189
+
1190
+ A new chunk is started when the next row would exceed either limit. Row order is
1191
+ preserved across chunks.
1192
+
1193
+ **Important:** when `insertMultiple` is called outside a transaction, each chunk
1194
+ commits independently. If a later chunk fails, earlier chunks remain persisted.
1195
+ Wrap the call in a transaction when you need all-or-nothing semantics:
1196
+
1197
+ ```js
1198
+ await Task.transaction(async () => {
1199
+ await Task.insertMultiple(
1200
+ ["project_id", "name", "created_at", "updated_at"],
1201
+ thousandsOfTasks
1202
+ )
1203
+ })
1204
+ ```
1205
+
1181
1206
  ### Find or create records
1182
1207
 
1183
1208
  ```js
@@ -0,0 +1,45 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * @typedef {object} CommandArgumentDefinition
5
+ * @property {string[]} [booleanOptions] - Flags that do not accept a value.
6
+ * @property {string[]} [valueOptions] - Flags that require one value.
7
+ */
8
+
9
+ /**
10
+ * Parses and validates one command's arguments.
11
+ * @param {object} args - Parser arguments.
12
+ * @param {CommandArgumentDefinition} args.definition - Accepted command options.
13
+ * @param {string[]} args.processArgs - Raw command arguments including the command name.
14
+ * @returns {Record<string, string | boolean>} - Values keyed by long option name without `--`.
15
+ */
16
+ export default function commandArguments({definition, processArgs}) {
17
+ const commandName = processArgs[0] || "command"
18
+ const booleanOptions = new Set(definition.booleanOptions || [])
19
+ const valueOptions = new Set(definition.valueOptions || [])
20
+ /** @type {Record<string, string | boolean>} */
21
+ const parsed = {}
22
+
23
+ for (let index = 1; index < processArgs.length; index++) {
24
+ const argument = processArgs[index]
25
+
26
+ if (booleanOptions.has(argument)) {
27
+ parsed[argument.slice(2)] = true
28
+ continue
29
+ }
30
+
31
+ if (valueOptions.has(argument)) {
32
+ const value = processArgs[index + 1]
33
+
34
+ if (!value || value.startsWith("-")) throw new Error(`Missing value for ${argument}`)
35
+
36
+ parsed[argument.slice(2)] = value
37
+ index++
38
+ continue
39
+ }
40
+
41
+ throw new Error(`Unknown argument for ${commandName}: ${argument}`)
42
+ }
43
+
44
+ return parsed
45
+ }
@@ -1,5 +1,7 @@
1
1
  // @ts-check
2
2
 
3
+ const COMMAND_VALUE_OPTIONS = new Set(["--tenant"])
4
+
3
5
  export default class VelociousCli {
4
6
  /**
5
7
  * Runs constructor.
@@ -94,6 +96,7 @@ export default class VelociousCli {
94
96
  * Current group.
95
97
  * @type {string[]} */
96
98
  let currentGroup = []
99
+ let expectsOptionValue = false
97
100
 
98
101
  for (const processArg of processArgs) {
99
102
  if (currentGroup.length == 0) {
@@ -103,11 +106,18 @@ export default class VelociousCli {
103
106
  continue
104
107
  }
105
108
 
109
+ if (expectsOptionValue) {
110
+ currentGroup.push(processArg)
111
+ expectsOptionValue = false
112
+ continue
113
+ }
114
+
106
115
  if (!processArg.startsWith("-") && commandNames.has(processArg)) {
107
116
  groups.push(currentGroup)
108
117
  currentGroup = [processArg]
109
118
  } else {
110
119
  currentGroup.push(processArg)
120
+ expectsOptionValue = COMMAND_VALUE_OPTIONS.has(processArg)
111
121
  }
112
122
  }
113
123
 
@@ -73,6 +73,8 @@
73
73
  * @property {string} [host] - Database host.
74
74
  * @property {boolean} [migrations] - Whether migrations are enabled for this database.
75
75
  * @property {boolean} [multipleStatements] - (MySQL) Opt in to multi-statement queries so a whole structure SQL dump loads in one round-trip via `StructureSqlLoader`. Off by default; ordinary queries otherwise reject stacked statements.
76
+ * @property {number} [maxRowsPerInsert] - Maximum rows per `INSERT ... VALUES (...), (...), ...` statement generated by `Record.insertMultiple`. Defaults to 500.
77
+ * @property {number} [maxInsertSqlBytes] - Maximum serialized SQL size, in bytes, for a single `INSERT ... VALUES (...), (...), ...` statement. Defaults to 1 MiB (1048576).
76
78
  * @property {string} [password] - Password for the database user.
77
79
  * @property {number} [port] - Database port.
78
80
  * @property {string} [primaryKeyType] - Default type for implicit migration primary keys and references. Defaults to `uuid`.
@@ -659,6 +661,7 @@
659
661
  /**
660
662
  * @typedef {object} TenantDatabaseProviderType
661
663
  * @property {(args: {configuration: import("./configuration.js").default, identifier: string}) => Array<ReturnType<typeof JSON.parse>> | Promise<Array<ReturnType<typeof JSON.parse>>>} listTenants - Lists tenants that should be created, checked, or migrated for this database identifier.
664
+ * @property {(args: {configuration: import("./configuration.js").default, identifier: string}) => ReturnType<typeof JSON.parse> | void | Promise<ReturnType<typeof JSON.parse> | void>} [resolveGenerationTenant] - Resolves one explicit tenant descriptor for schema/base-model generation without enumerating lifecycle tenants.
662
665
  * @property {(args: {configuration: import("./configuration.js").default, identifier: string}) => Array<ReturnType<typeof JSON.parse>> | Promise<Array<ReturnType<typeof JSON.parse>>>} [listRestrictTenants] - Lists existing tenants that should be checked for dependent restrict destroys. Defaults to listTenants.
663
666
  * @property {(args: {configuration: import("./configuration.js").default, databaseConfiguration: DatabaseConfigurationType, identifier: string, tenant: ReturnType<typeof JSON.parse>}) => void | Promise<void>} [createDatabase] - Creates the tenant database/schema for one tenant.
664
667
  * @property {(args: {configuration: import("./configuration.js").default, databaseConfiguration: DatabaseConfigurationType, identifier: string, tenant: ReturnType<typeof JSON.parse>}) => void | Promise<void>} [dropDatabase] - Drops the tenant database/schema for one tenant.
@@ -766,8 +766,94 @@ export default class VelociousDatabaseDriversBase {
766
766
  await this.query(sql)
767
767
  }
768
768
 
769
+ /**
770
+ * Maximum rows per `INSERT ... VALUES (...), (...), ...` statement. Drivers
771
+ * that build multi-value inserts must stay below database-specific limits
772
+ * (SQLite's `MAX_VARIABLE_NUMBER`, SQL Server's 2100 parameters, PostgreSQL's
773
+ * 65535 parameters, and so on). 500 rows is safely under every major engine
774
+ * for tables with a moderate number of columns and keeps generated SQL small.
775
+ *
776
+ * Override via `maxRowsPerInsert` in the database configuration.
777
+ * @returns {number} - Maximum rows per insert statement.
778
+ */
779
+ maxRowsPerInsert() {
780
+ return optionalPositiveInteger(this.getArgs().maxRowsPerInsert, "maxRowsPerInsert") ?? 500
781
+ }
782
+
783
+ /**
784
+ * Maximum serialized SQL size, in bytes, for a single `INSERT ... VALUES`
785
+ * statement. Large text/JSON payloads can push a modest row count well beyond
786
+ * database wire/protocol limits, so chunking also stops when the next row
787
+ * would push the generated string over this threshold.
788
+ *
789
+ * Override via `maxInsertSqlBytes` in the database configuration.
790
+ * @returns {number} - Maximum bytes per insert statement.
791
+ */
792
+ maxInsertSqlBytes() {
793
+ return optionalPositiveInteger(this.getArgs().maxInsertSqlBytes, "maxInsertSqlBytes") ?? 1048576
794
+ }
795
+
796
+ /**
797
+ * Splits `rows` into chunks that stay within both {@link maxRowsPerInsert}
798
+ * and {@link maxInsertSqlBytes} while preserving order.
799
+ *
800
+ * A chunk always contains at least one row, even if that single row exceeds
801
+ * the byte limit, so progress is guaranteed.
802
+ * @param {Array<Array<ReturnType<typeof JSON.parse>>>} rows - Rows to insert.
803
+ * @param {(rows: Array<Array<ReturnType<typeof JSON.parse>>>) => string} buildSql - Function that builds the full SQL for a candidate chunk; called with `[]` to measure the statement prefix and with `[row]` to measure each row's values tuple.
804
+ * @returns {Array<Array<Array<ReturnType<typeof JSON.parse>>>>} - Row chunks.
805
+ */
806
+ _insertMultipleChunks(rows, buildSql) {
807
+ const chunks = []
808
+ const maxRows = this.maxRowsPerInsert()
809
+ const maxBytes = this.maxInsertSqlBytes()
810
+ const emptySql = buildSql([])
811
+ const prefix = `${emptySql} VALUES `
812
+ const baseByteLength = Buffer.byteLength(prefix, "utf8")
813
+
814
+ let currentChunk = []
815
+ let currentBytes = 0
816
+
817
+ for (const row of rows) {
818
+ const singleRowSql = buildSql([row])
819
+ const rowValuesSql = singleRowSql.slice(prefix.length)
820
+ const rowValuesSqlBytes = Buffer.byteLength(rowValuesSql, "utf8")
821
+
822
+ if (currentChunk.length > 0) {
823
+ const candidateRows = currentChunk.length + 1
824
+ const candidateBytes = currentBytes + 2 + rowValuesSqlBytes // ", " separator
825
+
826
+ if (candidateRows > maxRows || candidateBytes > maxBytes) {
827
+ chunks.push(currentChunk)
828
+ currentChunk = []
829
+ currentBytes = 0
830
+ }
831
+ }
832
+
833
+ if (currentChunk.length === 0) {
834
+ currentBytes = baseByteLength + rowValuesSqlBytes
835
+ } else {
836
+ currentBytes += 2 + rowValuesSqlBytes
837
+ }
838
+
839
+ currentChunk.push(row)
840
+ }
841
+
842
+ if (currentChunk.length > 0) {
843
+ chunks.push(currentChunk)
844
+ }
845
+
846
+ return chunks
847
+ }
848
+
769
849
  /**
770
850
  * Runs insert multiple.
851
+ *
852
+ * Large row sets are split into multiple statements that each stay within
853
+ * {@link maxRowsPerInsert} rows and {@link maxInsertSqlBytes} serialized
854
+ * bytes so the generated SQL stays within database parameter and wire limits.
855
+ * When called outside a transaction each chunk commits independently; callers
856
+ * that need all-or-nothing semantics should wrap the call in {@link transaction}.
771
857
  * @param {string} tableName - Table name.
772
858
  * @param {Array<string>} columns - Column names.
773
859
  * @param {Array<Array<ReturnType<typeof JSON.parse>>>} rows - Rows to insert.
@@ -776,9 +862,13 @@ export default class VelociousDatabaseDriversBase {
776
862
  async insertMultiple(tableName, columns, rows) {
777
863
  this._assertNotReadOnly()
778
864
 
779
- const sql = this.insertSql({columns, tableName, rows})
865
+ const chunks = this._insertMultipleChunks(rows, (chunkRows) => this.insertSql({columns, tableName, rows: chunkRows}))
780
866
 
781
- await this.query(sql)
867
+ for (const chunk of chunks) {
868
+ const sql = this.insertSql({columns, tableName, rows: chunk})
869
+
870
+ await this.query(sql)
871
+ }
782
872
  }
783
873
 
784
874
  /**
@@ -218,9 +218,14 @@ export default class VelociousDatabaseDriversSqliteBase extends Base {
218
218
  */
219
219
  async insertMultipleWithSingleInsert(tableName, columns, rows) {
220
220
  this._assertNotReadOnly()
221
- const sql = new Insert({columns, driver: this, rows, tableName}).toSql()
222
221
 
223
- await this.query(sql)
222
+ const chunks = this._insertMultipleChunks(rows, (chunkRows) => new Insert({columns, driver: this, rows: chunkRows, tableName}).toSql())
223
+
224
+ for (const chunk of chunks) {
225
+ const sql = new Insert({columns, driver: this, rows: chunk, tableName}).toSql()
226
+
227
+ await this.query(sql)
228
+ }
224
229
  }
225
230
 
226
231
  /**
@@ -0,0 +1,121 @@
1
+ // @ts-check
2
+
3
+ import Tenant from "../tenants/tenant.js"
4
+
5
+ /**
6
+ * Immutable selection of one logical tenant database and one provider-resolved
7
+ * physical tenant. Schema tools can share this contract without ambient or
8
+ * process-global selection state.
9
+ */
10
+ export default class DatabaseGenerationContext {
11
+ /**
12
+ * Resolves one tenant-only database from its provider and captures its physical identity.
13
+ * @param {object} args - Selection arguments.
14
+ * @param {import("../configuration.js").default} args.configuration - Owning configuration.
15
+ * @param {string} args.databaseIdentifier - Logical tenant-only database identifier.
16
+ * @returns {Promise<DatabaseGenerationContext>} - Immutable selected database context.
17
+ */
18
+ static async resolve({configuration, databaseIdentifier}) {
19
+ const databaseConfiguration = configuration.getDatabaseConfiguration()[databaseIdentifier]
20
+
21
+ if (!databaseConfiguration) {
22
+ throw new Error(`No such tenant database identifier configured: ${databaseIdentifier}`)
23
+ }
24
+ if (!databaseConfiguration.tenantOnly) {
25
+ throw new Error(`Database identifier ${databaseIdentifier} is not configured with tenantOnly: true`)
26
+ }
27
+ if (configuration.getDisabledDatabaseIdentifiers().has(databaseIdentifier)) {
28
+ throw new Error(`Tenant database identifier ${databaseIdentifier} is disabled by VELOCIOUS_DISABLED_DATABASE_IDENTIFIERS`)
29
+ }
30
+
31
+ const provider = configuration.getTenantDatabaseProvider(databaseIdentifier)
32
+
33
+ await configuration.initialize({type: "database-generation"})
34
+
35
+ const tenants = await configuration.ensureConnections({name: `Resolve database generation context: ${databaseIdentifier}`}, async () => {
36
+ if (provider.resolveGenerationTenant) {
37
+ const tenant = await provider.resolveGenerationTenant({configuration, identifier: databaseIdentifier})
38
+
39
+ return tenant === undefined ? [] : [tenant]
40
+ }
41
+
42
+ return await provider.listTenants({configuration, identifier: databaseIdentifier})
43
+ })
44
+
45
+ if (!Array.isArray(tenants)) {
46
+ throw new Error(`Tenant database provider for ${databaseIdentifier} must return an array from listTenants`)
47
+ }
48
+ if (tenants.length === 0) {
49
+ throw new Error(`Tenant database selection ${databaseIdentifier} resolved no tenants`)
50
+ }
51
+ if (tenants.length !== 1) {
52
+ throw new Error(`Tenant database selection ${databaseIdentifier} is ambiguous: provider returned ${tenants.length} tenants`)
53
+ }
54
+
55
+ const tenant = tenants[0]
56
+
57
+ if (!tenant || typeof tenant !== "object" || Array.isArray(tenant)) {
58
+ throw new Error(`Tenant database selection ${databaseIdentifier} returned an invalid tenant descriptor`)
59
+ }
60
+
61
+ const handle = Tenant.handle(tenant, configuration)
62
+
63
+ // Resolve now so an inactive/stale descriptor fails before a selected
64
+ // schema connection can be checked out or read.
65
+ handle.databaseConfiguration(databaseIdentifier)
66
+
67
+ return new DatabaseGenerationContext({configuration, databaseIdentifier, handle})
68
+ }
69
+
70
+ /**
71
+ * Runs constructor.
72
+ * @param {object} args - Captured selection.
73
+ * @param {import("../configuration.js").default} args.configuration - Owning configuration.
74
+ * @param {string} args.databaseIdentifier - Logical database identifier.
75
+ * @param {ReturnType<typeof Tenant.handle>} args.handle - Captured tenant handle.
76
+ */
77
+ constructor({configuration, databaseIdentifier, handle}) {
78
+ this._configuration = configuration
79
+ this._databaseIdentifier = databaseIdentifier
80
+ this._handle = handle
81
+
82
+ Object.freeze(this)
83
+ }
84
+
85
+ /**
86
+ * Returns the captured logical database identifier.
87
+ * @returns {string} - Captured logical database identifier.
88
+ */
89
+ databaseIdentifier() { return this._databaseIdentifier }
90
+
91
+ /**
92
+ * Returns the captured physical database configuration.
93
+ * @returns {import("../configuration-types.js").DatabaseConfigurationType} - Captured physical database configuration.
94
+ */
95
+ databaseConfiguration() { return this._handle.databaseConfiguration(this._databaseIdentifier) }
96
+
97
+ /**
98
+ * Returns the captured tenant descriptor.
99
+ * @returns {ReturnType<ReturnType<typeof Tenant.handle>["tenant"]>} - Captured immutable tenant descriptor.
100
+ */
101
+ tenant() { return this._handle.tenant() }
102
+
103
+ /**
104
+ * Runs work on one connection pinned to the captured physical database.
105
+ * @template T
106
+ * @param {object} args - Work arguments.
107
+ * @param {(connection: import("./drivers/base.js").default) => Promise<T>} args.callback - Selected database work.
108
+ * @param {string} args.name - Checkout name.
109
+ * @returns {Promise<T>} - Callback result.
110
+ */
111
+ async run({callback, name}) {
112
+ return await this._configuration.runWithTenant(this.tenant(), async () => {
113
+ return await this._configuration.withDatabaseOperation({
114
+ databaseConfiguration: this.databaseConfiguration(),
115
+ databaseIdentifier: this.databaseIdentifier(),
116
+ name,
117
+ tenant: this.tenant()
118
+ }, async (operation) => await callback(operation.connection()))
119
+ })
120
+ }
121
+ }
@@ -43,6 +43,13 @@ export default class VelociousDatabaseInitializerFromRequireContext {
43
43
 
44
44
  if (!modelClass) throw new Error(`Model wasn't exported from: ${fileName}`)
45
45
 
46
+ const configuredDatabase = configuration.getDatabaseConfiguration()[modelClass.getConfiguredDatabaseIdentifier()]
47
+
48
+ if (configuredDatabase?.tenantOnly && !configuration.isDatabaseIdentifierActive(modelClass.getConfiguredDatabaseIdentifier())) {
49
+ modelClass.registerRecordClass({configuration})
50
+ continue
51
+ }
52
+
46
53
  if (!modelClass.getEagerLoadRecordMetadata()) {
47
54
  modelClass.registerRecordClass({configuration})
48
55
  await this._bestEffortInitializeDeferredModel({configuration, modelClass})
@@ -1,4 +1,6 @@
1
1
  import BaseCommand from "../../../../../../cli/base-command.js"
2
+ import commandArguments from "../../../../../../cli/command-arguments.js"
3
+ import DatabaseGenerationContext from "../../../../../../database/generation-context.js"
2
4
  import fileExists from "../../../../../../utils/file-exists.js"
3
5
  import path from "path"
4
6
 
@@ -8,6 +10,29 @@ export default class DbSchemaDump extends BaseCommand {
8
10
  * Runs execute.
9
11
  * @returns {Promise<void>} */
10
12
  async execute() {
13
+ const parsedArguments = commandArguments({
14
+ definition: {valueOptions: ["--tenant"]},
15
+ processArgs: this.processArgs || []
16
+ })
17
+ const tenantDatabaseIdentifier = parsedArguments.tenant
18
+
19
+ if (typeof tenantDatabaseIdentifier === "string") {
20
+ const context = await DatabaseGenerationContext.resolve({
21
+ configuration: this.getConfiguration(),
22
+ databaseIdentifier: tenantDatabaseIdentifier
23
+ })
24
+
25
+ await context.run({name: "DB selected tenant schema dump", callback: async (db) => {
26
+ const dbs = {[context.databaseIdentifier()]: db}
27
+ const shouldGenerate = await this.shouldGenerateStructureSql({dbs})
28
+
29
+ if (!shouldGenerate) return
30
+
31
+ await this.getEnvironmentHandler().afterMigrations({dbs, reason: "schemaDump"})
32
+ }})
33
+ return
34
+ }
35
+
11
36
  await this.getConfiguration().ensureConnections({name: "DB schema dump"}, async (dbs) => {
12
37
  const shouldGenerate = await this.shouldGenerateStructureSql({dbs})
13
38
 
@@ -1,4 +1,6 @@
1
1
  import BaseCommand from "../../../../../../cli/base-command.js"
2
+ import commandArguments from "../../../../../../cli/command-arguments.js"
3
+ import DatabaseGenerationContext from "../../../../../../database/generation-context.js"
2
4
  import fs from "fs/promises"
3
5
  import path from "path"
4
6
  import StructureSqlLoader from "../../../../../../database/structure-sql-loader.js"
@@ -9,17 +11,44 @@ export default class DbSchemaLoad extends BaseCommand {
9
11
  * Runs execute.
10
12
  * @returns {Promise<void>} */
11
13
  async execute() {
12
- await this.getConfiguration().ensureConnections({name: "DB schema load"}, async (dbs) => {
13
- const dbDir = path.join(this.directory(), "db")
14
- const loader = new StructureSqlLoader()
14
+ const parsedArguments = commandArguments({
15
+ definition: {valueOptions: ["--tenant"]},
16
+ processArgs: this.processArgs || []
17
+ })
18
+ const tenantDatabaseIdentifier = parsedArguments.tenant
15
19
 
16
- for (const identifier of Object.keys(dbs)) {
17
- const db = dbs[identifier]
18
- const structureFilePath = path.join(dbDir, `structure-${identifier}.sql`)
19
- const structureSql = await fs.readFile(structureFilePath, "utf8")
20
+ if (typeof tenantDatabaseIdentifier === "string") {
21
+ const context = await DatabaseGenerationContext.resolve({
22
+ configuration: this.getConfiguration(),
23
+ databaseIdentifier: tenantDatabaseIdentifier
24
+ })
20
25
 
21
- await loader.load({db, structureSql})
26
+ await context.run({name: "DB selected tenant schema load", callback: async (db) => {
27
+ await this.loadStructureSql({db, identifier: context.databaseIdentifier()})
28
+ }})
29
+ return
30
+ }
31
+
32
+ await this.getConfiguration().ensureConnections({name: "DB schema load"}, async (dbs) => {
33
+ for (const identifier of Object.keys(dbs)) {
34
+ await this.loadStructureSql({db: dbs[identifier], identifier})
22
35
  }
23
36
  })
24
37
  }
38
+
39
+ /**
40
+ * Loads one identifier's explicit structure file into one selected connection.
41
+ * @param {object} args - Load arguments.
42
+ * @param {import("../../../../../../database/drivers/base.js").default} args.db - Target connection.
43
+ * @param {string} args.identifier - Logical database identifier used in the file name.
44
+ * @returns {Promise<void>} - Resolves after loading.
45
+ */
46
+ async loadStructureSql({db, identifier}) {
47
+ const dbDir = path.join(this.directory(), "db")
48
+ const loader = new StructureSqlLoader()
49
+ const structureFilePath = path.join(dbDir, `structure-${identifier}.sql`)
50
+ const structureSql = await fs.readFile(structureFilePath, "utf8")
51
+
52
+ await loader.load({db, structureSql})
53
+ }
25
54
  }
@@ -1,6 +1,8 @@
1
1
  // @ts-check
2
2
 
3
3
  import BaseCommand from "../../../../../cli/base-command.js"
4
+ import commandArguments from "../../../../../cli/command-arguments.js"
5
+ import DatabaseGenerationContext from "../../../../../database/generation-context.js"
4
6
  import deburrColumnName from "../../../../../utils/deburr-column-name.js"
5
7
  import fileExists from "../../../../../utils/file-exists.js"
6
8
  import fs from "fs/promises"
@@ -62,15 +64,64 @@ function generatedRelationshipMethod({abstract = false, body, name, param, retur
62
64
 
63
65
  export default class DbGenerateModel extends BaseCommand {
64
66
  async execute() {
67
+ const parsedArguments = commandArguments({
68
+ definition: {
69
+ booleanOptions: ["--allow-missing-tables"],
70
+ valueOptions: ["--tenant"]
71
+ },
72
+ processArgs: this.processArgs || []
73
+ })
74
+ const allowMissingTables = parsedArguments["allow-missing-tables"] === true
75
+ const tenantDatabaseIdentifier = parsedArguments.tenant
76
+
77
+ if (typeof tenantDatabaseIdentifier === "string") {
78
+ const context = await DatabaseGenerationContext.resolve({
79
+ configuration: this.getConfiguration(),
80
+ databaseIdentifier: tenantDatabaseIdentifier
81
+ })
82
+ const selectedModelClasses = Object.values(this.getConfiguration().getModelClasses()).filter((modelClass) => {
83
+ const databaseIdentifier = modelClass.getDatabaseIdentifier({
84
+ enforceTenantDatabaseScope: false,
85
+ tenant: context.tenant()
86
+ })
87
+
88
+ if (databaseIdentifier !== context.databaseIdentifier()) return false
89
+
90
+ return modelClass.getDatabaseIdentifier({tenant: context.tenant()}) === context.databaseIdentifier()
91
+ })
92
+
93
+ try {
94
+ return await context.run({name: "Generate selected tenant base models", callback: async (connection) => {
95
+ await this.generateBaseModels({allowMissingTables, connections: {[context.databaseIdentifier()]: connection}, context})
96
+ }})
97
+ } finally {
98
+ for (const modelClass of selectedModelClasses) modelClass.resetRecordMetadata()
99
+ }
100
+ }
101
+
65
102
  await this.getConfiguration().initializeModels()
66
103
 
67
- const enforceTenantDatabaseScopes = this.getConfiguration().getEnforceTenantDatabaseScopes()
104
+ return await this.getConfiguration().ensureConnections({name: "Generate base models"}, async (connections) => {
105
+ await this.generateBaseModels({allowMissingTables, connections})
106
+ })
107
+ }
68
108
 
109
+ /**
110
+ * Generates model bases from explicit connections.
111
+ * @param {object} args - Generation arguments.
112
+ * @param {boolean} args.allowMissingTables - Whether absent tables are skipped.
113
+ * @param {Record<string, import("../../../../../database/drivers/base.js").default>} args.connections - Connections keyed by logical identifier.
114
+ * @param {DatabaseGenerationContext} [args.context] - Selected tenant database context.
115
+ * @returns {Promise<void>} - Resolves after writing generated bases.
116
+ */
117
+ async generateBaseModels({allowMissingTables, connections, context}) {
69
118
  const rootDirectory = this.directory()
70
119
  const modelsDir = `${rootDirectory}/src/models`
71
120
  const baseModelsDir = `${rootDirectory}/src/model-bases`
72
121
  const modelClasses = this.getConfiguration().getModelClasses()
73
- const allowMissingTables = Boolean(this.processArgs?.includes("--allow-missing-tables"))
122
+ const regenerateCommand = context
123
+ ? `${BASE_MODELS_REGENERATE_COMMAND} --tenant ${context.databaseIdentifier()}`
124
+ : BASE_MODELS_REGENERATE_COMMAND
74
125
  let devMode = false
75
126
 
76
127
  if (baseModelsDir.includes("/spec/dummy/src/model-bases")) {
@@ -81,13 +132,33 @@ export default class DbGenerateModel extends BaseCommand {
81
132
  await fs.mkdir(baseModelsDir, {recursive: true})
82
133
  }
83
134
 
84
- this.getConfiguration().setEnforceTenantDatabaseScopes(false)
85
-
86
- try {
87
- await this.getConfiguration().ensureConnections({name: "Generate base models"}, async () => {
88
- for (const modelClassName in modelClasses) {
135
+ for (const modelClassName in modelClasses) {
89
136
  const modelClass = modelClasses[modelClassName]
90
- const table = await modelClass.connection().getTableByName(modelClass.tableName(), {throwError: !allowMissingTables})
137
+ let databaseIdentifier
138
+
139
+ if (context) {
140
+ databaseIdentifier = modelClass.getDatabaseIdentifier({
141
+ enforceTenantDatabaseScope: false,
142
+ tenant: context.tenant()
143
+ })
144
+
145
+ if (databaseIdentifier !== context.databaseIdentifier()) continue
146
+
147
+ databaseIdentifier = modelClass.getDatabaseIdentifier({tenant: context.tenant()})
148
+ } else {
149
+ databaseIdentifier = modelClass.getConfiguredDatabaseIdentifier()
150
+ }
151
+
152
+ if (context && databaseIdentifier !== context.databaseIdentifier()) continue
153
+
154
+ const connection = connections[databaseIdentifier]
155
+
156
+ // Default generation continues to ignore inactive tenant-only identifiers.
157
+ if (!connection) continue
158
+
159
+ if (context) modelClass.resetRecordMetadata()
160
+
161
+ const table = await connection.getTableByName(modelClass.tableName(), {throwError: !allowMissingTables})
91
162
 
92
163
  if (!table) {
93
164
  console.warn(`Skipping base model for '${modelClass.name}': table '${modelClass.tableName()}' was not found (--allow-missing-tables). Keeping any existing base model.`)
@@ -95,6 +166,8 @@ export default class DbGenerateModel extends BaseCommand {
95
166
  continue
96
167
  }
97
168
 
169
+ await modelClass.ensureInitialized({configuration: this.getConfiguration(), connection})
170
+
98
171
  const modelName = inflection.dasherize(modelClassName)
99
172
  const modelNameCamelized = inflection.camelize(modelName.replaceAll("-", "_"))
100
173
  const modelBaseFileName = `${inflection.dasherize(inflection.underscore(modelName))}.js`
@@ -111,7 +184,7 @@ export default class DbGenerateModel extends BaseCommand {
111
184
  sourceModelFilePath = "velocious/build/src/database/record/index.js"
112
185
  }
113
186
 
114
- let fileContent = generatedFileBanner(BASE_MODELS_REGENERATE_COMMAND)
187
+ let fileContent = generatedFileBanner(regenerateCommand)
115
188
  let velociousPath
116
189
 
117
190
  if (devMode) {
@@ -454,10 +527,6 @@ export default class DbGenerateModel extends BaseCommand {
454
527
  fileContent += "}\n"
455
528
 
456
529
  await fs.writeFile(modelPath, fileContent)
457
- }
458
- })
459
- } finally {
460
- this.getConfiguration().setEnforceTenantDatabaseScopes(enforceTenantDatabaseScopes)
461
530
  }
462
531
  }
463
532
 
@@ -0,0 +1,27 @@
1
+ export type CommandArgumentDefinition = {
2
+ /**
3
+ * - Flags that do not accept a value.
4
+ */
5
+ booleanOptions?: string[];
6
+ /**
7
+ * - Flags that require one value.
8
+ */
9
+ valueOptions?: string[];
10
+ };
11
+ /**
12
+ * @typedef {object} CommandArgumentDefinition
13
+ * @property {string[]} [booleanOptions] - Flags that do not accept a value.
14
+ * @property {string[]} [valueOptions] - Flags that require one value.
15
+ */
16
+ /**
17
+ * Parses and validates one command's arguments.
18
+ * @param {object} args - Parser arguments.
19
+ * @param {CommandArgumentDefinition} args.definition - Accepted command options.
20
+ * @param {string[]} args.processArgs - Raw command arguments including the command name.
21
+ * @returns {Record<string, string | boolean>} - Values keyed by long option name without `--`.
22
+ */
23
+ export default function commandArguments({ definition, processArgs }: {
24
+ definition: CommandArgumentDefinition;
25
+ processArgs: string[];
26
+ }): Record<string, string | boolean>;
27
+ //# sourceMappingURL=command-arguments.d.ts.map