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,333 @@
1
+ // Change many records by id: validate every row in memory, read which of the
2
+ // ids the caller may change (one scoped read per chunk), then run one UPDATE per
3
+ // chunk with CASE expressions. patchWhereIds and patchWhere reuse the same path.
4
+ import { lockRelationshipTargets, processRelationships } from '../lib/writing/relationship-processor.js'
5
+ import { rejectRemovedOptions, resolveFormat } from '../lib/querying-writing/response-options.js'
6
+ import { requireExistingResourceId } from '../lib/querying-writing/resource-id-normalization.js'
7
+ import { validateUpdateRequest } from '../lib/querying-writing/request-contracts.js'
8
+ import { beginWriteTransaction } from '../../../lib/error-context.js'
9
+ import { RestApiRecordsNotFoundError, RestApiValidationError } from '../../../lib/rest-api-errors.js'
10
+ import { chunk, databaseFamily } from '../lib/storage/many-row-data-helpers.js'
11
+ import {
12
+ applyFieldSetters,
13
+ collectRelationshipTargets,
14
+ commitOwnedTransaction,
15
+ handleWriteMethodError,
16
+ keepHiddenLinks,
17
+ validateRelationshipTargets,
18
+ validateResourceAttributesBeforeWrite
19
+ } from './common.js'
20
+ import {
21
+ atIndex,
22
+ keepVisible,
23
+ manyRowResponse,
24
+ noHooks,
25
+ refuseDuplicateIds,
26
+ refuseUnsupportedResource,
27
+ rejectBulkOptions,
28
+ requireWhereScope,
29
+ resolveChunkSize,
30
+ resolveManyRowReturning,
31
+ resolveMissing,
32
+ selectManyInput,
33
+ toDocument,
34
+ unsupported,
35
+ validateFilters
36
+ } from './many-row-common.js'
37
+
38
+ // Ids read per visibility statement; the write chunks are sized separately.
39
+ export const VISIBLE_READ_CHUNK = 1000
40
+ export const VISIBLE_PAGE_SIZE = 10000
41
+
42
+ const invalid = (field, message) => new RestApiValidationError(message, { fields: [field], violations: [{ field, rule: 'many_row_input', message }] })
43
+
44
+ // Fields a many-row patch may never change: identity, ownership and the lock version.
45
+ function protectedFields (schemaInfo) {
46
+ const fields = new Set(['id', ...(schemaInfo.ownershipFields || [])])
47
+ if (schemaInfo.lockVersionField) fields.add(schemaInfo.lockVersionField)
48
+ const relationships = new Set()
49
+ for (const field of schemaInfo.ownershipFields || []) {
50
+ const alias = schemaInfo.schemaStructure[field]?.as
51
+ if (alias) relationships.add(alias)
52
+ }
53
+ return { fields, relationships }
54
+ }
55
+
56
+ function refuseProtectedFields (names, schemaInfo, index) {
57
+ const { fields } = protectedFields(schemaInfo)
58
+ for (const field of names) {
59
+ if (fields.has(field)) throw atIndex(invalid(`data.attributes.${field}`, `'${field}' cannot be changed by a many-row patch`), index)
60
+ }
61
+ }
62
+
63
+ function refuseProtectedInput ({ document, raw, schemaInfo, index }) {
64
+ const { relationships } = protectedFields(schemaInfo)
65
+ refuseProtectedFields([...Object.keys(document.data.attributes || {}), ...Object.keys(raw)], schemaInfo, index)
66
+ for (const name of Object.keys(document.data.relationships || {})) {
67
+ if (relationships.has(name)) throw atIndex(invalid(`data.relationships.${name}`, `'${name}' sets ownership and cannot be changed by a many-row patch`), index)
68
+ }
69
+ for (const field of Object.keys(raw)) {
70
+ const definition = schemaInfo.schemaStructure[field]
71
+ if (!definition || definition.virtual || definition.computed || definition.belongsTo || definition.belongsToPolymorphic) {
72
+ throw atIndex(invalid(`data.attributes.${field}`, `'${field}' cannot take a raw SQL value: it must be a stored, non-relationship field`), index)
73
+ }
74
+ }
75
+ }
76
+
77
+ function resolveOnlyIf (onlyIf, { schemaInfo, storageAdapter }) {
78
+ if (onlyIf === undefined) return undefined
79
+ if (!onlyIf || typeof onlyIf !== 'object' || Array.isArray(onlyIf) || !Object.keys(onlyIf).length) {
80
+ throw invalid('onlyIf', 'onlyIf must be an object of field values')
81
+ }
82
+ const conditions = {}
83
+ for (const [field, value] of Object.entries(onlyIf)) {
84
+ const definition = schemaInfo.schemaStructure[field]
85
+ if (!definition || definition.virtual || definition.computed || field === 'id') throw invalid(`onlyIf.${field}`, `'${field}' is not a stored field`)
86
+ if (value !== null && (typeof value === 'object' && !(value instanceof Date))) throw invalid(`onlyIf.${field}`, 'onlyIf values must be scalars or null')
87
+ conditions[storageAdapter.translateColumn(field)] = value === null ? null : storageAdapter.translateFilterValue(field, value)
88
+ }
89
+ return conditions
90
+ }
91
+
92
+ /**
93
+ * The shared path: `entries` are { id, document, raw } in input order. A
94
+ * `selection` (patchWhere) gives the filters that found the ids: its rows are
95
+ * never missing, and the write answers with the rows it changed.
96
+ * @param {any} args
97
+ * @param {{ operation: string, entries: Array<{ id: unknown, document: any, raw: Record<string, unknown> }>, started?: boolean, selection?: { filters?: Record<string, unknown> } }} batch
98
+ */
99
+ async function runPatchMany (args, { operation, entries, started = false, selection }) {
100
+ const { params, context, vars, helpers, scope, runHooks, hasHooks, perRecordHooks, scopeOptions, scopeName, api } = args
101
+ const schemaInfo = context.schemaInfo
102
+ const storageAdapter = helpers.getStorageAdapter(scopeName)
103
+ const chunkSize = resolveChunkSize(params.chunkSize)
104
+ const missing = selection ? 'skip' : resolveMissing(params.missing)
105
+ refuseUnsupportedResource({ api, helpers, perRecordHooks, scopeName, schemaInfo, operation, kind: 'patch', storageHelper: 'dataUpdateByIds' })
106
+ const onlyIf = resolveOnlyIf(params.onlyIf, { schemaInfo, storageAdapter })
107
+ entries.forEach((entry, index) => refuseProtectedInput({ ...entry, schemaInfo, index }))
108
+
109
+ if (!started) await beginWriteTransaction(context, params.transaction, helpers.newTransaction, runHooks)
110
+ context.db = context.transaction
111
+ context.inputRecords = entries.map(entry => entry.document)
112
+ // Fields set by raw SQL values, which plugins that refuse fields check too.
113
+ context.rawFields = [...new Set(entries.flatMap(entry => Object.keys(entry.raw)))]
114
+ await runHooks('beforeProcessingPatchMany')
115
+
116
+ const targets = new Map()
117
+ const rows = entries.map((entry, index) => {
118
+ const rowContext = { ...context, method: 'patch', inputRecord: entry.document, originalInputAttributes: undefined, bulkIndex: index }
119
+ // A row of raw values alone, or one that only advances a lock version,
120
+ // still carries an (empty) attributes object.
121
+ if (Object.keys(entry.raw).length || schemaInfo.lockVersionField) entry.document.data.attributes ||= {}
122
+ try {
123
+ validateUpdateRequest({ method: 'patch', params: {}, context: rowContext, vars, scopeOptions, scopeName })
124
+ } catch (error) { throw atIndex(error, index) }
125
+ collectRelationshipTargets(rowContext.inputRecord, api, targets)
126
+ return { rowContext, raw: entry.raw, index, clearsLink: false }
127
+ })
128
+ refuseDuplicateIds(rows.map(row => row.rowContext.id), storageAdapter)
129
+ await validateRelationshipTargets(context, [...targets.values()], helpers, api)
130
+
131
+ const parents = []
132
+ for (const row of rows) {
133
+ const { rowContext, index } = row
134
+ try {
135
+ const { belongsToUpdates, belongsToTargets, manyToManyRelationships, reverseRelationships } = processRelationships(scope, { context: rowContext })
136
+ if (manyToManyRelationships.length || reverseRelationships.length) {
137
+ throw unsupported(scopeName, operation, 'rows set many-to-many or reverse relationships; change those links with single writes')
138
+ }
139
+ await validateResourceAttributesBeforeWrite({ context: rowContext, schema: schemaInfo.schemaInstance, belongsToUpdates, runHooks: noHooks, isPartialValidation: true })
140
+ rowContext.inputRecord.data.attributes = { ...rowContext.inputRecord.data.attributes, ...belongsToUpdates }
141
+ row.clearsLink = Object.values(belongsToUpdates).includes(null)
142
+ // A polymorphic relationship writes its own type and id columns, which may be ownership fields.
143
+ refuseProtectedFields(Object.keys(rowContext.inputRecord.data.attributes), schemaInfo, index)
144
+ // A row with no fields still advances a lock version.
145
+ if (!Object.keys(rowContext.inputRecord.data.attributes).length && !Object.keys(row.raw).length && !schemaInfo.lockVersionField) {
146
+ throw invalid('data', 'A patch row must change at least one field')
147
+ }
148
+ parents.push(...belongsToTargets)
149
+ } catch (error) { throw atIndex(error, index) }
150
+ }
151
+
152
+ // Which of the requested rows the caller may change. With missing: 'skip' and
153
+ // no hook that needs the rows first, the UPDATE decides it, carrying the
154
+ // scope, and its changed rows answer. Otherwise one scoped read per chunk
155
+ // gives each row's minimal record to the permission check. On MySQL a
156
+ // locking read locks every listed row before it checks the scope, so ids
157
+ // from the caller always get that read first; a selection's ids come from
158
+ // its own scoped read. A selection's UPDATE carries the scope and its filters
159
+ // either way, so it changes only rows that still match.
160
+ // A row that clears a link reads first too, to keep a link to a hidden target.
161
+ const readFirst = rows.some(row => row.clearsLink) || !(missing === 'skip' && !hasHooks('checkPermissions') && !hasHooks('beforeDataCallPatchMany') &&
162
+ (selection || databaseFamily(context.db) !== 'mysql'))
163
+ const scoped = Boolean(selection) || !readFirst
164
+ let keptRows = rows
165
+ if (readFirst) {
166
+ const visible = new Map()
167
+ for (const part of chunk(rows.map(row => row.rowContext.id), VISIBLE_READ_CHUNK)) {
168
+ const records = await helpers.dataGetMinimal({ scopeName, context, runHooks, ids: part, filters: {}, queryPurpose: 'bulk-write' })
169
+ for (const record of records) visible.set(String(storageAdapter.translateFilterValue('id', record.id)), record)
170
+ }
171
+ const kept = keepVisible({ requested: rows.map(row => row.rowContext.id), visible, missing, scopeName, storageAdapter })
172
+ keptRows = rows.filter(row => kept.includes(row.rowContext.id))
173
+ for (const { rowContext } of keptRows) {
174
+ rowContext.minimalRecord = visible.get(String(storageAdapter.translateFilterValue('id', rowContext.id)))
175
+ }
176
+ await keepHiddenLinks({
177
+ context,
178
+ scopes: api.resources,
179
+ schemaInfo,
180
+ rows: keptRows.filter(row => row.clearsLink).map(({ rowContext }) => ({ updates: rowContext.inputRecord.data.attributes, stored: rowContext.minimalRecord, inputRecord: rowContext.inputRecord }))
181
+ })
182
+ for (const { rowContext, index } of keptRows) {
183
+ try {
184
+ await scope.checkPermissions({ method: 'patch', originalContext: rowContext })
185
+ } catch (error) { throw atIndex(error, index) }
186
+ }
187
+ }
188
+
189
+ context.ids = keptRows.map(row => String(row.rowContext.id))
190
+ context.inputRecords = keptRows.map(row => row.rowContext.inputRecord)
191
+ await runHooks('beforeDataCallPatchMany')
192
+ await lockRelationshipTargets(api, context.transaction, parents)
193
+
194
+ const updates = []
195
+ for (const { rowContext, raw, index } of keptRows) {
196
+ try {
197
+ await applyFieldSetters(rowContext, api, helpers)
198
+ } catch (error) { throw atIndex(error, index) }
199
+ const row = storageAdapter.toStorageRow(rowContext.inputRecord.data.attributes || {}, { context: rowContext, operation: 'patch' })
200
+ delete row[storageAdapter.getIdColumn()]
201
+ for (const [field, value] of Object.entries(raw)) row[storageAdapter.translateColumn(field)] = value
202
+ updates.push({ id: rowContext.id, row })
203
+ }
204
+
205
+ const changed = new Set((updates.length
206
+ ? await helpers.dataUpdateByIds({ scopeName, context, runHooks, updates, onlyIf, chunkSize, scoped, filters: selection?.filters })
207
+ : []).map(String))
208
+ // A row that left the caller's scope after the visibility read is not written.
209
+ const unchanged = updates.length - changed.size
210
+ if (unchanged && !onlyIf && missing === 'throw') throw new RestApiRecordsNotFoundError({ resourceType: scopeName, missing: unchanged })
211
+ // After-hooks see the changed rows' ids and inputs, paired by position.
212
+ const written = keptRows.filter(row => changed.has(String(storageAdapter.translateFilterValue('id', row.rowContext.id))))
213
+ context.ids = written.map(row => String(storageAdapter.translateFilterValue('id', row.rowContext.id)))
214
+ context.inputRecords = written.map(row => row.rowContext.inputRecord)
215
+ await runHooks('afterDataCallPatchMany')
216
+
217
+ const response = await manyRowResponse({
218
+ api, scopeName, context, ids: context.ids, returning: context.returning, format: context.format, queryParams: params.queryParams
219
+ })
220
+ await commitOwnedTransaction(context)
221
+ return response
222
+ }
223
+
224
+ function setupManyWrite ({ params, context, vars, scopes, scopeName }, method) {
225
+ context.method = method
226
+ rejectRemovedOptions(params)
227
+ rejectBulkOptions(params)
228
+ context.scopeName = scopeName
229
+ context.schemaInfo = scopes[scopeName].vars.schemaInfo
230
+ context.format = resolveFormat(params.format, vars.format)
231
+ context.simplified = context.format === 'plain'
232
+ context.returning = resolveManyRowReturning(params.returning)
233
+ context.params = params
234
+ }
235
+
236
+ // One record of attributes for every id: `data: {…}` or `document: { data: {…} }`.
237
+ function selectSharedRecord (params, { scopeName, schemaInfo }) {
238
+ const hasData = Object.hasOwn(params, 'data')
239
+ if (hasData === Object.hasOwn(params, 'document')) throw invalid('data', 'Supply exactly one of data or document')
240
+ const record = hasData ? params.data : params.document?.data
241
+ if (!record || typeof record !== 'object' || Array.isArray(record)) throw invalid(hasData ? 'data' : 'document.data', 'The patch must be one record object')
242
+ if (record.id !== undefined) throw invalid(hasData ? 'data.id' : 'document.data.id', 'The patch applies to the listed ids; it cannot carry an id')
243
+ return { inputKey: hasData ? 'data' : 'document', record }
244
+ }
245
+
246
+ function entriesFor (ids, shared, { scopeName, schemaInfo }) {
247
+ return ids.map(id => {
248
+ const record = shared.inputKey === 'data'
249
+ ? { ...shared.record, id }
250
+ : { ...shared.record, type: shared.record.type ?? scopeName, id: String(id) }
251
+ const { document, raw } = toDocument({ record, inputKey: shared.inputKey, scopeName, schemaInfo, allowRaw: true })
252
+ return { id, document, raw }
253
+ })
254
+ }
255
+
256
+ function requireIds (ids, { scopeOptions, vars, scopeName }) {
257
+ if (!Array.isArray(ids) || !ids.length) throw invalid('ids', 'ids must be a non-empty array')
258
+ return ids.map(id => requireExistingResourceId(id, { scopeOptions, vars, scopeName }))
259
+ }
260
+
261
+ /**
262
+ * Reads every visible identifier the filters select, in keyset pages.
263
+ * @param {any} args
264
+ * @param {Record<string, unknown> | undefined} filters
265
+ */
266
+ export async function visibleIdsByFilters ({ helpers, context, runHooks, scopeName }, filters) {
267
+ const ids = []
268
+ let after
269
+ for (;;) {
270
+ const page = await helpers.dataVisibleIds({ scopeName, context, runHooks, filters, queryPurpose: 'bulk-write', after, limit: VISIBLE_PAGE_SIZE })
271
+ ids.push(...page)
272
+ if (page.length < VISIBLE_PAGE_SIZE) return ids
273
+ after = page[page.length - 1]
274
+ }
275
+ }
276
+
277
+ /** @param {any} args */
278
+ export async function patchManyMethod (args) {
279
+ const { params, context, scopeName, log } = args
280
+ try {
281
+ setupManyWrite(args, 'patchMany')
282
+ const { inputKey, records } = selectManyInput(params)
283
+ const entries = records.map((record, index) => {
284
+ if (record.id === undefined || record.id === null) throw atIndex(invalid('data.id', 'Every patch row needs an id'), index)
285
+ try {
286
+ return { id: record.id, ...toDocument({ record, inputKey, scopeName, schemaInfo: context.schemaInfo, allowRaw: true }) }
287
+ } catch (error) { throw atIndex(error, index) }
288
+ })
289
+ return await runPatchMany(args, { operation: 'patchMany', entries })
290
+ } catch (error) {
291
+ await handleWriteMethodError(error, context, 'PATCH_MANY', scopeName, log)
292
+ }
293
+ }
294
+
295
+ /** @param {any} args */
296
+ export async function patchWhereIdsMethod (args) {
297
+ const { params, context, scopeName, log } = args
298
+ try {
299
+ setupManyWrite(args, 'patchWhereIds')
300
+ const shared = selectSharedRecord(params, { scopeName, schemaInfo: context.schemaInfo })
301
+ const ids = requireIds(params.ids, args)
302
+ return await runPatchMany(args, { operation: 'patchWhereIds', entries: entriesFor(ids, shared, { scopeName, schemaInfo: context.schemaInfo }) })
303
+ } catch (error) {
304
+ await handleWriteMethodError(error, context, 'PATCH_WHERE_IDS', scopeName, log)
305
+ }
306
+ }
307
+
308
+ /** @param {any} args */
309
+ export async function patchWhereMethod (args) {
310
+ const { params, context, scopeName, vars, helpers, log } = args
311
+ try {
312
+ setupManyWrite(args, 'patchWhere')
313
+ const shared = selectSharedRecord(params, { scopeName, schemaInfo: context.schemaInfo })
314
+ if (typeof helpers.dataVisibleIds !== 'function') {
315
+ throw unsupported(scopeName, 'patchWhere', 'its storage plugin has no many-row support (AnyAPI storage takes single writes)')
316
+ }
317
+ const filters = validateFilters({ filters: params.filters, scopeName, schemaInfo: context.schemaInfo, vars })
318
+ requireWhereScope({ filters, all: params.all, missing: params.missing, operation: 'patchWhere' })
319
+ // The visible ids are read in the write's transaction, so open it first.
320
+ await beginWriteTransaction(context, params.transaction, helpers.newTransaction, args.runHooks)
321
+ context.db = context.transaction
322
+ const ids = await visibleIdsByFilters(args, filters)
323
+ if (!ids.length) {
324
+ await commitOwnedTransaction(context)
325
+ return context.returning === 'count' ? { meta: { count: 0 } } : context.returning === 'none' ? undefined : { data: [] }
326
+ }
327
+ return await runPatchMany(args, {
328
+ operation: 'patchWhere', entries: entriesFor(ids, shared, { scopeName, schemaInfo: context.schemaInfo }), started: true, selection: { filters }
329
+ })
330
+ } catch (error) {
331
+ await handleWriteMethodError(error, context, 'PATCH_WHERE', scopeName, log)
332
+ }
333
+ }
@@ -18,7 +18,8 @@ import {
18
18
  validatePivotResource,
19
19
  handleRecordReturnAfterWrite,
20
20
  handleWriteMethodError,
21
- commitOwnedTransaction
21
+ commitOwnedTransaction,
22
+ keepHiddenLinks
22
23
  } from './common.js'
23
24
 
24
25
  /**
@@ -90,6 +91,7 @@ export default async function patchMethod ({
90
91
  }
91
92
 
92
93
  context.minimalRecord = minimalRecord
94
+ await keepHiddenLinks({ context, scopes, schemaInfo: context.schemaInfo, rows: [{ updates: belongsToUpdates, stored: minimalRecord, inputRecord: context.inputRecord }] })
93
95
 
94
96
  await scope.checkPermissions({
95
97
  method: 'patch',
@@ -0,0 +1,295 @@
1
+ // Create many records: each row goes through validation, relationship access,
2
+ // ownership stamping, setters and permissions in memory; then one INSERT runs
3
+ // per chunk. Statement counts stay flat as the batch grows.
4
+ import { lockRelationshipTargets, processRelationships } from '../lib/writing/relationship-processor.js'
5
+ import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
6
+ import { requireDocumentResourceId } from '../lib/querying-writing/resource-id-normalization.js'
7
+ import { rejectRemovedOptions, resolveFormat } from '../lib/querying-writing/response-options.js'
8
+ import { beginWriteTransaction } from '../../../lib/error-context.js'
9
+ import { RestApiResourceError, RestApiValidationError } from '../../../lib/rest-api-errors.js'
10
+ import { chunk } from '../lib/storage/many-row-data-helpers.js'
11
+ import {
12
+ applyFieldSetters,
13
+ collectRelationshipTargets,
14
+ commitOwnedTransaction,
15
+ handleWriteMethodError,
16
+ keepHiddenLinks,
17
+ validateRelationshipTargets,
18
+ validateResourceAttributesBeforeWrite
19
+ } from './common.js'
20
+ import {
21
+ atIndex,
22
+ manyRowResponse,
23
+ noHooks,
24
+ refuseUnsupportedResource,
25
+ rejectBulkOptions,
26
+ resolveChunkSize,
27
+ resolveManyRowReturning,
28
+ selectManyInput,
29
+ toDocument,
30
+ unsupported
31
+ } from './many-row-common.js'
32
+ import { VISIBLE_READ_CHUNK } from './patch-many.js'
33
+
34
+ /**
35
+ * Upserts may only match the caller's own rows: the conflict keys must hold
36
+ * every ownership field, and merge cannot run under a row policy it cannot see.
37
+ * The id (by its own name or the idProperty's) may be the only key on a
38
+ * resource with neither, where no other owner's row can match.
39
+ */
40
+ function resolveOnConflict (onConflict, { scopeName, schemaInfo, scopes }) {
41
+ if (onConflict === undefined) return undefined
42
+ const fail = message => { throw new RestApiValidationError(message, { fields: ['onConflict'] }) }
43
+ if (!onConflict || typeof onConflict !== 'object' || Array.isArray(onConflict)) fail('onConflict must be an object')
44
+ const { keys: requestedKeys, action, merge, ...unknown } = onConflict
45
+ let keys = requestedKeys
46
+ if (Object.keys(unknown).length) fail(`Unknown onConflict options: ${Object.keys(unknown).join(', ')}`)
47
+ if (action !== 'ignore' && action !== 'merge') fail("onConflict.action must be 'ignore' or 'merge'")
48
+ const fields = schemaInfo.schemaStructure
49
+ const stored = field => typeof field === 'string' && Object.hasOwn(fields, field) && !fields[field].virtual && !fields[field].computed
50
+ const ownership = schemaInfo.ownershipFields || []
51
+ const rowPolicy = scopes[scopeName]?.vars?.rowPolicy
52
+ const idKey = Array.isArray(keys) && keys.some(field => field === 'id' || field === schemaInfo.idProperty)
53
+ if (idKey) {
54
+ if (keys.length !== 1) fail('onConflict on the id takes the id as its only key')
55
+ if (ownership.length || rowPolicy) {
56
+ throw unsupported(scopeName, 'postMany', 'onConflict on the id needs a resource without ownership fields or a row policy, or a conflict could match a row the caller cannot see; use keys that include the ownership field(s)')
57
+ }
58
+ keys = ['id']
59
+ } else if (!Array.isArray(keys) || !keys.length || !keys.every(stored)) fail('onConflict.keys must list stored fields of the resource')
60
+
61
+ const missingOwners = ownership.filter(field => !keys.includes(field))
62
+ if (missingOwners.length) {
63
+ throw unsupported(scopeName, 'postMany', `onConflict.keys must include the ownership field(s) ${missingOwners.join(', ')}, or a conflict could match another owner's row`)
64
+ }
65
+ if (action === 'merge' && rowPolicy) {
66
+ throw unsupported(scopeName, 'postMany', 'onConflict merge cannot see the row policy; insert with ignore, then patchMany the rows you may change')
67
+ }
68
+ const protectedFields = new Set(['id', ...keys, ...ownership, schemaInfo.lockVersionField].filter(Boolean))
69
+ if (action === 'merge' && merge !== undefined) {
70
+ if (!Array.isArray(merge) || !merge.length || !merge.every(stored)) fail('onConflict.merge must list stored fields of the resource')
71
+ const forbidden = merge.filter(field => protectedFields.has(field))
72
+ if (forbidden.length) fail(`onConflict.merge cannot change ${forbidden.join(', ')}`)
73
+ } else if (merge !== undefined) {
74
+ fail('onConflict.merge applies only to the merge action')
75
+ }
76
+ // Without a list, merge updates every stored field the rows supply.
77
+ const mergeFields = supplied => (merge ?? [...new Set(supplied.flatMap(fields => [...fields]))])
78
+ .filter(field => stored(field) && !protectedFields.has(field))
79
+ return { keys, action, mergeFields }
80
+ }
81
+
82
+ /**
83
+ * The fields a row supplies: those the caller sent (with belongs-to links),
84
+ * and those a setter added. Schema defaults are not supplied, even when a
85
+ * setter rewrites them, so a merge never resets a stored value to a default.
86
+ * @param {any} row
87
+ * @param {Record<string, unknown>} beforeSetters
88
+ * @param {Record<string, unknown>} afterSetters
89
+ */
90
+ function suppliedFields (row, beforeSetters, afterSetters) {
91
+ const fields = new Set(Object.keys(row.originalInputAttributes || {}))
92
+ for (const field of Object.keys(afterSetters)) if (!Object.hasOwn(beforeSetters, field)) fields.add(field)
93
+ return fields
94
+ }
95
+
96
+ /**
97
+ * Upsert merge: lock the rows the conflict keys match, check that the caller
98
+ * may patch each of them, insert the other rows with a plain INSERT (a clash on
99
+ * any other unique index fails the batch), then update the matched rows by id
100
+ * with the merge fields each row supplies.
101
+ * Returns the identifier of every row, merged or inserted.
102
+ * @param {any} args
103
+ * @param {{ rows: any[], attributes: Record<string, unknown>[], supplied: Set<string>[], onConflict: { keys: string[], merge: string[] }, chunkSize: number, needIds: boolean }} batch
104
+ */
105
+ async function mergeRows ({ context, helpers, scope, scopes, runHooks, scopeName }, { rows, attributes, supplied, onConflict, chunkSize, needIds }) {
106
+ const existing = await helpers.dataIdsByKeys({ scopeName, context, rows: attributes, keys: onConflict.keys, chunkSize })
107
+ const matched = attributes.flatMap((_row, index) => existing[index] === undefined ? [] : [index])
108
+ const missing = attributes.flatMap((_row, index) => existing[index] === undefined ? [index] : [])
109
+
110
+ const visible = new Map()
111
+ for (const part of chunk(matched.map(index => existing[index]), VISIBLE_READ_CHUNK)) {
112
+ const records = await helpers.dataGetMinimal({ scopeName, context, runHooks, ids: part, filters: {}, queryPurpose: 'bulk-write' })
113
+ for (const record of records) visible.set(String(record.id), record)
114
+ }
115
+ for (const index of matched) {
116
+ const id = String(existing[index])
117
+ const minimalRecord = visible.get(id)
118
+ try {
119
+ if (!minimalRecord) {
120
+ throw new RestApiResourceError(`The ${scopeName} record these conflict keys match cannot be changed by the caller`, { subtype: 'forbidden', resourceType: scopeName })
121
+ }
122
+ await scope.checkPermissions({ method: 'patch', originalContext: { ...rows[index], method: 'patch', id, minimalRecord, originalMinimalRecord: minimalRecord } })
123
+ } catch (error) { throw atIndex(error, index) }
124
+ }
125
+
126
+ const ids = [...existing]
127
+ if (missing.length) {
128
+ const inserted = await helpers.dataInsertMany({ scopeName, context, rows: missing.map(index => attributes[index]), chunkSize, needIds })
129
+ missing.forEach((index, position) => { ids[index] = inserted?.[position] })
130
+ }
131
+ if (matched.length) {
132
+ const storageAdapter = helpers.getStorageAdapter(scopeName)
133
+ // Only the merge fields a row supplies change; the lock version advances.
134
+ const merges = matched.map(index => Object.fromEntries(onConflict.merge
135
+ .filter(field => supplied[index].has(field))
136
+ .map(field => [field, attributes[index][field]])))
137
+ // A merge clears a link the way a patch does, so it keeps one to a hidden target.
138
+ await keepHiddenLinks({ context, scopes, schemaInfo: context.schemaInfo, rows: matched.map((index, position) => ({ updates: merges[position], stored: visible.get(String(existing[index])) })) })
139
+ const updates = matched.map((index, position) => ({
140
+ id: existing[index],
141
+ row: storageAdapter.toStorageRow(merges[position], { context: rows[index], operation: 'patch' })
142
+ }))
143
+ await helpers.dataUpdateByIds({ scopeName, context, updates, chunkSize })
144
+ }
145
+ return ids
146
+ }
147
+
148
+ /** @param {any} args */
149
+ export default async function postManyMethod ({
150
+ params, context, vars, helpers, scope, scopes, runHooks, hasHooks, perRecordHooks, scopeOptions, scopeName, api, log
151
+ }) {
152
+ context.method = 'postMany'
153
+ try {
154
+ rejectRemovedOptions(params)
155
+ rejectBulkOptions(params)
156
+ context.scopeName = scopeName
157
+ context.schemaInfo = scopes[scopeName].vars.schemaInfo
158
+ context.format = resolveFormat(params.format, vars.format)
159
+ context.simplified = context.format === 'plain'
160
+ context.returning = resolveManyRowReturning(params.returning)
161
+ context.params = params
162
+ const chunkSize = resolveChunkSize(params.chunkSize)
163
+ const { inputKey, records } = selectManyInput(params)
164
+ refuseUnsupportedResource({
165
+ api, helpers, perRecordHooks, scopeName, schemaInfo: context.schemaInfo, operation: 'postMany', kind: 'post', storageHelper: 'dataInsertMany'
166
+ })
167
+ const conflict = resolveOnConflict(params.onConflict, { scopeName, schemaInfo: context.schemaInfo, scopes })
168
+ if (conflict?.action === 'merge') {
169
+ // Merged rows are updates: the resource must take patches as a batch too.
170
+ refuseUnsupportedResource({
171
+ api, helpers, perRecordHooks, scopeName, schemaInfo: context.schemaInfo, operation: 'postMany', kind: 'patch', storageHelper: 'dataUpdateByIds'
172
+ })
173
+ }
174
+
175
+ await beginWriteTransaction(context, params.transaction, helpers.newTransaction, runHooks)
176
+ context.db = context.transaction
177
+
178
+ const schemaInfo = context.schemaInfo
179
+ context.inputRecords = records.map((record, index) => {
180
+ try {
181
+ return toDocument({ record, inputKey, scopeName, schemaInfo }).document
182
+ } catch (error) { throw atIndex(error, index) }
183
+ })
184
+ // Batch hooks: ownership stamping and the refusals of plugins that cannot batch.
185
+ await runHooks('beforeProcessingPostMany')
186
+
187
+ const contracts = getRequestContracts({ scopeName, schemaInfo, includeDepthLimit: vars.includeDepthLimit, sortableFields: vars.sortableFields })
188
+ const targets = new Map()
189
+ context.inputRecords = context.inputRecords.map((document, index) => {
190
+ try {
191
+ const validated = validateRequestContractOrThrow(contracts.post, document, 'POST request body is invalid')
192
+ if (validated.data.id !== undefined) validated.data.id = requireDocumentResourceId(validated.data.id, { scopeOptions, vars })
193
+ collectRelationshipTargets(validated, api, targets)
194
+ return validated
195
+ } catch (error) { throw atIndex(error, index) }
196
+ })
197
+ // One scoped read per related type for the whole batch.
198
+ await validateRelationshipTargets(context, [...targets.values()], helpers, api)
199
+
200
+ const parents = []
201
+ const rows = []
202
+ for (const [index, document] of context.inputRecords.entries()) {
203
+ const rowContext = { ...context, method: 'post', inputRecord: document, originalInputAttributes: undefined, bulkIndex: index }
204
+ try {
205
+ const { belongsToUpdates, belongsToTargets, manyToManyRelationships, reverseRelationships } = processRelationships(scope, { context: rowContext })
206
+ if (manyToManyRelationships.length || reverseRelationships.length) {
207
+ throw unsupported(scopeName, 'postMany', 'rows set many-to-many or reverse relationships; create those links with single writes')
208
+ }
209
+ if (Object.keys(belongsToUpdates).length) {
210
+ document.data.attributes = { ...document.data.attributes, ...belongsToUpdates }
211
+ }
212
+ await validateResourceAttributesBeforeWrite({ context: rowContext, schema: schemaInfo.schemaInstance, belongsToUpdates, runHooks: noHooks })
213
+ rowContext.minimalRecord = {
214
+ type: scopeName,
215
+ ...(document.data.id !== undefined && document.data.id !== null ? { id: String(document.data.id) } : {}),
216
+ attributes: structuredClone(document.data.attributes || {}),
217
+ relationships: structuredClone(document.data.relationships || {})
218
+ }
219
+ await scope.checkPermissions({ method: 'post', originalContext: rowContext })
220
+ parents.push(...belongsToTargets)
221
+ rows.push(rowContext)
222
+ } catch (error) { throw atIndex(error, index) }
223
+ }
224
+
225
+ if (conflict) {
226
+ const byId = conflict.keys[0] === 'id'
227
+ rows.forEach((row, index) => {
228
+ const id = row.inputRecord.data.id
229
+ if (byId && (id === undefined || id === null)) throw atIndex(new RestApiValidationError('An upsert on the id needs an id in every row', { fields: ['data.id'] }), index)
230
+ if (byId || id === undefined || id === null) return
231
+ throw atIndex(new RestApiValidationError('An upsert row cannot carry an id: its conflict keys identify it', { fields: ['data.id'] }), index)
232
+ })
233
+ if (!byId) {
234
+ rows.forEach((row, index) => {
235
+ const attributes = row.inputRecord.data.attributes || {}
236
+ const empty = conflict.keys.filter(field => attributes[field] === undefined || attributes[field] === null)
237
+ if (empty.length) throw atIndex(new RestApiValidationError(`Conflict keys need values: ${empty.join(', ')}`, { fields: empty.map(field => `data.attributes.${field}`) }), index)
238
+ })
239
+ }
240
+ }
241
+
242
+ await runHooks('beforeDataCallPostMany')
243
+ // Parents are locked once for the batch, in the transaction.
244
+ await lockRelationshipTargets(api, context.transaction, parents)
245
+
246
+ const attributes = []
247
+ const supplied = []
248
+ for (const [index, row] of rows.entries()) {
249
+ const beforeSetters = { ...(row.inputRecord.data.attributes || {}) }
250
+ try {
251
+ await applyFieldSetters(row, api, helpers)
252
+ } catch (error) { throw atIndex(error, index) }
253
+ const data = row.inputRecord.data
254
+ supplied.push(suppliedFields(row, beforeSetters, data.attributes || {}))
255
+ attributes.push({ ...(data.attributes || {}), ...(data.id !== undefined ? { id: data.id } : {}) })
256
+ }
257
+
258
+ let onConflict
259
+ if (conflict) {
260
+ onConflict = { keys: conflict.keys, action: conflict.action, merge: conflict.action === 'merge' ? conflict.mergeFields(supplied) : [] }
261
+ if (conflict.action === 'merge' && !onConflict.merge.length) {
262
+ throw new RestApiValidationError('onConflict merge has no fields to update; use ignore', { fields: ['onConflict'] })
263
+ }
264
+ const seen = new Map()
265
+ attributes.forEach((row, index) => {
266
+ const key = JSON.stringify(onConflict.keys.map(field => row[field]))
267
+ if (seen.has(key)) {
268
+ throw atIndex(new RestApiValidationError(`Rows ${seen.get(key)} and ${index} have the same conflict keys`, {
269
+ fields: onConflict.keys.map(field => `data.attributes.${field}`)
270
+ }), index)
271
+ }
272
+ seen.set(key, index)
273
+ })
274
+ }
275
+
276
+ // Without a conflict every row is inserted or the batch fails, so a count needs no ids.
277
+ const countsRows = context.returning === 'count' && !onConflict
278
+ const needIds = (context.returning !== 'none' && !countsRows) || hasHooks('afterDataCallPostMany')
279
+ const ids = onConflict?.action === 'merge'
280
+ ? await mergeRows({ context, helpers, scope, scopes, runHooks, scopeName }, { rows, attributes, supplied, onConflict, chunkSize, needIds })
281
+ : await helpers.dataInsertMany({ scopeName, context, rows: attributes, chunkSize, needIds, onConflict })
282
+ context.ids = ids?.map(id => id === undefined || id === null ? undefined : String(id))
283
+ await runHooks('afterDataCallPostMany')
284
+
285
+ const response = countsRows
286
+ ? { meta: { count: rows.length } }
287
+ : await manyRowResponse({
288
+ api, scopeName, context, ids: context.ids || [], returning: context.returning, format: context.format, queryParams: params.queryParams
289
+ })
290
+ await commitOwnedTransaction(context)
291
+ return response
292
+ } catch (error) {
293
+ await handleWriteMethodError(error, context, 'POST_MANY', scopeName, log)
294
+ }
295
+ }
@@ -19,7 +19,8 @@ import {
19
19
  validatePivotResource,
20
20
  handleRecordReturnAfterWrite,
21
21
  handleWriteMethodError,
22
- commitOwnedTransaction
22
+ commitOwnedTransaction,
23
+ keepHiddenLinks
23
24
  } from './common.js'
24
25
 
25
26
  /**
@@ -130,6 +131,9 @@ export default async function putMethod ({
130
131
  }
131
132
  }
132
133
  }
134
+ if (context.isUpdate) {
135
+ await keepHiddenLinks({ context, scopes, schemaInfo: context.schemaInfo, rows: [{ updates: belongsToUpdates, stored: context.minimalRecord, inputRecord: context.inputRecord }] })
136
+ }
133
137
 
134
138
  // Merge belongsTo updates with attributes
135
139
  if (Object.keys(belongsToUpdates).length > 0) {