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
@@ -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
+ }
@@ -0,0 +1,390 @@
1
+ // @ts-check
2
+
3
+ import MigrationsLedger from "../migrations-ledger.js"
4
+ import TableData from "../table-data/index.js"
5
+ import TableIndex from "../table-data/table-index.js"
6
+
7
+ const SIMPLE_DEFAULT_PATTERN = /^(?:-?\d+(?:\.\d+)?|[A-Za-z0-9 _.,:/@+-]*)$/
8
+
9
+ /** @type {Record<string, number>} */
10
+ const TEXT_TYPE_RANKS = {
11
+ tinytext: 1,
12
+ text: 2,
13
+ mediumtext: 3,
14
+ longtext: 4
15
+ }
16
+
17
+ /**
18
+ * Clones table structure (columns, text-type widening, indexes) from a source database
19
+ * into a target database for a given set of tables, then baselines the target's
20
+ * `schema_migrations` ledger to the source via {@link MigrationsLedger}. This is the
21
+ * mechanism multi-tenant apps use to provision a tenant database from a template/global
22
+ * database without re-running migrations: the structure is copied and the ledger is
23
+ * recorded as already-applied, so a later `db:tenants:migrate` does not re-run an
24
+ * `addColumn` whose column already exists.
25
+ *
26
+ * The cloner is intentionally policy-free — the caller decides which tables to sync and
27
+ * which databases are source/target. It is idempotent: missing tables are created,
28
+ * missing columns added, too-narrow text columns widened, and missing indexes created;
29
+ * an index whose definition diverges from the source is treated as drift and throws.
30
+ */
31
+ export default class SchemaCloner {
32
+ /**
33
+ * Creates a cloner that copies table structure from `sourceDb` into `targetDb`.
34
+ * @param {{sourceDb: import("../drivers/base.js").default, targetDb: import("../drivers/base.js").default}} args
35
+ */
36
+ constructor({sourceDb, targetDb}) {
37
+ this.sourceDb = sourceDb
38
+ this.targetDb = targetDb
39
+ }
40
+
41
+ /**
42
+ * Clones every given table from the source into the target, then baselines the
43
+ * target's ledger so the cloned schema is recorded as already-migrated.
44
+ * @param {string[]} tableNames
45
+ * @returns {Promise<void>}
46
+ */
47
+ async syncTables(tableNames) {
48
+ for (const tableName of tableNames) {
49
+ await this.syncTable(tableName)
50
+ }
51
+
52
+ await this.reconcileLedger()
53
+ }
54
+
55
+ /**
56
+ * Clones a single table from the source into the target, creating it or adding and
57
+ * widening columns and indexes as needed.
58
+ * @param {string} tableName
59
+ * @returns {Promise<void>}
60
+ */
61
+ async syncTable(tableName) {
62
+ const sourceTable = await this.sourceDb.getTableByName(tableName)
63
+
64
+ if (!sourceTable) {
65
+ throw new Error(`Expected source table to exist: ${tableName}`)
66
+ }
67
+
68
+ if (!await this.targetDb.tableExists(tableName)) {
69
+ await this.createTargetTable({sourceTable, tableName})
70
+ return
71
+ }
72
+
73
+ const changedColumns = await this.ensureTargetColumns({sourceTable, tableName})
74
+
75
+ if (changedColumns) {
76
+ this.targetDb.clearSchemaCache()
77
+ }
78
+
79
+ await this.ensureTargetIndexes({sourceTable, tableName})
80
+ }
81
+
82
+ /**
83
+ * Creates the table in the target from the source table's columns and its
84
+ * non-primary-key indexes.
85
+ * @param {{sourceTable: import("../drivers/base-table.js").default, tableName: string}} args
86
+ * @returns {Promise<void>}
87
+ */
88
+ async createTargetTable({sourceTable, tableName}) {
89
+ const tableData = new TableData(tableName)
90
+
91
+ for (const sourceColumn of await sourceTable.getColumns()) {
92
+ tableData.addColumn(sourceColumn.getName(), this.columnArgsFromSourceColumn(sourceColumn, {isNewColumn: false}))
93
+ }
94
+
95
+ for (const sourceIndex of await sourceTable.getIndexes()) {
96
+ if (!sourceIndex.isPrimaryKey()) {
97
+ tableData.addIndex(this.tableDataIndexFromSourceIndex(sourceIndex))
98
+ }
99
+ }
100
+
101
+ await this.targetDb.createTable(tableData)
102
+ this.targetDb.clearSchemaCache()
103
+ }
104
+
105
+ /**
106
+ * Adds columns present on the source but missing from the target, and widens
107
+ * too-narrow target text columns.
108
+ * @param {{sourceTable: import("../drivers/base-table.js").default, tableName: string}} args
109
+ * @returns {Promise<boolean>} Whether any column was added or widened.
110
+ */
111
+ async ensureTargetColumns({sourceTable, tableName}) {
112
+ const sourceColumns = await sourceTable.getColumns()
113
+ const targetTable = await this.targetDb.getTableByNameOrFail(tableName)
114
+ const targetColumnsByName = new Map()
115
+ const missingColumns = []
116
+ const columnsNeedingWidening = []
117
+
118
+ for (const targetColumn of await targetTable.getColumns()) {
119
+ targetColumnsByName.set(targetColumn.getName(), targetColumn)
120
+ }
121
+
122
+ for (const sourceColumn of sourceColumns) {
123
+ const targetColumn = targetColumnsByName.get(sourceColumn.getName())
124
+
125
+ if (!targetColumn) {
126
+ missingColumns.push(sourceColumn)
127
+ } else if (this.columnNeedsWidening(sourceColumn, targetColumn)) {
128
+ columnsNeedingWidening.push(sourceColumn)
129
+ }
130
+ }
131
+
132
+ if (missingColumns.length <= 0 && columnsNeedingWidening.length <= 0) {
133
+ return false
134
+ }
135
+
136
+ const tableData = new TableData(tableName)
137
+
138
+ for (const sourceColumn of missingColumns) {
139
+ tableData.addColumn(sourceColumn.getName(), this.columnArgsFromSourceColumn(sourceColumn, {isNewColumn: true}))
140
+ }
141
+
142
+ for (const sourceColumn of columnsNeedingWidening) {
143
+ tableData.addColumn(sourceColumn.getName(), this.columnArgsFromSourceColumn(sourceColumn, {isNewColumn: false}))
144
+ }
145
+
146
+ for (const alterSql of await this.targetDb.alterTableSQLs(tableData)) {
147
+ await this.targetDb.query(alterSql)
148
+ }
149
+
150
+ return true
151
+ }
152
+
153
+ /**
154
+ * Creates non-primary-key indexes present on the source but missing from the target,
155
+ * and throws if an existing target index diverges from the source.
156
+ * @param {{sourceTable: import("../drivers/base-table.js").default, tableName: string}} args
157
+ * @returns {Promise<void>}
158
+ */
159
+ async ensureTargetIndexes({sourceTable, tableName}) {
160
+ const targetTable = await this.targetDb.getTableByNameOrFail(tableName)
161
+ /** @type {Map<string, import("../drivers/base-columns-index.js").default>} */
162
+ const targetIndexesByName = new Map()
163
+ const targetIndexSignatures = new Set()
164
+ let createdIndex = false
165
+
166
+ for (const targetIndex of await targetTable.getIndexes()) {
167
+ targetIndexesByName.set(targetIndex.getName(), targetIndex)
168
+ targetIndexSignatures.add(this.indexSignature(targetIndex))
169
+ }
170
+
171
+ for (const sourceIndex of await sourceTable.getIndexes()) {
172
+ if (sourceIndex.isPrimaryKey()) {
173
+ continue
174
+ }
175
+
176
+ const sourceIndexSignature = this.indexSignature(sourceIndex)
177
+
178
+ // SQLite index names are unique per-database, not per-table, so match cloned
179
+ // indexes by their column/uniqueness signature rather than their name.
180
+ if (this.targetDb.getType() === "sqlite" && targetIndexSignatures.has(sourceIndexSignature)) {
181
+ continue
182
+ }
183
+
184
+ const targetIndex = this.targetDb.getType() === "sqlite" ? undefined : targetIndexesByName.get(sourceIndex.getName())
185
+
186
+ if (!targetIndex) {
187
+ const createIndexSqls = await this.targetDb.createIndexSQLs(this.createIndexArgsFromSourceIndex({sourceIndex, tableName}))
188
+
189
+ for (const createIndexSql of createIndexSqls) {
190
+ await this.targetDb.query(createIndexSql)
191
+ }
192
+
193
+ createdIndex = true
194
+ targetIndexSignatures.add(sourceIndexSignature)
195
+ continue
196
+ }
197
+
198
+ if (!this.indexesMatch(sourceIndex, targetIndex)) {
199
+ throw new Error(`Schema clone index drift for ${tableName}.${sourceIndex.getName()}`)
200
+ }
201
+ }
202
+
203
+ if (createdIndex) {
204
+ this.targetDb.clearSchemaCache()
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Baselines the target ledger so the cloned schema is recorded as already-applied.
210
+ * @returns {Promise<string[]>} The versions newly recorded on the target.
211
+ */
212
+ async reconcileLedger() {
213
+ return await MigrationsLedger.baselineFromDatabase({sourceDb: this.sourceDb, targetDb: this.targetDb})
214
+ }
215
+
216
+ /**
217
+ * Whether the target ledger is missing any version applied on the source — i.e. the
218
+ * target schema may have been advanced out of band without recording it.
219
+ * @returns {Promise<boolean>}
220
+ */
221
+ async ledgerDriftsFromSource() {
222
+ if (!await MigrationsLedger.tableExists(this.targetDb)) {
223
+ return true
224
+ }
225
+
226
+ const sourceVersions = await MigrationsLedger.appliedVersions(this.sourceDb)
227
+ const targetVersionSet = new Set(await MigrationsLedger.appliedVersions(this.targetDb))
228
+
229
+ return sourceVersions.some((version) => !targetVersionSet.has(version))
230
+ }
231
+
232
+ /**
233
+ * Maps a source index into a TableData index for table creation (SQLite omits the
234
+ * index name so the driver can generate a unique one).
235
+ * @param {import("../drivers/base-columns-index.js").default} sourceIndex
236
+ * @returns {TableIndex}
237
+ */
238
+ tableDataIndexFromSourceIndex(sourceIndex) {
239
+ /** @type {{name?: string, unique: boolean}} */
240
+ const args = {unique: sourceIndex.isUnique()}
241
+
242
+ // SQLite index names are unique per-database, not per-table, so let the driver
243
+ // generate one; other drivers preserve the source index name. Build the TableIndex
244
+ // directly (rather than via the driver's getTableDataIndex, which only MySQL and
245
+ // SQLite implement) so cloning a PostgreSQL or MS-SQL source table works too.
246
+ if (this.targetDb.getType() !== "sqlite") {
247
+ args.name = sourceIndex.getName()
248
+ }
249
+
250
+ return new TableIndex(sourceIndex.getColumnNames(), args)
251
+ }
252
+
253
+ /**
254
+ * Builds driver create-index args from a source index (the index name is omitted on
255
+ * SQLite, where index names are unique per-database rather than per-table).
256
+ * @param {{sourceIndex: import("../drivers/base-columns-index.js").default, tableName: string}} args
257
+ * @returns {{columns: string[], name?: string, tableName: string, unique: boolean}}
258
+ */
259
+ createIndexArgsFromSourceIndex({sourceIndex, tableName}) {
260
+ /** @type {{columns: string[], name?: string, tableName: string, unique: boolean}} */
261
+ const createIndexArgs = {
262
+ columns: sourceIndex.getColumnNames(),
263
+ tableName,
264
+ unique: sourceIndex.isUnique()
265
+ }
266
+
267
+ if (this.targetDb.getType() !== "sqlite") {
268
+ createIndexArgs.name = sourceIndex.getName()
269
+ }
270
+
271
+ return createIndexArgs
272
+ }
273
+
274
+ /**
275
+ * Whether two indexes have the same uniqueness and ordered column list.
276
+ * @param {import("../drivers/base-columns-index.js").default} sourceIndex
277
+ * @param {import("../drivers/base-columns-index.js").default} targetIndex
278
+ * @returns {boolean}
279
+ */
280
+ indexesMatch(sourceIndex, targetIndex) {
281
+ const sourceColumnNames = sourceIndex.getColumnNames()
282
+ const targetColumnNames = targetIndex.getColumnNames()
283
+
284
+ if (sourceIndex.isUnique() !== targetIndex.isUnique()) {
285
+ return false
286
+ }
287
+
288
+ if (sourceColumnNames.length !== targetColumnNames.length) {
289
+ return false
290
+ }
291
+
292
+ for (let columnIndex = 0; columnIndex < sourceColumnNames.length; columnIndex++) {
293
+ if (sourceColumnNames[columnIndex] !== targetColumnNames[columnIndex]) {
294
+ return false
295
+ }
296
+ }
297
+
298
+ return true
299
+ }
300
+
301
+ /**
302
+ * A stable signature for an index, used to match cloned indexes by shape.
303
+ * @param {import("../drivers/base-columns-index.js").default} index
304
+ * @returns {string}
305
+ */
306
+ indexSignature(index) {
307
+ return `${index.isUnique() ? "unique" : "index"}:${index.getColumnNames().join(",")}`
308
+ }
309
+
310
+ /**
311
+ * Normalizes a column type to its canonical lowercase form (`int` becomes `integer`).
312
+ * @param {string} columnType
313
+ * @returns {string}
314
+ */
315
+ normalizedColumnType(columnType) {
316
+ const normalizedType = columnType.toLowerCase()
317
+
318
+ if (normalizedType === "int") {
319
+ return "integer"
320
+ }
321
+
322
+ return normalizedType
323
+ }
324
+
325
+ /**
326
+ * The widening rank of a text column type (0 when not a text type).
327
+ * @param {string} columnType
328
+ * @returns {number}
329
+ */
330
+ textTypeRank(columnType) {
331
+ return TEXT_TYPE_RANKS[this.normalizedColumnType(columnType)] || 0
332
+ }
333
+
334
+ /**
335
+ * Whether the target's text column is narrower than the source's and must be widened.
336
+ * @param {import("../drivers/base-column.js").default} sourceColumn
337
+ * @param {import("../drivers/base-column.js").default} targetColumn
338
+ * @returns {boolean}
339
+ */
340
+ columnNeedsWidening(sourceColumn, targetColumn) {
341
+ const sourceRank = this.textTypeRank(sourceColumn.getType())
342
+ const targetRank = this.textTypeRank(targetColumn.getType())
343
+
344
+ return sourceRank > 0 && targetRank > 0 && sourceRank > targetRank
345
+ }
346
+
347
+ /**
348
+ * Builds TableData column args from a source column, copying type, nullability,
349
+ * length, notes, simple defaults and (for full clones) primary-key flag.
350
+ * @param {import("../drivers/base-column.js").default} sourceColumn
351
+ * @param {{isNewColumn: boolean}} args
352
+ * @returns {Record<string, unknown>}
353
+ */
354
+ columnArgsFromSourceColumn(sourceColumn, {isNewColumn}) {
355
+ /** @type {{autoIncrement?: boolean, default?: unknown, isNewColumn: boolean, maxLength?: number, notes?: string, null: boolean, primaryKey?: boolean, type: string}} */
356
+ const columnArgs = {
357
+ isNewColumn,
358
+ null: sourceColumn.getNull(),
359
+ type: this.normalizedColumnType(sourceColumn.getType())
360
+ }
361
+ const defaultValue = sourceColumn.getDefault()
362
+ const maxLength = sourceColumn.getMaxLength()
363
+ const notes = sourceColumn.getNotes()
364
+
365
+ if (!isNewColumn && sourceColumn.getPrimaryKey()) {
366
+ columnArgs.primaryKey = true
367
+ }
368
+
369
+ if (sourceColumn.getAutoIncrement()) {
370
+ columnArgs.autoIncrement = true
371
+ }
372
+
373
+ // A maxLength of -1 is the MS-SQL "max" sentinel (NVARCHAR(MAX) / VARBINARY(MAX),
374
+ // backing Velocious text/json/blob columns); the column type drives the unbounded
375
+ // SQL, so don't forward -1 as an explicit length (it would emit NVARCHAR(-1)).
376
+ if (maxLength !== undefined && maxLength >= 0) {
377
+ columnArgs.maxLength = maxLength
378
+ }
379
+
380
+ if (notes) {
381
+ columnArgs.notes = notes
382
+ }
383
+
384
+ if (defaultValue !== null && defaultValue !== undefined && SIMPLE_DEFAULT_PATTERN.test(String(defaultValue))) {
385
+ columnArgs.default = defaultValue
386
+ }
387
+
388
+ return columnArgs
389
+ }
390
+ }
@@ -0,0 +1,20 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Describes how one table's rows are partitioned between tenants so a DataCopier can select
5
+ * the subset that belongs to a given tenant key.
6
+ *
7
+ * A row belongs to the tenant when its `keyColumn` equals the tenant key value, or — for
8
+ * tables that have no direct tenant column — when its `parentColumn` references a row of
9
+ * `parentTableName` that was itself already selected as belonging to the tenant. Exactly
10
+ * one of `keyColumn` or the (`parentTableName` + `parentColumn`) pair must be set; a
11
+ * parent-scoped entry must appear after its parent in the plan so the parent ids are known
12
+ * by the time the child is loaded.
13
+ * @typedef {object} TenantTablePlanEntry
14
+ * @property {string} tableName Name of the table whose rows are partitioned by tenant.
15
+ * @property {string} [keyColumn] Column matched directly against the tenant key value.
16
+ * @property {string} [parentTableName] Earlier-in-plan table whose selected rows scope this one.
17
+ * @property {string} [parentColumn] Column on this table referencing the parent table's primary key.
18
+ */
19
+
20
+ export {}