json-rest-api 2.4.0 → 3.0.0

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 (83) hide show
  1. package/README.md +7 -2
  2. package/docs/API.md +262 -54
  3. package/docs/GUIDE/01-getting-started.md +1 -1
  4. package/docs/GUIDE/07-polymorphic-relationships.md +4 -2
  5. package/docs/GUIDE/09-pagination-and-sorting.md +4 -2
  6. package/docs/GUIDE/13-hooks-and-lifecycle.md +34 -6
  7. package/docs/GUIDE/14-custom-resource-methods.md +3 -2
  8. package/docs/GUIDE/16-autofiltering.md +54 -2
  9. package/docs/GUIDE/17-row-policies.md +15 -5
  10. package/docs/GUIDE/19-managed-transactions.md +8 -9
  11. package/docs/GUIDE/20-transaction-outcomes.md +3 -4
  12. package/docs/GUIDE/21-schema-and-migrations.md +7 -1
  13. package/docs/GUIDE/22-version-field-migrations.md +3 -3
  14. package/docs/GUIDE/27-many-row-writes.md +458 -0
  15. package/docs/GUIDE/28-socketio.md +10 -2
  16. package/docs/GUIDE/30-backend-capabilities.md +42 -2
  17. package/docs/GUIDE/31-positioning.md +3 -1
  18. package/docs/GUIDE/33-migrating-to-v2.md +2 -2
  19. package/docs/GUIDE/34-scoped-reads-and-locks.md +464 -0
  20. package/docs/GUIDE/35-migrating-to-v3.md +325 -0
  21. package/docs/GUIDE/index.md +5 -2
  22. package/docs/QUICKSTART.md +2 -2
  23. package/docs/contributing.md +1 -1
  24. package/index.d.ts +8 -1
  25. package/index.js +4 -0
  26. package/lib/rest-api-errors.js +79 -0
  27. package/lib/runtime/json-rest-api.js +18 -3
  28. package/lib/system-context.js +42 -0
  29. package/package.json +3 -2
  30. package/plugins/core/bulk-operations-plugin.js +54 -436
  31. package/plugins/core/connectors/lib/transport-http-helpers.js +34 -3
  32. package/plugins/core/file-handling-plugin.js +17 -2
  33. package/plugins/core/lib/anyapi/anyapi-registry.js +3 -2
  34. package/plugins/core/lib/anyapi/canonical-link-store.js +5 -2
  35. package/plugins/core/lib/anyapi/canonical-relationship-reader.js +56 -60
  36. package/plugins/core/lib/dbIntrospection.js +106 -6
  37. package/plugins/core/lib/querying/aggregate-query.js +215 -22
  38. package/plugins/core/lib/querying/include-query-helpers.js +11 -13
  39. package/plugins/core/lib/querying/include-to-one.js +54 -59
  40. package/plugins/core/lib/querying/knex-pagination-helpers.js +9 -2
  41. package/plugins/core/lib/querying/query-constraint.d.ts +11 -0
  42. package/plugins/core/lib/querying/query-constraint.js +45 -0
  43. package/plugins/core/lib/querying/read-only-query.js +92 -0
  44. package/plugins/core/lib/querying-writing/compile-schemas.js +14 -0
  45. package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
  46. package/plugins/core/lib/storage/canonical-data-helpers.js +103 -39
  47. package/plugins/core/lib/storage/many-row-data-helpers.js +738 -0
  48. package/plugins/core/lib/storage/ordinary-data-helpers.js +63 -79
  49. package/plugins/core/lib/storage/single-row-writes.js +71 -0
  50. package/plugins/core/lib/storage/storage-adapter.js +18 -8
  51. package/plugins/core/lib/storage/storage-mapping.js +4 -1
  52. package/plugins/core/lib/storage/storage-types.d.ts +21 -3
  53. package/plugins/core/lib/writing/many-to-many-manipulations.js +10 -5
  54. package/plugins/core/lib/writing/relationship-processor.js +33 -37
  55. package/plugins/core/rest-api-anyapi-knex-plugin.js +3 -0
  56. package/plugins/core/rest-api-autofilter-plugin.js +137 -10
  57. package/plugins/core/rest-api-knex-plugin.js +2 -0
  58. package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
  59. package/plugins/core/rest-api-plugin-methods/common.js +144 -84
  60. package/plugins/core/rest-api-plugin-methods/delete-many.js +132 -0
  61. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +22 -0
  62. package/plugins/core/rest-api-plugin-methods/get.js +48 -42
  63. package/plugins/core/rest-api-plugin-methods/many-row-common.js +274 -0
  64. package/plugins/core/rest-api-plugin-methods/patch-many.js +333 -0
  65. package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
  66. package/plugins/core/rest-api-plugin-methods/post-many.js +295 -0
  67. package/plugins/core/rest-api-plugin-methods/put.js +5 -1
  68. package/plugins/core/rest-api-plugin-methods/scoped-reads.js +221 -0
  69. package/plugins/core/rest-api-plugin.js +17 -0
  70. package/plugins/core/rest-api-positioning-plugin.js +22 -4
  71. package/plugins/core/rest-api-query-projections-plugin.js +9 -1
  72. package/plugins/core/rest-api-row-policy-plugin.js +4 -0
  73. package/plugins/core/socketio-plugin.js +80 -2
  74. package/types/autofilter.d.ts +5 -1
  75. package/types/data-access-methods.d.ts +141 -0
  76. package/types/errors.d.ts +45 -0
  77. package/types/hook-context.d.ts +21 -0
  78. package/types/plugin-options.d.ts +3 -1
  79. package/types/resource-methods.d.ts +27 -4
  80. package/types/row-policy.d.ts +3 -0
  81. package/types/runtime.d.ts +7 -1
  82. package/docs/GUIDE/27-bulk-operations.md +0 -277
  83. package/types/bulk-methods.d.ts +0 -63
@@ -0,0 +1,738 @@
1
+ // Many-row storage for table-backed resources: scoped builders, visible-id reads,
2
+ // multi-row inserts, by-id CASE updates, by-id deletes and row locks. Every write
3
+ // runs one statement per chunk; none of them runs through the filter hooks, so
4
+ // MySQL never sees an UPDATE or DELETE whose subquery reads its own table.
5
+ import { RestApiBulkInsertIdsError, RestApiBulkWriteUnsupportedError, RestApiResourceError, RestApiValidationError } from '../../../../lib/rest-api-errors.js'
6
+ import { withQueryFilteringContext } from '../querying/query-builder-utils.js'
7
+ import { ANY_OWNER, applyWriteConditions, hasWriteConditions, writeConditions } from '../querying/query-constraint.js'
8
+ import { applyDatabaseReadOptions, databaseIdentityExpression, normalizeStoredFieldValue } from '../querying-writing/database-value-normalizers.js'
9
+ import { currentKnexMysqlSchema, listKnexMysqlIndexes, listKnexUniqueKeyColumns } from '../dbIntrospection.js'
10
+ import { applyFieldSelectionToQuery, buildFieldSelection } from '../querying-writing/knex-field-helpers.js'
11
+ import { applyInsertReturning } from '../querying-writing/database-capabilities.js'
12
+ import { loadRelationshipIdentifiers } from '../querying/relationship-identifiers.js'
13
+ import { buildJsonApiResponse } from '../querying/knex-json-api-transformers-querying.js'
14
+ import { deletePivotReferences } from '../writing/many-to-many-manipulations.js'
15
+
16
+ // Bound parameters one statement may carry, with headroom for predicates.
17
+ const PLACEHOLDER_LIMITS = { sqlite: 32000, pg: 65000, mysql: 65000 }
18
+ // Knex writes a SQLite multi-row INSERT as SELECT … UNION ALL, and SQLite caps a compound SELECT at 500 terms.
19
+ const SQLITE_INSERT_ROWS = 500
20
+
21
+ /** @param {import('knex').Knex} db */
22
+ export function databaseFamily (db) {
23
+ const client = String(db.client.config.client)
24
+ if (client.includes('sqlite')) return 'sqlite'
25
+ if (client === 'pg' || client === 'postgresql') return 'pg'
26
+ return 'mysql'
27
+ }
28
+
29
+ // The mysql and mysql2 drivers' client flag that makes an UPDATE count the rows it matched.
30
+ const FOUND_ROWS = 0x2
31
+
32
+ /**
33
+ * Refuses a selection write on MySQL or MariaDB whose scope joins a derived
34
+ * table, as a filter on another table's field does: InnoDB reads a derived
35
+ * table from the snapshot without locking it, so the write could not re-check
36
+ * that filter on a row it waited for.
37
+ * @param {{ db: any, scope: any, filters?: Record<string, unknown>, method: string, scopeName: string }} request
38
+ */
39
+ function refuseUnlockedSelection ({ db, scope, filters, method, scopeName }) {
40
+ if (!filters || !Object.keys(filters).length || databaseFamily(db) !== 'mysql') return
41
+ if (!scope._statements.some(statement => statement.grouping === 'join' && typeof statement.table !== 'string')) return
42
+ const message = `${method} on '${scopeName}' filters on another table, which MySQL and MariaDB read from the transaction snapshot without locking it; filter on the row's own columns instead, for example on a link after locking its targets with lockByIds`
43
+ throw new RestApiValidationError(message, { fields: ['filters'], violations: [{ field: 'filters', rule: 'selection_cross_table', message }] })
44
+ }
45
+
46
+ /**
47
+ * Whether an UPDATE's count is the rows it matched. PostgreSQL and SQLite
48
+ * always count those; MySQL and MariaDB do so only on a connection with the
49
+ * FOUND_ROWS client flag, the drivers' default, and count the rows it changed
50
+ * otherwise. A connection this cannot see counts as not.
51
+ * @param {any} db
52
+ */
53
+ export async function countsMatchedRows (db) {
54
+ if (databaseFamily(db) !== 'mysql') return true
55
+ if (!db.isTransaction) return false
56
+ // A transaction's client hands back its own connection.
57
+ const connection = await db.client.acquireConnection()
58
+ return ((connection?.config?.clientFlags ?? 0) & FOUND_ROWS) !== 0
59
+ }
60
+
61
+ /** @param {{ dbInfo?: { version?: string } } | undefined} capabilities */
62
+ function supportsMariaDbReturning (capabilities) {
63
+ const version = String(capabilities?.dbInfo?.version || '').toLowerCase()
64
+ if (!version.includes('mariadb')) return false
65
+ const [major, minor] = version.split(/[.-]/).map(Number)
66
+ return major > 10 || (major === 10 && minor >= 5)
67
+ }
68
+
69
+ /**
70
+ * Rows per statement: the requested chunk size, lowered so a statement's
71
+ * bound parameters (and a SQLite INSERT's compound terms) stay under the dialect's limit.
72
+ * @param {import('knex').Knex} db
73
+ * @param {number} chunkSize
74
+ * @param {number} bindingsPerRow
75
+ * @param {{ insert?: boolean }} [options]
76
+ */
77
+ export function rowsPerStatement (db, chunkSize, bindingsPerRow, { insert = false } = {}) {
78
+ const family = databaseFamily(db)
79
+ const rows = Math.min(chunkSize, Math.floor(PLACEHOLDER_LIMITS[family] / Math.max(1, bindingsPerRow)))
80
+ return Math.max(1, insert && family === 'sqlite' ? Math.min(rows, SQLITE_INSERT_ROWS) : rows)
81
+ }
82
+
83
+ /** @template T @param {T[]} items @param {number} size @returns {T[][]} */
84
+ export function chunk (items, size) {
85
+ const chunks = []
86
+ for (let offset = 0; offset < items.length; offset += size) chunks.push(items.slice(offset, offset + size))
87
+ return chunks
88
+ }
89
+
90
+ /**
91
+ * A Knex raw expression. JSON input cannot produce one: the check needs its
92
+ * toSQL function, not only the isRawInstance flag.
93
+ * @param {unknown} value @returns {boolean}
94
+ */
95
+ export function isRawValue (value) {
96
+ const raw = /** @type {{ isRawInstance?: boolean, toSQL?: unknown } | null} */ (value)
97
+ return Boolean(raw && typeof raw === 'object' && raw.isRawInstance === true && typeof raw.toSQL === 'function')
98
+ }
99
+
100
+ /**
101
+ * Conflict keys compare exactly, in their public form: drivers return booleans
102
+ * as 1 and 0, numbers as strings and dates in their own shapes, so each value
103
+ * goes through its field's read normalization first. Strings compare as they
104
+ * are, without the column's collation. A field with a storage serializer
105
+ * compares its stored form.
106
+ * @param {Record<string, any>} schemaStructure
107
+ * @param {string[]} keys
108
+ * @param {string[]} keyColumns
109
+ */
110
+ function conflictKeyMatcher (schemaStructure, keys, keyColumns) {
111
+ const definitions = keys.map(field => schemaStructure[field] || {})
112
+ const canonical = (value, index) => {
113
+ if (value === null || value === undefined) return null
114
+ const definition = definitions[index]
115
+ const normal = definition.storage?.serialize ? value : normalizeStoredFieldValue(value, definition, { fieldName: keys[index] })
116
+ if (normal === null || normal === undefined) return null
117
+ if (normal instanceof Date) return normal.toISOString()
118
+ if (['number', 'integer'].includes(definition.type) && typeof normal === 'string') {
119
+ return /^-?\d+$/.test(normal) ? String(BigInt(normal)) : String(Number(normal))
120
+ }
121
+ return String(normal)
122
+ }
123
+ return {
124
+ /** A row's key from its public attributes, or its storage row for serialized fields. */
125
+ input: (attributes, storageRow) => JSON.stringify(keys.map((field, index) =>
126
+ canonical(definitions[index].storage?.serialize ? storageRow[keyColumns[index]] : attributes[field], index))),
127
+ /** A key read from the table. */
128
+ stored: row => JSON.stringify(keyColumns.map((column, index) => canonical(row[column], index)))
129
+ }
130
+ }
131
+
132
+ /**
133
+ * @param {object} dependencies
134
+ * @param {any} dependencies.api
135
+ * @param {(scopeName: string) => any} dependencies.getScopeStorageAdapter
136
+ * @param {Record<string, any>} dependencies.scopes
137
+ * @param {any} dependencies.log
138
+ */
139
+ export function createManyRowDataHelpers ({ api, getScopeStorageAdapter, scopes, log }) {
140
+ const adapterFor = scopeName => {
141
+ const adapter = getScopeStorageAdapter(scopeName)
142
+ if (!adapter) throw new Error(`Storage adapter for resource '${scopeName}' not found`)
143
+ return adapter
144
+ }
145
+
146
+ // Tables and key columns a unique index was found to cover. A missing index is
147
+ // looked for again on the next call, so adding one needs no restart.
148
+ const coveredKeys = new Set()
149
+ /**
150
+ * Refuses conflict keys that no unique index covers. Without one, the keys
151
+ * could match several rows, and a locking read on them would examine (and on
152
+ * MySQL lock) every row of the table.
153
+ */
154
+ const requireUniqueKeys = async ({ scopeName, db, tableName, keyColumns }) => {
155
+ const cacheKey = JSON.stringify([tableName, [...keyColumns].sort()])
156
+ if (coveredKeys.has(cacheKey)) return
157
+ const uniqueKeys = await listKnexUniqueKeyColumns(db, tableName)
158
+ if (!uniqueKeys.some(columns => columns.every(column => keyColumns.includes(column)))) {
159
+ throw new RestApiBulkWriteUnsupportedError({
160
+ resourceType: scopeName,
161
+ operation: 'postMany',
162
+ reason: `no unique index of '${tableName}' covers the onConflict keys (${keyColumns.join(', ')}); add one, so that the keys identify one row`
163
+ })
164
+ }
165
+ coveredKeys.add(cacheKey)
166
+ }
167
+
168
+ /**
169
+ * The resource's base query with ownership, row policy and `filters` applied,
170
+ * as `{ query, alias }`: a builder is thenable, so an async function cannot return it bare.
171
+ * @param {{ scopeName: string, context: any, runHooks: Function, alias?: string, purpose: string, filters?: Record<string, unknown> }} request
172
+ */
173
+ const dataScopedQuery = async ({ scopeName, context, runHooks, alias, purpose, filters }) => {
174
+ const storageAdapter = adapterFor(scopeName)
175
+ context.storageAdapter = storageAdapter
176
+ const db = context.db || api.knex.instance
177
+ const tableName = storageAdapter.getTableName()
178
+ const tableAlias = alias || tableName
179
+ const state = await withQueryFilteringContext(context, {
180
+ query: storageAdapter.buildBaseQuery({ transaction: db, tableAlias: tableAlias === tableName ? undefined : tableAlias }),
181
+ filters,
182
+ schemaInfo: context.schemaInfo,
183
+ scopeName,
184
+ tableName: tableAlias,
185
+ db,
186
+ queryPurpose: purpose,
187
+ adapter: storageAdapter,
188
+ storageAdapter
189
+ }, () => runHooks('knexQueryFiltering'))
190
+ return { query: state.query, alias: tableAlias }
191
+ }
192
+
193
+ /**
194
+ * Identifiers the caller may touch, in id order: those of `ids` that are
195
+ * visible, or one keyset page of the rows `filters` select.
196
+ * @param {{ scopeName: string, context: any, runHooks: Function, ids?: unknown[], filters?: Record<string, unknown>, queryPurpose: string, after?: unknown, limit?: number }} request
197
+ * @returns {Promise<Array<string | number>>}
198
+ */
199
+ const dataVisibleIds = async ({ scopeName, context, runHooks, ids, filters, queryPurpose, after, limit }) => {
200
+ const storageAdapter = adapterFor(scopeName)
201
+ const db = context.db || api.knex.instance
202
+ const tableName = storageAdapter.getTableName()
203
+ const idColumn = `${tableName}.${storageAdapter.getIdColumn()}`
204
+ const { query } = await dataScopedQuery({ scopeName, context, runHooks, purpose: queryPurpose, filters })
205
+ // A selection write reads its rows here first, so it is refused here whatever rows match.
206
+ if (queryPurpose === 'bulk-write') refuseUnlockedSelection({ db, scope: query, filters, method: context.method, scopeName })
207
+ if (ids) query.whereIn(idColumn, ids.map(id => storageAdapter.translateFilterValue('id', id)))
208
+ if (after !== undefined) query.where(idColumn, '>', storageAdapter.translateFilterValue('id', after))
209
+ // Policy joins can repeat a row; the identity projection keeps big integers exact.
210
+ query.distinct({ id: databaseIdentityExpression(db, idColumn) }).orderBy(idColumn)
211
+ if (limit) query.limit(limit)
212
+ const rows = await applyDatabaseReadOptions(query)
213
+ return rows.map(row => row.id)
214
+ }
215
+
216
+ /**
217
+ * The stored identifier of the row each input row matches by `keys`, or
218
+ * undefined, read with a row lock (SQLite's single writer needs none), one
219
+ * statement per chunk. Upserts decide from it which rows to insert and which
220
+ * to merge.
221
+ * @param {{ scopeName: string, context: any, rows: Record<string, unknown>[], keys: string[], chunkSize: number }} request
222
+ * @returns {Promise<Array<string | number | undefined>>}
223
+ */
224
+ const dataIdsByKeys = async ({ scopeName, context, rows, keys, chunkSize }) => {
225
+ const storageAdapter = adapterFor(scopeName)
226
+ const db = context.transaction
227
+ const tableName = storageAdapter.getTableName()
228
+ const keyColumns = keys.map(field => storageAdapter.translateColumn(field))
229
+ await requireUniqueKeys({ scopeName, db, tableName, keyColumns })
230
+ await takeSqliteWriteLock(db, tableName, storageAdapter.getIdColumn())
231
+ const storageRows = rows.map(attributes => storageAdapter.toStorageRow(attributes, { context, operation: 'post' }))
232
+ const matcher = conflictKeyMatcher(scopes[scopeName].vars.schemaInfo.schemaStructure, keys, keyColumns)
233
+ const inputKeys = rows.map((attributes, index) => matcher.input(attributes, storageRows[index]))
234
+ const wanted = new Set(inputKeys)
235
+ const found = new Map()
236
+ for (const part of chunk(storageRows, rowsPerStatement(db, chunkSize, keyColumns.length))) {
237
+ const query = db(tableName)
238
+ .whereIn(keyColumns, part.map(row => keyColumns.map(column => row[column] ?? null)))
239
+ .select({ id: databaseIdentityExpression(db, storageAdapter.getIdColumn()) }, ...keyColumns)
240
+ for (const row of await applyDatabaseReadOptions(lockRows(query, db))) {
241
+ const key = matcher.stored(row)
242
+ // The database matched by its own comparison (a collation, say); merging or skipping would guess.
243
+ if (!wanted.has(key)) {
244
+ throw new RestApiResourceError(`A stored ${scopeName} record matches the onConflict keys (${keys.join(', ')}) only as the database compares them, for example ignoring letter case; upsert keys must match exactly`, {
245
+ subtype: 'conflict', resourceType: scopeName
246
+ })
247
+ }
248
+ found.set(key, row.id)
249
+ }
250
+ }
251
+ return inputKeys.map(key => found.get(key))
252
+ }
253
+
254
+ /**
255
+ * Insert validated rows with one statement per chunk and column set.
256
+ * Returns the stored identifier for each input row (undefined for rows an
257
+ * ignore conflict skipped), or nothing when `needIds` is false.
258
+ * @param {{ scopeName: string, context: any, rows: Record<string, unknown>[], chunkSize: number, needIds: boolean, onConflict?: { keys: string[], action: 'ignore' } }} request
259
+ * @returns {Promise<Array<string | number | undefined> | undefined>}
260
+ */
261
+ const dataInsertMany = async ({ scopeName, context, rows, chunkSize, needIds, onConflict }) => {
262
+ const storageAdapter = adapterFor(scopeName)
263
+ const db = context.transaction
264
+ const tableName = storageAdapter.getTableName()
265
+ const idColumn = storageAdapter.getIdColumn()
266
+ const family = databaseFamily(db)
267
+ /** @type {Array<string | number | undefined>} */
268
+ const ids = new Array(rows.length)
269
+
270
+ if (onConflict && family === 'mysql') {
271
+ // ON DUPLICATE KEY fires on every unique index, the primary key included:
272
+ // lock the rows that match by the keys, then insert only the others with a
273
+ // plain INSERT, so a clash on any other unique index fails the batch.
274
+ const existing = await dataIdsByKeys({ scopeName, context, rows, keys: onConflict.keys, chunkSize })
275
+ const missing = rows.flatMap((_row, index) => existing[index] === undefined ? [index] : [])
276
+ const inserted = missing.length
277
+ ? await dataInsertMany({ scopeName, context, rows: missing.map(index => rows[index]), chunkSize, needIds })
278
+ : []
279
+ missing.forEach((index, position) => { ids[index] = inserted?.[position] })
280
+ return needIds ? ids : undefined
281
+ }
282
+
283
+ const storageRows = rows.map(attributes => storageAdapter.toStorageRow(attributes, { context, operation: 'post' }))
284
+ const keyColumns = onConflict?.keys.map(field => storageAdapter.translateColumn(field))
285
+
286
+ // Rows with the same columns share a statement; SQLite cannot insert DEFAULT for a missing value.
287
+ const groups = new Map()
288
+ storageRows.forEach((row, index) => {
289
+ const signature = Object.keys(row).sort().join('\u0000')
290
+ if (!groups.has(signature)) groups.set(signature, [])
291
+ groups.get(signature).push(index)
292
+ })
293
+ let increment
294
+ for (const indexes of groups.values()) {
295
+ const columns = Object.keys(storageRows[indexes[0]])
296
+ for (const part of chunk(indexes, rowsPerStatement(db, chunkSize, columns.length + 1, { insert: true }))) {
297
+ const values = part.map(index => storageRows[index])
298
+ let query = db(tableName).insert(values)
299
+ // Only a clash on the conflict keys is skipped; any other unique index still fails.
300
+ if (onConflict) query = query.onConflict(keyColumns).ignore()
301
+
302
+ if (!needIds) {
303
+ await query
304
+ continue
305
+ }
306
+
307
+ if (family === 'mysql' && supportsMariaDbReturning(api.knex.capabilities)) {
308
+ const [returned] = await db.raw('? returning ??', [query, idColumn])
309
+ returned.forEach((row, position) => { ids[part[position]] = row[idColumn] })
310
+ continue
311
+ }
312
+
313
+ if (family === 'mysql') {
314
+ if (increment === undefined) {
315
+ const [[setting]] = await db.raw('select @@session.auto_increment_increment as step')
316
+ increment = Number(setting.step)
317
+ }
318
+ const explicit = values.every(row => row[idColumn] !== undefined && row[idColumn] !== null)
319
+ const [firstId] = await query
320
+ const stored = explicit
321
+ ? values.map(row => row[idColumn])
322
+ : await verifyMysqlInsertIds({ db, tableName, idColumn, values, firstId, increment, scopeName, schemaInfo: context.schemaInfo, storageAdapter })
323
+ part.forEach((index, position) => { ids[index] = stored[position] })
324
+ continue
325
+ }
326
+
327
+ const returnColumns = { [idColumn]: databaseIdentityExpression(db, idColumn) }
328
+ for (const column of keyColumns || []) returnColumns[`__jra_key_${column}`] = column
329
+ let returned = await applyDatabaseReadOptions(applyInsertReturning(query, returnColumns))
330
+ if (onConflict) {
331
+ // Ignored rows return nothing, so returned rows are matched to inputs by their keys.
332
+ const matcher = conflictKeyMatcher(scopes[scopeName].vars.schemaInfo.schemaStructure, onConflict.keys, keyColumns)
333
+ const byKey = new Map(returned.map(row => [matcher.stored(Object.fromEntries(keyColumns.map(column => [column, row[`__jra_key_${column}`]]))), row[idColumn]]))
334
+ part.forEach((index, position) => { ids[index] = byKey.get(matcher.input(rows[index], values[position])) })
335
+ continue
336
+ }
337
+ if (family === 'sqlite' && values.some(row => row[idColumn] === undefined || row[idColumn] === null)) {
338
+ // SQLite does not promise RETURNING order; generated row ids increase in insertion order.
339
+ returned = [...returned].sort((left, right) => Number(left[idColumn]) - Number(right[idColumn]))
340
+ }
341
+ returned.forEach((row, position) => { ids[part[position]] = row[idColumn] })
342
+ }
343
+ }
344
+ return needIds ? ids : undefined
345
+ }
346
+
347
+ // The rows a by-id UPDATE or DELETE writes: the ids, onlyIf, the caller's
348
+ // ownership conditions and, with `scoped`, the caller's scope (with a
349
+ // selection's filters), built once (its filtering hooks record the ownership
350
+ // conditions).
351
+ const writeMatcher = async ({ scopeName, context, runHooks, scoped, onlyIf, filters }) => {
352
+ const storageAdapter = adapterFor(scopeName)
353
+ const db = context.transaction
354
+ const family = databaseFamily(db)
355
+ const tableName = storageAdapter.getTableName()
356
+ const idColumn = storageAdapter.getIdColumn()
357
+ const identity = { id: databaseIdentityExpression(db, `${tableName}.${idColumn}`) }
358
+ const scope = scoped ? (await dataScopedQuery({ scopeName, context, runHooks, purpose: 'bulk-write', filters })).query : null
359
+ const guard = query => applyWriteConditions({ query: applyOnlyIf(query, onlyIf || {}, tableName), context, scopeName, storageAdapter, tableName })
360
+ // The WHERE of the write, with the scope as a subquery.
361
+ const matching = ids => {
362
+ const query = guard(db(tableName).whereIn(idColumn, ids))
363
+ return scope ? query.whereIn(idColumn, scope.clone().whereIn(`${tableName}.${idColumn}`, ids).clearSelect().select(`${tableName}.${idColumn}`)) : query
364
+ }
365
+ // A selection's filters must hold for a row as it is once locked, after any
366
+ // wait for another writer, or two writers that both matched the old row
367
+ // would both write it. A subquery does not see that row: InnoDB reads it
368
+ // from the transaction snapshot, PostgreSQL as of the statement's start.
369
+ const selection = Boolean(scope && filters && Object.keys(filters).length)
370
+ if (selection) refuseUnlockedSelection({ db, scope, filters, method: context.method, scopeName })
371
+ return {
372
+ matching,
373
+ // PostgreSQL: lock the rows first, so the statement that writes them
374
+ // starts after any wait and reads what their holder committed.
375
+ settle: async ids => {
376
+ if (selection && family === 'pg') await lockRows(db(tableName).whereIn(idColumn, ids).orderBy(idColumn), db).select(idColumn)
377
+ },
378
+ // The ids that still match, locked. MySQL and MariaDB check a selection's
379
+ // scope and filters in the locking read's own query block, on the
380
+ // current row.
381
+ locked: async ids => {
382
+ const query = selection && family === 'mysql'
383
+ ? guard(scope.clone().whereIn(`${tableName}.${idColumn}`, ids)).clearSelect()
384
+ : matching(ids)
385
+ const rows = await applyDatabaseReadOptions(lockRows(query, db).select(identity))
386
+ // A join of the scope or the filters can repeat a row.
387
+ return [...new Map(rows.map(row => [String(row.id), row.id])).values()]
388
+ }
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Update visible rows by id: one UPDATE per chunk, `CASE id WHEN … THEN …`
394
+ * for values that differ between rows and a plain SET for shared ones.
395
+ * With `scoped`, each statement carries the caller's scope (row policy
396
+ * included) and `filters` as a subquery.
397
+ * Returns the identifiers the statements changed.
398
+ * @param {{ scopeName: string, context: any, runHooks?: Function, updates: Array<{ id: string | number, row: Record<string, unknown> }>, onlyIf?: Record<string, unknown>, chunkSize: number, scoped?: boolean, filters?: Record<string, unknown> }} request
399
+ * @returns {Promise<Array<string | number>>}
400
+ */
401
+ const dataUpdateByIds = async ({ scopeName, context, runHooks, updates, onlyIf, chunkSize, scoped = false, filters }) => {
402
+ const storageAdapter = adapterFor(scopeName)
403
+ const db = context.transaction
404
+ const tableName = storageAdapter.getTableName()
405
+ const idColumn = storageAdapter.getIdColumn()
406
+ const family = databaseFamily(db)
407
+ const lockVersionColumn = context.schemaInfo.lockVersionField ? storageAdapter.translateColumn(context.schemaInfo.lockVersionField) : null
408
+ const columnCount = new Set(updates.flatMap(update => Object.keys(update.row))).size
409
+ const { matching, settle, locked } = await writeMatcher({ scopeName, context, runHooks, scoped, onlyIf, filters })
410
+ // onlyIf, the scope and the caller's ownership narrow each statement; without them every listed id is written.
411
+ const guarded = scoped || Boolean(onlyIf) || hasWriteConditions(context, scopeName)
412
+ const changed = []
413
+
414
+ // A scoped statement binds each id twice, once more in its subquery.
415
+ for (const part of chunk(updates, rowsPerStatement(db, chunkSize, 2 * columnCount + (scoped ? 2 : 1)))) {
416
+ const set = {}
417
+ const columns = [...new Set(part.flatMap(update => Object.keys(update.row)))]
418
+ for (const column of columns) {
419
+ const entries = part.filter(update => Object.hasOwn(update.row, column))
420
+ const first = entries[0].row[column]
421
+ const same = entries.length === part.length && entries.every(update => sameValue(update.row[column], first))
422
+ if (same) {
423
+ set[column] = first
424
+ continue
425
+ }
426
+ const bindings = [idColumn]
427
+ let sql = 'case ??'
428
+ for (const update of entries) {
429
+ sql += ' when ? then ?'
430
+ bindings.push(storageAdapter.translateFilterValue('id', update.id), update.row[column])
431
+ }
432
+ set[column] = db.raw(`${sql} else ?? end`, [...bindings, column])
433
+ }
434
+ if (lockVersionColumn) set[lockVersionColumn] = db.raw('?? + 1', [lockVersionColumn])
435
+ const writes = Object.keys(set).length > 0
436
+ const ids = part.map(update => storageAdapter.translateFilterValue('id', update.id))
437
+ if (!guarded) {
438
+ if (writes) await db(tableName).whereIn(idColumn, ids).update(set)
439
+ changed.push(...part.map(update => update.id))
440
+ } else if (family === 'mysql' || !writes) {
441
+ // MySQL cannot return updated rows. One row's UPDATE answers from its
442
+ // count when that counts matched rows; with a scope subquery it would
443
+ // name the updated table, which MySQL refuses.
444
+ if (writes && !scoped && part.length === 1 && await countsMatchedRows(db)) {
445
+ if (await matching(ids).update(set)) changed.push(part[0].id)
446
+ continue
447
+ }
448
+ // Otherwise lock the rows that match, then update exactly those.
449
+ await settle(ids)
450
+ const matched = await locked(ids)
451
+ if (matched.length && writes) await db(tableName).whereIn(idColumn, matched).update(set)
452
+ changed.push(...matched)
453
+ } else {
454
+ await settle(ids)
455
+ const rows = await applyDatabaseReadOptions(matching(ids).returning({ id: databaseIdentityExpression(db, idColumn) }).update(set))
456
+ changed.push(...rows.map(row => row.id))
457
+ }
458
+ }
459
+ return changed
460
+ }
461
+
462
+ /**
463
+ * Delete visible rows by id, one DELETE per chunk, then their pivot rows.
464
+ * With `scoped`, each DELETE carries the caller's scope and `filters` (see
465
+ * dataUpdateByIds).
466
+ * Returns the identifiers the statements deleted.
467
+ * @param {{ scopeName: string, context: any, runHooks?: Function, ids: Array<string | number>, chunkSize: number, scoped?: boolean, filters?: Record<string, unknown> }} request
468
+ * @returns {Promise<Array<string | number>>}
469
+ */
470
+ const dataDeleteByIds = async ({ scopeName, context, runHooks, ids, chunkSize, scoped = false, filters }) => {
471
+ const storageAdapter = adapterFor(scopeName)
472
+ const db = context.transaction
473
+ const family = databaseFamily(db)
474
+ const tableName = storageAdapter.getTableName()
475
+ const idColumn = storageAdapter.getIdColumn()
476
+ const identity = { id: databaseIdentityExpression(db, idColumn) }
477
+ const { matching, settle, locked } = await writeMatcher({ scopeName, context, runHooks, scoped, filters })
478
+ const guarded = scoped || hasWriteConditions(context, scopeName)
479
+ const deleted = []
480
+ for (const part of chunk(ids, rowsPerStatement(db, chunkSize, scoped ? 2 : 1))) {
481
+ let values = part.map(id => storageAdapter.translateFilterValue('id', id))
482
+ if (guarded && family === 'pg') {
483
+ // The scope and the caller's ownership narrow the DELETE, which returns the rows it removed.
484
+ await settle(values)
485
+ values = (await applyDatabaseReadOptions(matching(values).returning(identity).delete())).map(row => row.id)
486
+ } else if (guarded && family === 'sqlite') {
487
+ // Knex reads no rows back from a SQLite DELETE, so its RETURNING runs as
488
+ // raw SQL around the builder, which keeps its own bindings: a '?' inside
489
+ // a string literal of a row policy stays text.
490
+ values = (await db.raw('? returning ? as ??', [matching(values).delete(), identity.id, 'id'])).map(row => row.id)
491
+ } else if (guarded) {
492
+ // MySQL cannot return deleted rows: lock the rows that still match, then delete exactly those.
493
+ values = await locked(values)
494
+ if (values.length) await db(tableName).whereIn(idColumn, values).delete()
495
+ } else {
496
+ await db(tableName).whereIn(idColumn, values).delete()
497
+ }
498
+ if (!values.length) continue
499
+ await deletePivotReferences(api, scopeName, context, values)
500
+ deleted.push(...values)
501
+ }
502
+ return deleted
503
+ }
504
+
505
+ // Reads rows through the get field selection, so they leave as response records.
506
+ const readRecords = async ({ scopeName, context, query, tableName }) => {
507
+ const scope = api.resources[scopeName]
508
+ const storageAdapter = adapterFor(scopeName)
509
+ const db = context.db
510
+ const fieldSelectionInfo = await buildFieldSelection(scope, { context })
511
+ context.computedDependencies = fieldSelectionInfo.computedDependencies
512
+ const { query: selected } = await applyFieldSelectionToQuery({
513
+ query, scope, fieldSelectionInfo, tableName, useTablePrefix: true, storageAdapter, db, context, scopeName
514
+ })
515
+ const rows = await applyDatabaseReadOptions(selected)
516
+ await loadRelationshipIdentifiers(rows, scopeName, scopes, db, context)
517
+ return buildJsonApiResponse(scope, rows, [], false, scopeName, context)
518
+ }
519
+
520
+ // SQLite has one writer, but a deferred transaction's reads take only a shared
521
+ // lock: two connections could both read as if they held the rows, and the
522
+ // later write would fail with SQLITE_BUSY halfway through. A write statement
523
+ // takes the write lock even when it changes nothing, so a SQLite lock read
524
+ // starts with one, once per transaction; the lock read itself is then plain.
525
+ const writeLocked = new WeakSet()
526
+ const takeSqliteWriteLock = async (db, tableName, idColumn) => {
527
+ if (databaseFamily(db) !== 'sqlite' || writeLocked.has(db)) return
528
+ await db(tableName).whereRaw('0 = 1').update({ [idColumn]: db.ref(idColumn) })
529
+ writeLocked.add(db)
530
+ }
531
+
532
+ const lockRows = (query, db, { skipLocked = false } = {}) => {
533
+ if (databaseFamily(db) === 'sqlite') return query
534
+ query.forUpdate()
535
+ return skipLocked ? query.skipLocked() : query
536
+ }
537
+
538
+ // The scoped query of a lock or claim, refused unless it is still a SELECT on
539
+ // the resource's own table: FOR UPDATE on a derived table or a common table
540
+ // expression locks nothing and reads the transaction snapshot, and InnoDB
541
+ // would lock every joined row the read examines.
542
+ const lockingQuery = async ({ scopeName, context, runHooks, operation, purpose, filters }) => {
543
+ const storageAdapter = adapterFor(scopeName)
544
+ const tableName = storageAdapter.getTableName()
545
+ const { query } = await dataScopedQuery({ scopeName, context, runHooks, purpose, filters })
546
+ const added = query._statements.find(statement => ['join', 'with', 'union'].includes(statement.grouping))?.grouping
547
+ if (added || query._single.table !== tableName) {
548
+ const what = { join: 'joins', with: 'a common table expression', union: 'a set operation' }[added] ?? 'another source table'
549
+ const message = `${operation} on '${scopeName}' needs a single-table query, but its filters or row policy add ${what}`
550
+ throw new RestApiValidationError(message, { fields: ['filters'], violations: [{ field: 'filters', rule: `${purpose}_single_table`, message }] })
551
+ }
552
+ await takeSqliteWriteLock(context.db, tableName, storageAdapter.getIdColumn())
553
+ return { query, storageAdapter, tableName, idColumn: `${tableName}.${storageAdapter.getIdColumn()}` }
554
+ }
555
+
556
+ /**
557
+ * Lock the visible rows among `ids` in id order and read them, in one locking
558
+ * read that carries the scope and row-policy predicates. No plain read comes
559
+ * first: under REPEATABLE READ (MySQL, MariaDB) it would fix the snapshot
560
+ * before the lock wait, hiding what the lock holder commits meanwhile.
561
+ * Returns the rows the locking read saw as a JSON:API document.
562
+ * @param {{ scopeName: string, context: any, runHooks: Function, ids: Array<string | number> }} request
563
+ */
564
+ const dataLockByIds = async ({ scopeName, context, runHooks, ids }) => {
565
+ const { query, storageAdapter, tableName, idColumn } = await lockingQuery({ scopeName, context, runHooks, operation: 'lockByIds', purpose: 'lock' })
566
+ query.whereIn(idColumn, ids.map(id => storageAdapter.translateFilterValue('id', id))).orderBy(idColumn)
567
+ return readRecords({ scopeName, context, query: lockRows(query, context.db), tableName })
568
+ }
569
+
570
+ // The index lockWhere forces on MySQL, by schema, table and the read's columns (see lockIndexFor).
571
+ const lockIndexes = new Map()
572
+ /**
573
+ * InnoDB locks every row a locking read examines, and holds those locks until
574
+ * commit. lockWhere on MySQL therefore forces an index that keeps its read
575
+ * inside the caller's rows: one whose leading columns are the whole
576
+ * ownership columns the read is limited to, or, without ownership
577
+ * conditions, one that starts with a column the filters compare. Otherwise
578
+ * the read could walk the primary key, locking other owners' rows and the
579
+ * gaps between them. Invisible and ignored indexes cannot be forced.
580
+ * Returns the index name.
581
+ * @param {{ scopeName: string, context: any, storageAdapter: any, tableName: string, filters?: Record<string, unknown> }} request
582
+ * @returns {Promise<string>}
583
+ */
584
+ const lockIndexFor = async ({ scopeName, context, storageAdapter, tableName, filters = {} }) => {
585
+ const owned = Object.entries(context[writeConditions]?.[scopeName] || {})
586
+ .filter(([, value]) => value !== null && value !== ANY_OWNER)
587
+ .map(([field]) => storageAdapter.translateColumn(field))
588
+ const search = context.schemaInfo.searchSchemaStructure || {}
589
+ const compared = Object.keys(filters).flatMap(key => {
590
+ const definition = search[key]
591
+ if (!definition || definition.oneOf || definition.applyFilter || definition.polymorphicField || definition.actualField?.includes('.')) return []
592
+ return [storageAdapter.translateColumn(definition.actualField || key)]
593
+ })
594
+ // One pool can serve several databases, so the cache is per schema.
595
+ const schema = await currentKnexMysqlSchema(context.db)
596
+ const cacheKey = JSON.stringify([schema, tableName, [...owned].sort(), [...compared].sort()])
597
+ if (lockIndexes.has(cacheKey)) return lockIndexes.get(cacheKey)
598
+ const indexes = await listKnexMysqlIndexes(context.db, schema, tableName)
599
+ const leading = owned.length
600
+ ? indexes.filter(({ columns }) => columns.length >= owned.length &&
601
+ columns.slice(0, owned.length).every(column => owned.includes(column.name) && column.prefix === null))
602
+ : indexes.filter(({ columns }) => compared.includes(columns[0]?.name))
603
+ // Among those, one whose next column the filters compare locks fewer rows.
604
+ const index = leading.find(({ columns }) => compared.includes(columns[owned.length]?.name)) ?? leading[0]
605
+ if (!index) {
606
+ throw new RestApiBulkWriteUnsupportedError({
607
+ resourceType: scopeName,
608
+ operation: 'lockWhere',
609
+ reason: `on MySQL and MariaDB a locking read locks every row it examines; add a visible index of '${tableName}' that starts with ${owned.length ? `the whole ownership column(s) ${owned.join(', ')}` : 'a column the filters compare'}`
610
+ })
611
+ }
612
+ lockIndexes.set(cacheKey, index.name)
613
+ return index.name
614
+ }
615
+
616
+ /**
617
+ * Lock up to `limit` visible rows the filters select, waiting for
618
+ * transactions that hold them, in one locking read like dataLockByIds: in id
619
+ * order, except on MySQL, where the read is forced onto the index that
620
+ * lockIndexFor picks and the rows come in its order, as ORDER BY id could
621
+ * walk the primary key.
622
+ * @param {{ scopeName: string, context: any, runHooks: Function, filters?: Record<string, unknown>, limit: number }} request
623
+ */
624
+ const dataLockWhere = async ({ scopeName, context, runHooks, filters, limit }) => {
625
+ const read = async () => {
626
+ const { query, storageAdapter, tableName, idColumn } = await lockingQuery({ scopeName, context, runHooks, operation: 'lockWhere', purpose: 'lock', filters })
627
+ if (databaseFamily(context.db) === 'mysql') {
628
+ // Without FORCE INDEX the optimizer could still pick another index or a full scan.
629
+ const index = await lockIndexFor({ scopeName, context, storageAdapter, tableName, filters })
630
+ query.from(context.db.raw('?? force index (??)', [tableName, index]))
631
+ } else query.orderBy(idColumn)
632
+ return readRecords({ scopeName, context, query: lockRows(query.limit(limit), context.db), tableName })
633
+ }
634
+ let document = await read()
635
+ // A PostgreSQL statement finds only the rows committed when it began; after a
636
+ // lock wait it rechecks those, but never sees rows that started to match
637
+ // meanwhile. Read again, under the locks already held, until none appears.
638
+ // (InnoDB's locking reads see the latest rows, and its next-key locks keep
639
+ // new rows out of the range the read has passed.)
640
+ if (databaseFamily(context.db) === 'pg') {
641
+ for (let previous = -1; document.data.length > previous && document.data.length < limit;) {
642
+ previous = document.data.length
643
+ document = await read()
644
+ }
645
+ }
646
+ return document
647
+ }
648
+
649
+ /**
650
+ * Take up to `limit` matching rows no other transaction holds.
651
+ * @param {{ scopeName: string, context: any, runHooks: Function, filters?: Record<string, unknown>, sort: Array<{ field: string, direction: 'asc' | 'desc' }>, limit: number }} request
652
+ */
653
+ const dataClaim = async ({ scopeName, context, runHooks, filters, sort, limit }) => {
654
+ const db = context.db
655
+ const claimQuery = () => lockingQuery({ scopeName, context, runHooks, operation: 'claim', purpose: 'claim', filters })
656
+ const { query, storageAdapter, tableName, idColumn } = await claimQuery()
657
+ for (const { field, direction } of sort) query.orderBy(`${tableName}.${storageAdapter.translateColumn(field)}`, direction)
658
+ if (databaseFamily(db) !== 'mysql') {
659
+ // PostgreSQL locks rows as the LIMIT takes them; SQLite has one writer.
660
+ return readRecords({ scopeName, context, query: lockRows(query.limit(limit), db, { skipLocked: true }), tableName })
661
+ }
662
+
663
+ // InnoDB locks every row a locking read examines, including rows a sort
664
+ // reads and the LIMIT then drops, so one claim would hold every match.
665
+ // Instead: candidates in sort order by a plain read; then, for windows of
666
+ // candidates no larger than what is still needed, a SKIP LOCKED lock by
667
+ // primary key on the bare table, and a locking read of the held rows that
668
+ // drops any a committed change made stop matching. SKIP LOCKED never waits,
669
+ // so the snapshot the plain read starts misses nothing a lock wait let commit.
670
+ const claimed = { data: [] }
671
+ const identity = { id: databaseIdentityExpression(db, idColumn) }
672
+ const pageSize = Math.max(limit * 2, 20)
673
+ for (let offset = 0; claimed.data.length < limit; offset += pageSize) {
674
+ const candidates = (await applyDatabaseReadOptions(query.clone().offset(offset).limit(pageSize).select(identity))).map(row => row.id)
675
+ for (let next = 0; next < candidates.length && claimed.data.length < limit;) {
676
+ const window = candidates.slice(next, next + limit - claimed.data.length)
677
+ next += window.length
678
+ const byKey = applyWriteConditions({ query: db(tableName).whereIn(idColumn, window), context, scopeName, storageAdapter, tableName })
679
+ const held = (await applyDatabaseReadOptions(byKey.forUpdate().skipLocked().select(identity))).map(row => row.id)
680
+ if (!held.length) continue
681
+ const { query: current } = await claimQuery()
682
+ const document = await readRecords({ scopeName, context, query: current.whereIn(idColumn, held).forUpdate().skipLocked(), tableName })
683
+ const order = new Map(window.map((id, position) => [String(id), position]))
684
+ claimed.data.push(...document.data.sort((left, right) => order.get(String(left.id)) - order.get(String(right.id))))
685
+ }
686
+ if (candidates.length < pageSize) break
687
+ }
688
+ return claimed
689
+ }
690
+
691
+ return { dataScopedQuery, dataVisibleIds, dataIdsByKeys, dataInsertMany, dataUpdateByIds, dataDeleteByIds, dataLockByIds, dataLockWhere, dataClaim }
692
+ }
693
+
694
+ const sameValue = (left, right) => {
695
+ if (left === right) return true
696
+ if (isRawValue(left) || isRawValue(right)) return false
697
+ if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime()
698
+ return false
699
+ }
700
+
701
+ function applyOnlyIf (query, onlyIf, tableName) {
702
+ for (const [column, value] of Object.entries(onlyIf)) {
703
+ if (value === null) query.whereNull(`${tableName}.${column}`)
704
+ else query.where(`${tableName}.${column}`, value)
705
+ }
706
+ return query
707
+ }
708
+
709
+ // MySQL reports only the first generated id. One multi-row INSERT is expected to
710
+ // take consecutive values; prove it by reading the range back inside the
711
+ // transaction and comparing the rows with what was inserted.
712
+ async function verifyMysqlInsertIds ({ db, tableName, idColumn, values, firstId, increment, scopeName, schemaInfo, storageAdapter }) {
713
+ const start = Number(firstId)
714
+ const expected = values.map((_row, position) => start + position * increment)
715
+ const comparable = comparableColumns(values, schemaInfo, storageAdapter)
716
+ const rows = await applyDatabaseReadOptions(db(tableName).whereIn(idColumn, expected).orderBy(idColumn)
717
+ .select({ __jra_id: idColumn }, ...comparable))
718
+ const verified = rows.length === values.length && rows.every((row, position) =>
719
+ Number(row.__jra_id) === expected[position] && comparable.every(column => String(row[column]) === String(values[position][column])))
720
+ if (!verified) {
721
+ throw new RestApiBulkInsertIdsError({ resourceType: scopeName, expected: values.length, verified: rows.length })
722
+ }
723
+ return expected
724
+ }
725
+
726
+ // Columns whose stored value reads back exactly: strings and integers.
727
+ function comparableColumns (values, schemaInfo, storageAdapter) {
728
+ const columns = []
729
+ for (const [field, definition] of Object.entries(schemaInfo.schemaStructure || {})) {
730
+ if (field === 'id' || definition.virtual || definition.computed || definition.storage?.serialize) continue
731
+ const column = storageAdapter.translateColumn(field)
732
+ const sample = values[0][column]
733
+ const textual = ['string', 'email', 'uuid'].includes(definition.type) && typeof sample === 'string'
734
+ const integral = (definition.type === 'integer' || definition.belongsTo) && Number.isSafeInteger(Number(sample)) && sample !== null && sample !== ''
735
+ if ((textual || integral) && values.every(row => row[column] !== null && row[column] !== undefined)) columns.push(column)
736
+ }
737
+ return columns
738
+ }