velocious 1.0.481 → 1.0.483

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 (38) hide show
  1. package/README.md +2 -0
  2. package/build/cli/commands/db/tenants/drop.js +34 -0
  3. package/build/cli/tenant-database-command-helper.js +8 -85
  4. package/build/database/tenants/data-copier.js +269 -0
  5. package/build/database/tenants/schema-cloner.js +390 -0
  6. package/build/database/tenants/tenant-table-plan.js +20 -0
  7. package/build/src/cli/commands/db/tenants/drop.d.ts +13 -0
  8. package/build/src/cli/commands/db/tenants/drop.d.ts.map +1 -0
  9. package/build/src/cli/commands/db/tenants/drop.js +31 -0
  10. package/build/src/cli/tenant-database-command-helper.d.ts +0 -20
  11. package/build/src/cli/tenant-database-command-helper.d.ts.map +1 -1
  12. package/build/src/cli/tenant-database-command-helper.js +8 -73
  13. package/build/src/database/tenants/data-copier.d.ts +125 -0
  14. package/build/src/database/tenants/data-copier.d.ts.map +1 -0
  15. package/build/src/database/tenants/data-copier.js +232 -0
  16. package/build/src/database/tenants/schema-cloner.d.ts +147 -0
  17. package/build/src/database/tenants/schema-cloner.d.ts.map +1 -0
  18. package/build/src/database/tenants/schema-cloner.js +326 -0
  19. package/build/src/database/tenants/tenant-table-plan.d.ts +30 -0
  20. package/build/src/database/tenants/tenant-table-plan.d.ts.map +1 -0
  21. package/build/src/database/tenants/tenant-table-plan.js +3 -0
  22. package/build/src/tenants/tenant-iterator.d.ts +52 -0
  23. package/build/src/tenants/tenant-iterator.d.ts.map +1 -0
  24. package/build/src/tenants/tenant-iterator.js +97 -0
  25. package/build/src/tenants/tenant.d.ts +51 -0
  26. package/build/src/tenants/tenant.d.ts.map +1 -0
  27. package/build/src/tenants/tenant.js +76 -0
  28. package/build/tenants/tenant-iterator.js +111 -0
  29. package/build/tenants/tenant.js +86 -0
  30. package/build/tsconfig.tsbuildinfo +1 -1
  31. package/package.json +1 -1
  32. package/src/cli/commands/db/tenants/drop.js +34 -0
  33. package/src/cli/tenant-database-command-helper.js +8 -85
  34. package/src/database/tenants/data-copier.js +269 -0
  35. package/src/database/tenants/schema-cloner.js +390 -0
  36. package/src/database/tenants/tenant-table-plan.js +20 -0
  37. package/src/tenants/tenant-iterator.js +111 -0
  38. package/src/tenants/tenant.js +86 -0
package/README.md CHANGED
@@ -2242,4 +2242,6 @@ npx velocious db:tenants:migrate projectTenant --parallel 20
2242
2242
 
2243
2243
  `afterMigrateTenant` hooks run inside the active default and tenant database connection scope for the tenant being migrated.
2244
2244
 
2245
+ At runtime, the apartment-style `Tenant` façade (`velocious/build/src/tenants/tenant.js`) is the single entry point: `Tenant.with(tenant, callback)` / `Tenant.current()` to switch into and read a tenant context, `Tenant.each({identifier, callback, parallel?, filter?})` to run a callback within every provider-listed tenant, and `Tenant.drop({identifier, tenant})` (plus the `db:tenants:drop` CLI command) to drop a tenant's database through the provider's `dropDatabase` hook.
2246
+
2245
2247
  See [docs/tenant-databases.md](docs/tenant-databases.md) for the full configuration and migration pattern.
@@ -0,0 +1,34 @@
1
+ // @ts-check
2
+
3
+ import BaseCommand from "../../../base-command.js"
4
+ import TenantDatabaseCommandHelper from "../../../tenant-database-command-helper.js"
5
+
6
+ export default class DbTenantsDrop extends BaseCommand {
7
+ /**
8
+ * Drops the tenant database/schema for every listed tenant through the provider's
9
+ * `dropDatabase` hook.
10
+ * @returns {Promise<{identifier: string, tenantCount: number} | void>} - Result in test mode.
11
+ */
12
+ async execute() {
13
+ const helper = new TenantDatabaseCommandHelper({
14
+ command: this,
15
+ identifier: this.processArgs?.[1]
16
+ })
17
+ const provider = helper.provider
18
+
19
+ if (typeof provider.dropDatabase !== "function") {
20
+ throw new Error(`Tenant database provider for ${helper.identifier} must define dropDatabase to use db:tenants:drop`)
21
+ }
22
+
23
+ const tenantCount = await helper.eachTenant(async ({databaseConfiguration, tenant}) => {
24
+ await provider.dropDatabase?.({
25
+ configuration: this.getConfiguration(),
26
+ databaseConfiguration,
27
+ identifier: helper.identifier,
28
+ tenant
29
+ })
30
+ })
31
+
32
+ if (this.args.testing) return {identifier: helper.identifier, tenantCount}
33
+ }
34
+ }
@@ -1,5 +1,7 @@
1
1
  // @ts-check
2
2
 
3
+ import TenantIterator from "../tenants/tenant-iterator.js"
4
+
3
5
  export default class TenantDatabaseCommandHelper {
4
6
  /**
5
7
  * Runs constructor.
@@ -76,55 +78,13 @@ export default class TenantDatabaseCommandHelper {
76
78
  */
77
79
  async eachTenant(callback) {
78
80
  const tenants = await this.listTenants()
79
- const parallelCount = this.parallelCount()
80
-
81
- if (parallelCount <= 1) {
82
- for (const tenant of tenants) {
83
- await this.runTenantCallback({callback, tenant})
84
- }
85
-
86
- return tenants.length
87
- }
88
-
89
- /**
90
- * Failures.
91
- * @type {Array<{error: Error, tenant: ?}>} */
92
- const failures = []
93
- const workers = []
94
- let tenantIndex = 0
95
- const workerCount = Math.min(parallelCount, tenants.length)
96
-
97
- for (let workerIndex = 0; workerIndex < workerCount; workerIndex++) {
98
- workers.push((async () => {
99
- while (tenantIndex < tenants.length) {
100
- const tenant = tenants[tenantIndex]
101
-
102
- tenantIndex++
103
-
104
- try {
105
- await this.runTenantCallback({callback, tenant})
106
- } catch (error) {
107
- failures.push({
108
- error: error instanceof Error ? error : new Error(String(error)),
109
- tenant
110
- })
111
- }
112
- }
113
- })())
114
- }
115
-
116
- await Promise.all(workers)
117
-
118
- if (failures.length > 0) {
119
- const failedTenantLabels = failures.map((failure) => this.tenantLabel(failure.tenant)).join(", ")
120
-
121
- throw new AggregateError(
122
- failures.map((failure) => failure.error),
123
- `Failed tenant database command for tenant(s): ${failedTenantLabels}`
124
- )
125
- }
81
+ const iterator = new TenantIterator({
82
+ configuration: this.configuration,
83
+ identifier: this.identifier,
84
+ parallelCount: this.parallelCount()
85
+ })
126
86
 
127
- return tenants.length
87
+ return await iterator.run(tenants, callback)
128
88
  }
129
89
 
130
90
  /**
@@ -156,41 +116,4 @@ export default class TenantDatabaseCommandHelper {
156
116
 
157
117
  return parallelCount
158
118
  }
159
-
160
- /**
161
- * Runs run tenant callback.
162
- * @param {object} args - Tenant callback args.
163
- * @param {function({databaseConfiguration: import("../configuration-types.js").DatabaseConfigurationType, tenant: ?}) : Promise<void>} args.callback - Callback.
164
- * @param {?} args.tenant - Tenant.
165
- * @returns {Promise<void>}
166
- */
167
- async runTenantCallback({callback, tenant}) {
168
- await this.configuration.runWithTenant(tenant, async () => {
169
- if (!this.configuration.isDatabaseIdentifierActive(this.identifier)) {
170
- throw new Error(`Tenant database identifier ${this.identifier} is inactive for tenant: ${this.tenantLabel(tenant)}`)
171
- }
172
-
173
- await callback({
174
- databaseConfiguration: this.configuration.resolveDatabaseConfiguration(this.identifier),
175
- tenant
176
- })
177
- })
178
- }
179
-
180
- /**
181
- * Runs tenant label.
182
- * @param {?} tenant - Tenant.
183
- * @returns {string} - Human readable tenant label.
184
- */
185
- tenantLabel(tenant) {
186
- if (tenant && typeof tenant === "object") {
187
- const tenantObject = /** @type {{id?: ?, name?: ?, slug?: ?}} */ (tenant)
188
-
189
- if (tenantObject.slug) return String(tenantObject.slug)
190
- if (tenantObject.name) return String(tenantObject.name)
191
- if (tenantObject.id) return String(tenantObject.id)
192
- }
193
-
194
- return JSON.stringify(tenant)
195
- }
196
119
  }
@@ -0,0 +1,269 @@
1
+ // @ts-check
2
+
3
+ const DEFAULT_INSERT_CHUNK_SIZE = 100
4
+ const DEFAULT_QUERY_CHUNK_SIZE = 500
5
+
6
+ /**
7
+ * Splits an array into chunks of at most `chunkSize` items.
8
+ * @template T
9
+ * @param {T[]} values
10
+ * @param {number} chunkSize
11
+ * @returns {T[][]}
12
+ */
13
+ function chunks(values, chunkSize) {
14
+ const chunkedValues = []
15
+
16
+ for (let index = 0; index < values.length; index += chunkSize) {
17
+ chunkedValues.push(values.slice(index, index + chunkSize))
18
+ }
19
+
20
+ return chunkedValues
21
+ }
22
+
23
+ /**
24
+ * Stringifies values and returns the distinct, non-blank ones, preserving first-seen order.
25
+ * @param {unknown[]} values
26
+ * @returns {string[]}
27
+ */
28
+ function uniqueStrings(values) {
29
+ return Array.from(new Set(values.map((value) => String(value)).filter((value) => value.trim())))
30
+ }
31
+
32
+ /**
33
+ * Copies a single tenant's rows from a source database into a target database, following a
34
+ * table plan that partitions each table either by a direct tenant key column or by
35
+ * parent-id traversal. This is the row-level counterpart to schema cloning: multi-tenant
36
+ * apps that keep each tenant's data in its own database use it to (re)materialise the
37
+ * tenant's rows from a global/template database.
38
+ *
39
+ * The copy is delete-then-reinsert and therefore idempotent, and it mirrors the source
40
+ * snapshot: the rows to delete are the tenant's *current* rows in the target (selected with
41
+ * the same plan traversal run against the target), so a row that was removed from the source
42
+ * since the last copy is dropped from the target too rather than lingering. The deletes run
43
+ * children first and the source inserts parents first, all inside one target transaction with
44
+ * foreign-key enforcement disabled so the ordering never trips a constraint. Reads, deletes
45
+ * and inserts are chunked to bound statement size.
46
+ *
47
+ * The copier is policy-free: the caller supplies the plan, the source/target databases and
48
+ * the tenant key, and {@link DataCopier#copy} returns the loaded rows keyed by table name so
49
+ * the caller can perform any app-specific post-copy work (for example registering record
50
+ * locations) without that policy leaking into the framework.
51
+ */
52
+ export default class DataCopier {
53
+ /**
54
+ * Creates a copier that moves tenant-owned rows from `sourceDb` into `targetDb`.
55
+ * @param {{
56
+ * sourceDb: import("../drivers/base.js").default,
57
+ * targetDb: import("../drivers/base.js").default,
58
+ * tablePlan: import("./tenant-table-plan.js").TenantTablePlanEntry[],
59
+ * idColumn?: string,
60
+ * insertChunkSize?: number,
61
+ * queryChunkSize?: number,
62
+ * onProgress?: (message: string) => void
63
+ * }} args
64
+ */
65
+ constructor({sourceDb, targetDb, tablePlan, idColumn = "id", insertChunkSize = DEFAULT_INSERT_CHUNK_SIZE, queryChunkSize = DEFAULT_QUERY_CHUNK_SIZE, onProgress}) {
66
+ this.sourceDb = sourceDb
67
+ this.targetDb = targetDb
68
+ this.tablePlan = tablePlan
69
+ this.idColumn = idColumn
70
+ this.insertChunkSize = insertChunkSize
71
+ this.queryChunkSize = queryChunkSize
72
+ this.onProgress = onProgress
73
+ }
74
+
75
+ /**
76
+ * Copies every plan table's rows for `keyValue` from the source into the target and
77
+ * returns the copied source rows keyed by table name. The target's current tenant rows
78
+ * are deleted (children first) and the source rows inserted (parents first) in a single
79
+ * target transaction with foreign keys disabled.
80
+ * @param {string} keyValue
81
+ * @returns {Promise<Map<string, Record<string, unknown>[]>>}
82
+ */
83
+ async copy(keyValue) {
84
+ const sourceRowsByTableName = await this.loadRows(this.sourceDb, keyValue)
85
+ const targetRowsByTableName = await this.loadRows(this.targetDb, keyValue)
86
+
87
+ await this.targetDb.withDisabledForeignKeys(async () => {
88
+ await this.targetDb.transaction(async () => {
89
+ await this.deleteTargetRows(targetRowsByTableName)
90
+ await this.insertTargetRows(sourceRowsByTableName)
91
+ })
92
+ })
93
+
94
+ return sourceRowsByTableName
95
+ }
96
+
97
+ /**
98
+ * Loads the rows for `keyValue` for every table in the plan from `db`, resolving
99
+ * parent-scoped tables from the ids already selected for their parent table. Used for
100
+ * both the source rows to copy and the target's current tenant rows to delete.
101
+ * @param {import("../drivers/base.js").default} db
102
+ * @param {string} keyValue
103
+ * @returns {Promise<Map<string, Record<string, unknown>[]>>}
104
+ */
105
+ async loadRows(db, keyValue) {
106
+ /** @type {Map<string, string[]>} */
107
+ const idsByTableName = new Map()
108
+ /** @type {Map<string, Record<string, unknown>[]>} */
109
+ const rowsByTableName = new Map()
110
+
111
+ for (const tableConfig of this.tablePlan) {
112
+ let rows
113
+
114
+ if (tableConfig.keyColumn) {
115
+ rows = await this.queryRowsByColumn({
116
+ columnName: tableConfig.keyColumn,
117
+ db,
118
+ tableName: tableConfig.tableName,
119
+ values: [keyValue]
120
+ })
121
+ } else {
122
+ if (!tableConfig.parentColumn || !tableConfig.parentTableName) {
123
+ throw new Error(`Expected keyColumn or parentTableName+parentColumn for table ${tableConfig.tableName} in the tenant table plan.`)
124
+ }
125
+
126
+ if (!idsByTableName.has(tableConfig.parentTableName)) {
127
+ throw new Error(`Tenant table plan entry ${tableConfig.tableName} references parent table ${tableConfig.parentTableName}, which has not been loaded; parent tables must appear before their children in the plan.`)
128
+ }
129
+
130
+ rows = await this.queryRowsByColumn({
131
+ columnName: tableConfig.parentColumn,
132
+ db,
133
+ tableName: tableConfig.tableName,
134
+ values: idsByTableName.get(tableConfig.parentTableName) || []
135
+ })
136
+ }
137
+
138
+ if (rows.length > 0) {
139
+ this.reportProgress(`${tableConfig.tableName}: loaded ${rows.length} row(s)`)
140
+ }
141
+
142
+ idsByTableName.set(tableConfig.tableName, uniqueStrings(rows.map((row) => row[this.idColumn])))
143
+ rowsByTableName.set(tableConfig.tableName, rows)
144
+ }
145
+
146
+ return rowsByTableName
147
+ }
148
+
149
+ /**
150
+ * Selects all rows of `tableName` in `db` whose `columnName` is in `values`, chunked.
151
+ * @param {{columnName: string, db: import("../drivers/base.js").default, tableName: string, values: string[]}} args
152
+ * @returns {Promise<Record<string, unknown>[]>}
153
+ */
154
+ async queryRowsByColumn({columnName, db, tableName, values}) {
155
+ const normalizedValues = uniqueStrings(values)
156
+
157
+ if (normalizedValues.length <= 0) {
158
+ return []
159
+ }
160
+
161
+ const rows = []
162
+ const quotedTable = db.quoteTable(tableName)
163
+ const quotedColumn = db.quoteColumn(columnName)
164
+
165
+ for (const valuesChunk of chunks(normalizedValues, this.queryChunkSize)) {
166
+ const sql = `SELECT ${quotedTable}.* FROM ${quotedTable} WHERE ${quotedColumn} IN (${this.quotedValuesSql(db, valuesChunk)})`
167
+
168
+ rows.push(...await this.executeQuietQuery(db, sql))
169
+ }
170
+
171
+ return rows
172
+ }
173
+
174
+ /**
175
+ * Deletes the matching target rows for every plan table, children before parents, so the
176
+ * reinsert that follows starts from a clean slate without violating foreign keys.
177
+ * @param {Map<string, Record<string, unknown>[]>} rowsByTableName
178
+ * @returns {Promise<void>}
179
+ */
180
+ async deleteTargetRows(rowsByTableName) {
181
+ for (const tableConfig of [...this.tablePlan].reverse()) {
182
+ const rowIds = uniqueStrings((rowsByTableName.get(tableConfig.tableName) || []).map((row) => row[this.idColumn]))
183
+
184
+ if (rowIds.length <= 0) {
185
+ continue
186
+ }
187
+
188
+ const quotedTable = this.targetDb.quoteTable(tableConfig.tableName)
189
+ const quotedIdColumn = this.targetDb.quoteColumn(this.idColumn)
190
+
191
+ for (const rowIdsChunk of chunks(rowIds, this.queryChunkSize)) {
192
+ await this.executeQuietQuery(
193
+ this.targetDb,
194
+ `DELETE FROM ${quotedTable} WHERE ${quotedIdColumn} IN (${this.quotedValuesSql(this.targetDb, rowIdsChunk)})`
195
+ )
196
+ }
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Inserts the loaded source rows into the target for every plan table, parents before
202
+ * children, chunked to bound statement size.
203
+ * @param {Map<string, Record<string, unknown>[]>} rowsByTableName
204
+ * @returns {Promise<void>}
205
+ */
206
+ async insertTargetRows(rowsByTableName) {
207
+ for (const tableConfig of this.tablePlan) {
208
+ const rows = rowsByTableName.get(tableConfig.tableName) || []
209
+
210
+ if (rows.length <= 0) {
211
+ continue
212
+ }
213
+
214
+ const columns = Object.keys(rows[0])
215
+ const insertChunks = chunks(rows, this.insertChunkSize)
216
+
217
+ this.reportProgress(`${tableConfig.tableName}: inserting ${rows.length} row(s) in ${insertChunks.length} chunk(s)`)
218
+
219
+ for (const rowsChunk of insertChunks) {
220
+ await this.insertRowsQuietly({
221
+ columns,
222
+ db: this.targetDb,
223
+ rows: rowsChunk.map((row) => columns.map((column) => row[column])),
224
+ tableName: tableConfig.tableName
225
+ })
226
+ }
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Quotes and comma-joins values for an SQL `IN (...)` list against the given database.
232
+ * @param {import("../drivers/base.js").default} db
233
+ * @param {string[]} values
234
+ * @returns {string}
235
+ */
236
+ quotedValuesSql(db, values) {
237
+ return values.map((value) => db.quote(value)).join(", ")
238
+ }
239
+
240
+ /**
241
+ * Runs a query without per-query logging, used for the high-volume copy statements.
242
+ * @param {import("../drivers/base.js").default} db
243
+ * @param {string} sql
244
+ * @returns {Promise<Record<string, unknown>[]>}
245
+ */
246
+ async executeQuietQuery(db, sql) {
247
+ return await db.query(sql, {logQuery: false})
248
+ }
249
+
250
+ /**
251
+ * Inserts column-aligned row tuples into a table without per-query logging.
252
+ * @param {{columns: string[], db: import("../drivers/base.js").default, rows: Array<Array<unknown>>, tableName: string}} args
253
+ * @returns {Promise<void>}
254
+ */
255
+ async insertRowsQuietly({columns, db, rows, tableName}) {
256
+ await this.executeQuietQuery(db, db.insertSql({columns, tableName, rows}))
257
+ }
258
+
259
+ /**
260
+ * Forwards a progress message to the optional `onProgress` callback when one was given.
261
+ * @param {string} message
262
+ * @returns {void}
263
+ */
264
+ reportProgress(message) {
265
+ if (this.onProgress) {
266
+ this.onProgress(message)
267
+ }
268
+ }
269
+ }