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
@@ -1,475 +1,93 @@
1
1
  import { RestApiValidationError } from '../../lib/rest-api-errors.js'
2
- import { beginWriteTransaction, commitTransaction, onTransactionFinished, rollbackAfterError, withWriteOutcome } from '../../lib/error-context.js'
3
- import { rejectRemovedOptions, resolveFormat, resolveReturning } from './lib/querying-writing/response-options.js'
4
- import { selectWriteInput } from './lib/querying-writing/request-contracts.js'
5
2
 
6
- function validateAtomic (atomic, field = 'atomic') {
7
- if (typeof atomic !== 'boolean') {
8
- throw new RestApiValidationError(`${field} must be a boolean`, { fields: [field] })
9
- }
10
- }
11
-
12
- function validateTransactionMode (atomic, transaction) {
13
- if (!atomic && transaction) {
14
- throw new RestApiValidationError('A caller transaction requires atomic bulk operations', { fields: ['transaction'] })
15
- }
16
- }
17
-
18
- function resolveBulkVersions (params, records, schemaInfo) {
19
- if (params.expectedVersion !== undefined) {
20
- throw new RestApiValidationError('Bulk writes require expectedVersions aligned with the batch', { fields: ['expectedVersion'] })
21
- }
22
- const versions = params.expectedVersions
23
- if (versions === undefined) return undefined
24
- if (!records || !schemaInfo?.versionField || !Array.isArray(versions) || versions.length !== records.length ||
25
- Array.from(versions).some(token => typeof token !== 'string' || !token.length || token.length > 128)) {
26
- throw new RestApiValidationError('expectedVersions requires a versioned update/delete batch with one non-empty token of at most 128 characters per item', { fields: ['expectedVersions'] })
27
- }
28
- return versions.slice()
29
- }
30
-
31
- function retainChildCleanup (context, recordContext, bulkIndex) {
32
- let copiedErrors = 0
33
- let copiedUploads = []
34
- const collect = () => {
35
- const errors = recordContext.cleanupErrors || []
36
- if (errors.length > copiedErrors) {
37
- const entries = context.cleanupErrors ||= []
38
- for (const entry of errors.slice(copiedErrors)) entries.push({ ...entry, bulkIndex })
39
- copiedErrors = errors.length
40
- }
41
- if (copiedUploads.length) context.fileHandlingUploads = context.fileHandlingUploads.filter(entry => !copiedUploads.includes(entry))
42
- copiedUploads = (recordContext.fileHandlingUploads || []).map(entry => ({ ...entry, bulkIndex }))
43
- if (copiedUploads.length) (context.fileHandlingUploads ||= []).push(...copiedUploads)
44
- }
45
- collect()
46
- onTransactionFinished(recordContext.transaction, collect)
47
- }
3
+ const DEFAULT_MAX_BULK_OPERATIONS = 1000
48
4
 
5
+ /**
6
+ * HTTP routes for many-row writes: POST, PATCH and DELETE /<type>/bulk run
7
+ * postMany, patchMany and deleteMany, one statement per chunk and all or
8
+ * nothing. Programs call those methods directly; this plugin only exposes them
9
+ * over HTTP with a request size limit.
10
+ */
49
11
  export const BulkOperationsPlugin = {
50
12
  name: 'bulk-operations',
51
13
  dependencies: ['rest-api'],
52
14
 
53
- async install ({ api, log, addHook, addResourceMethod, helpers, pluginOptions }) {
15
+ async install ({ api, log, addHook, pluginOptions }) {
54
16
  const bulkOptions = pluginOptions || {}
55
- const {
56
- maxBulkOperations = 100,
57
- defaultAtomic = true
58
- } = bulkOptions
59
- const unknown = Object.keys(bulkOptions).filter(name => !['maxBulkOperations', 'defaultAtomic'].includes(name))
17
+ const { maxBulkOperations = DEFAULT_MAX_BULK_OPERATIONS } = bulkOptions
18
+ const unknown = Object.keys(bulkOptions).filter(name => name !== 'maxBulkOperations')
60
19
  if (unknown.length) {
61
- throw new RestApiValidationError(`Unsupported bulk options: ${unknown.join(', ')}. Pass maxBulkOperations and defaultAtomic directly.`, { fields: unknown })
20
+ throw new RestApiValidationError(`Unsupported bulk options: ${unknown.join(', ')}. Bulk routes are all-or-nothing and take only maxBulkOperations.`, { fields: unknown })
62
21
  }
63
22
  if (!Number.isSafeInteger(maxBulkOperations) || maxBulkOperations < 1) {
64
23
  throw new RestApiValidationError('maxBulkOperations must be a positive safe integer', { fields: ['maxBulkOperations'] })
65
24
  }
66
- validateAtomic(defaultAtomic, 'defaultAtomic')
67
-
68
- log.info('Installing Bulk Operations plugin', { maxBulkOperations, defaultAtomic })
69
25
 
70
- // Add bulk methods to each scope
71
- addResourceMethod('bulkPost', withWriteOutcome(async ({ scope, vars, params, context }) => {
72
- const { atomic = defaultAtomic } = params
73
- rejectRemovedOptions(params)
74
- const inputKey = selectWriteInput(params)
75
- const inputRecords = inputKey === 'document' ? params.document.data : params.data
76
- validateAtomic(atomic)
77
- validateTransactionMode(atomic, params.transaction)
78
- resolveBulkVersions(params)
79
- const format = resolveFormat(params.format, vars.format)
80
- const returning = resolveReturning(params.returning, vars.returning)
26
+ log.info('Installing Bulk Operations plugin', { maxBulkOperations })
81
27
 
82
- // Validate bulk size
83
- if (!Array.isArray(inputRecords) || inputRecords.length === 0) {
84
- throw new RestApiValidationError('Bulk operations require an array of records', {
28
+ // The body's data array, checked against the limit before anything runs.
29
+ const bodyRows = (body, queryString) => {
30
+ const query = [...new URLSearchParams(queryString).keys()]
31
+ if (query.length) {
32
+ throw new RestApiValidationError(`Bulk routes take no query parameters: ${query.join(', ')}`, { fields: query })
33
+ }
34
+ const rows = body?.data
35
+ if (!Array.isArray(rows) || rows.length === 0) {
36
+ throw new RestApiValidationError('A bulk request body is { data: [...] } with at least one entry', {
85
37
  fields: ['data'],
86
38
  violations: [{ field: 'data', rule: 'required_array', message: 'Must be a non-empty array' }]
87
39
  })
88
40
  }
89
-
90
- if (inputRecords.length > maxBulkOperations) {
91
- throw new RestApiValidationError(`Bulk operations limited to ${maxBulkOperations} records`, {
41
+ if (rows.length > maxBulkOperations) {
42
+ throw new RestApiValidationError(`Bulk requests are limited to ${maxBulkOperations} entries`, {
92
43
  fields: ['data'],
93
- violations: [{
94
- field: 'data',
95
- rule: 'max_items',
96
- message: `Cannot process more than ${maxBulkOperations} records at once`
97
- }]
98
- })
99
- }
100
-
101
- const results = []
102
- const errors = []
103
- let transaction = null
104
- const ownsTransaction = !params.transaction
105
-
106
- try {
107
- // Start transaction if atomic mode
108
- if (atomic) {
109
- transaction = await beginWriteTransaction(context, params.transaction, helpers.newTransaction)
110
- }
111
-
112
- for (const [recordIndex, inputRecord] of inputRecords.entries()) {
113
- const recordContext = {
114
- ...context,
115
- cleanupErrors: undefined,
116
- fileHandlingUploads: undefined,
117
- bulkOperation: true,
118
- bulkIndex: recordIndex
119
- }
120
- try {
121
- // Use the existing post method with transaction
122
- const result = await scope.post({
123
- ...(inputKey === 'document'
124
- ? { document: { ...params.document, data: inputRecord } }
125
- : { data: inputRecord }),
126
- transaction,
127
- format,
128
- returning
129
- }, recordContext)
130
-
131
- results.push({
132
- index: recordIndex,
133
- status: 'success',
134
- data: format === 'jsonapi' ? result?.data : result
135
- })
136
- } catch (error) {
137
- if (atomic) {
138
- throw error
139
- } else {
140
- // In non-atomic mode, collect errors
141
- errors.push({
142
- index: recordIndex,
143
- status: 'error',
144
- error: {
145
- code: error.code || 'UNKNOWN_ERROR',
146
- message: error.message,
147
- details: error.details,
148
- transactionOutcome: error.transactionOutcome
149
- }
150
- })
151
- }
152
- } finally { retainChildCleanup(context, recordContext, recordIndex) }
153
- }
154
-
155
- // Commit transaction if atomic
156
- if (transaction && ownsTransaction) {
157
- await commitTransaction(transaction, context)
158
- }
159
-
160
- // Build response
161
- return {
162
- ...(returning === 'none' ? {} : { data: results.map(r => r.data) }),
163
- errors: errors.length > 0 ? errors : undefined,
164
- meta: {
165
- total: inputRecords.length,
166
- succeeded: results.length,
167
- failed: errors.length,
168
- atomic
169
- }
170
- }
171
- } catch (error) {
172
- await rollbackAfterError(error, context, ownsTransaction ? transaction : null)
173
- throw error
174
- }
175
- }))
176
-
177
- addResourceMethod('bulkPatch', withWriteOutcome(async ({ scope, vars, params, context }) => {
178
- const { operations, atomic = defaultAtomic } = params
179
- rejectRemovedOptions(params)
180
- validateAtomic(atomic)
181
- validateTransactionMode(atomic, params.transaction)
182
- const format = resolveFormat(params.format, vars.format)
183
- const returning = resolveReturning(params.returning, vars.returning)
184
-
185
- // Validate operations
186
- if (!Array.isArray(operations) || operations.length === 0) {
187
- throw new RestApiValidationError('Bulk patch requires an array of operations', {
188
- fields: ['operations'],
189
- violations: [{ field: 'operations', rule: 'required_array', message: 'Must be a non-empty array' }]
44
+ violations: [{ field: 'data', rule: 'max_items', message: `Cannot process more than ${maxBulkOperations} entries at once` }]
190
45
  })
191
46
  }
47
+ return rows
48
+ }
192
49
 
193
- if (operations.length > maxBulkOperations) {
194
- throw new RestApiValidationError(`Bulk operations limited to ${maxBulkOperations} operations`, {
195
- fields: ['operations'],
196
- violations: [{
197
- field: 'operations',
198
- rule: 'max_items',
199
- message: `Cannot process more than ${maxBulkOperations} operations at once`
200
- }]
201
- })
202
- }
203
-
204
- const expectedVersions = resolveBulkVersions(params, operations, vars.schemaInfo)
205
- const results = []
206
- const errors = []
207
- let transaction = null
208
- const ownsTransaction = !params.transaction
209
-
210
- try {
211
- if (atomic) {
212
- transaction = await beginWriteTransaction(context, params.transaction, helpers.newTransaction)
213
- }
214
-
215
- for (let i = 0; i < operations.length; i++) {
216
- const operation = operations[i]
217
-
218
- // Validate operation structure
219
- if (!operation || operation.id === undefined || operation.id === null) {
220
- errors.push({
221
- index: i,
222
- status: 'error',
223
- error: {
224
- code: 'INVALID_OPERATION',
225
- message: 'Operation must include id and exactly one of data or document',
226
- details: { operation },
227
- transactionOutcome: 'none'
228
- }
229
- })
230
- if (atomic) {
231
- throw new RestApiValidationError('Invalid operation structure', {
232
- fields: [`operations[${i}]`],
233
- violations: [{
234
- field: `operations[${i}]`,
235
- rule: 'required_fields',
236
- message: 'Operation must include id and exactly one of data or document'
237
- }]
238
- })
239
- }
240
- continue
241
- }
242
-
243
- const recordContext = {
244
- ...context,
245
- cleanupErrors: undefined,
246
- fileHandlingUploads: undefined,
247
- bulkOperation: true,
248
- bulkIndex: i
249
- }
250
- try {
251
- const result = await scope.patch({
252
- ...operation,
253
- expectedVersion: expectedVersions?.[i],
254
- transaction,
255
- format,
256
- returning
257
- }, recordContext)
258
-
259
- results.push({
260
- index: i,
261
- id: operation.id,
262
- status: 'success',
263
- data: format === 'jsonapi' ? result?.data : result
264
- })
265
- } catch (error) {
266
- if (atomic) {
267
- throw error
268
- } else {
269
- errors.push({
270
- index: i,
271
- id: operation.id,
272
- status: 'error',
273
- error: {
274
- code: error.code || 'UNKNOWN_ERROR',
275
- message: error.message,
276
- details: error.details,
277
- transactionOutcome: error.transactionOutcome
278
- }
279
- })
280
- }
281
- } finally { retainChildCleanup(context, recordContext, i) }
282
- }
283
-
284
- if (transaction && ownsTransaction) {
285
- await commitTransaction(transaction, context)
286
- }
287
-
288
- return {
289
- ...(returning === 'none' ? {} : { data: results.map(r => r.data) }),
290
- errors: errors.length > 0 ? errors : undefined,
291
- meta: {
292
- total: operations.length,
293
- succeeded: results.length,
294
- failed: errors.length,
295
- atomic
296
- }
297
- }
298
- } catch (error) {
299
- await rollbackAfterError(error, context, ownsTransaction ? transaction : null)
300
- throw error
301
- }
302
- }))
303
-
304
- addResourceMethod('bulkDelete', withWriteOutcome(async ({ scope, vars, params, context }) => {
305
- const { ids, atomic = defaultAtomic } = params
306
- rejectRemovedOptions(params)
307
- validateAtomic(atomic)
308
- validateTransactionMode(atomic, params.transaction)
309
- if (params.format !== undefined) resolveFormat(params.format)
310
-
311
- // Validate IDs
312
- if (!Array.isArray(ids) || ids.length === 0) {
313
- throw new RestApiValidationError('Bulk delete requires an array of IDs', {
314
- fields: ['ids'],
315
- violations: [{ field: 'ids', rule: 'required_array', message: 'Must be a non-empty array' }]
316
- })
317
- }
318
-
319
- if (ids.length > maxBulkOperations) {
320
- throw new RestApiValidationError(`Bulk operations limited to ${maxBulkOperations} IDs`, {
321
- fields: ['ids'],
322
- violations: [{
323
- field: 'ids',
324
- rule: 'max_items',
325
- message: `Cannot process more than ${maxBulkOperations} IDs at once`
326
- }]
327
- })
328
- }
329
-
330
- const expectedVersions = resolveBulkVersions(params, ids, vars.schemaInfo)
331
- const results = []
332
- const errors = []
333
- let transaction = null
334
- const ownsTransaction = !params.transaction
335
-
336
- try {
337
- if (atomic) {
338
- transaction = await beginWriteTransaction(context, params.transaction, helpers.newTransaction)
339
- }
340
-
341
- // Process deletes
342
- for (let i = 0; i < ids.length; i++) {
343
- const id = ids[i]
344
-
345
- const recordContext = {
346
- ...context,
347
- cleanupErrors: undefined,
348
- fileHandlingUploads: undefined,
349
- bulkOperation: true,
350
- bulkIndex: i
351
- }
352
- try {
353
- await scope.delete({
354
- id,
355
- expectedVersion: expectedVersions?.[i],
356
- transaction
357
- }, recordContext)
358
-
359
- results.push({
360
- index: i,
361
- id,
362
- status: 'success'
363
- })
364
- } catch (error) {
365
- if (atomic) {
366
- throw error
367
- } else {
368
- errors.push({
369
- index: i,
370
- id,
371
- status: 'error',
372
- error: {
373
- code: error.code || 'UNKNOWN_ERROR',
374
- message: error.message,
375
- details: error.details,
376
- transactionOutcome: error.transactionOutcome
377
- }
378
- })
379
- }
380
- } finally { retainChildCleanup(context, recordContext, i) }
381
- }
382
-
383
- if (transaction && ownsTransaction) {
384
- await commitTransaction(transaction, context)
385
- }
386
-
387
- return {
388
- meta: {
389
- total: ids.length,
390
- succeeded: results.length,
391
- failed: errors.length,
392
- deleted: results.filter(r => r.status === 'success').map(r => r.id),
393
- atomic
394
- },
395
- errors: errors.length > 0 ? errors : undefined
396
- }
397
- } catch (error) {
398
- await rollbackAfterError(error, context, ownsTransaction ? transaction : null)
399
- throw error
400
- }
401
- }))
402
-
403
- // Hook into scope creation to add bulk routes
404
50
  addHook('resource:added', 'bulkOperationsRoutes', { beforeFunction: 'registerScopeRoutes' }, async ({ context: { scopeName } }) => {
405
51
  const urlPrefix = api.vars.transport?.mountPath || ''
406
- const scopePath = `${urlPrefix}/${scopeName}`
407
-
408
- // Create route handlers
409
- const createBulkRouteHandler = (method) => {
410
- return async ({ context, body, queryString }) => {
411
- const query = Object.fromEntries(new URLSearchParams(queryString))
412
- rejectRemovedOptions(query)
413
- if (query.atomic !== undefined && query.atomic !== 'true' && query.atomic !== 'false') {
414
- throw new RestApiValidationError('atomic must be true or false', { fields: ['atomic'] })
415
- }
416
- // Parse query params for atomic mode override
417
- const atomic = query?.atomic !== undefined
418
- ? query.atomic === 'true'
419
- : defaultAtomic
420
-
421
- let params
422
- if (method === 'bulkPost') {
423
- // For bulk create, expect array in data field (JSON:API style)
424
- params = {
425
- document: body,
426
- atomic
427
- }
428
- } else if (method === 'bulkPatch') {
429
- // For bulk update, expect operations array
430
- const operations = body?.operations || body
431
- params = {
432
- operations: Array.isArray(operations)
433
- ? operations.map(operation => ({ id: operation?.id, document: { data: operation?.data } }))
434
- : operations,
435
- atomic
436
- }
437
- } else if (method === 'bulkDelete') {
438
- // For bulk delete, expect IDs array
439
- params = {
440
- ids: body?.data || body?.ids || body,
441
- atomic
442
- }
443
- }
444
-
445
- const result = await api.resources[scopeName][method]({
446
- ...params,
447
- expectedVersion: body?.expectedVersion,
448
- expectedVersions: body?.expectedVersions,
449
- format: 'jsonapi',
450
- returning: 'full'
451
- }, context)
452
- return { statusCode: method === 'bulkPost' ? 201 : 200, body: result }
453
- }
454
- }
52
+ const path = `${urlPrefix}/${scopeName}/bulk`
53
+ const resource = () => api.resources[scopeName]
455
54
 
456
- // Register bulk routes
55
+ // Body: { data: [resource objects] }; responds 201 { data: [records] }.
457
56
  await api.addRoute({
458
57
  method: 'POST',
459
- path: `${scopePath}/bulk`,
460
- handler: createBulkRouteHandler('bulkPost')
58
+ path,
59
+ handler: async ({ context, body, queryString }) => {
60
+ bodyRows(body, queryString)
61
+ const result = await resource().postMany({ document: body, format: 'jsonapi', returning: 'full' }, context)
62
+ return { statusCode: 201, body: result }
63
+ }
461
64
  })
462
65
 
66
+ // Body: { data: [resource objects with id] }; responds 200 { data: [records] }.
463
67
  await api.addRoute({
464
68
  method: 'PATCH',
465
- path: `${scopePath}/bulk`,
466
- handler: createBulkRouteHandler('bulkPatch')
69
+ path,
70
+ handler: async ({ context, body, queryString }) => {
71
+ bodyRows(body, queryString)
72
+ const result = await resource().patchMany({ document: body, format: 'jsonapi', returning: 'full' }, context)
73
+ return { statusCode: 200, body: result }
74
+ }
467
75
  })
468
76
 
77
+ // Body: { data: [{ type, id }] }; responds 200 { data: [the deleted identifiers] }.
469
78
  await api.addRoute({
470
79
  method: 'DELETE',
471
- path: `${scopePath}/bulk`,
472
- handler: createBulkRouteHandler('bulkDelete')
80
+ path,
81
+ handler: async ({ context, body, queryString }) => {
82
+ const ids = bodyRows(body, queryString).map((identifier, index) => {
83
+ if (identifier?.type !== scopeName || identifier.id === undefined || identifier.id === null) {
84
+ throw new RestApiValidationError(`data[${index}] must be a resource identifier { type: '${scopeName}', id }`, { fields: [`data[${index}]`] })
85
+ }
86
+ return identifier.id
87
+ })
88
+ const result = await resource().deleteMany({ ids, returning: 'minimal' }, context)
89
+ return { statusCode: 200, body: result }
90
+ }
473
91
  })
474
92
 
475
93
  log.debug(`Added bulk operation routes for scope: ${scopeName}`)
@@ -23,8 +23,32 @@ export function mergeResponseHeaders (...sources) {
23
23
  export function getHttpDiagnosticError (error) {
24
24
  // Parser messages and stacks can quote raw input before a resource is known.
25
25
  // Keep the original error for hooks/responses, but log only this safe preview.
26
- if (error?.type !== 'entity.parse.failed') return error
27
- return { name: 'SyntaxError', message: 'Invalid JSON request body', type: 'entity.parse.failed', status: 400 }
26
+ if (error?.type === 'entity.parse.failed') {
27
+ return { name: 'SyntaxError', message: 'Invalid JSON request body', type: 'entity.parse.failed', status: 400 }
28
+ }
29
+ // A database error repeats the stored values: in the message Knex builds
30
+ // from the SQL and its bindings, in MySQL's sql and sqlMessage, and in
31
+ // PostgreSQL's detail. Log only which failure it was.
32
+ const failure = databaseFailure(error)
33
+ return failure ? { name: failure.name, code: failure.code } : error
34
+ }
35
+
36
+ /**
37
+ * The database driver error behind `error`, which may wrap it as its cause
38
+ * (a RestApiWriteError does).
39
+ * @param {unknown} error
40
+ */
41
+ function databaseFailure (error) {
42
+ let current = error
43
+ for (let depth = 0; current && typeof current === 'object' && depth < 10; depth++) {
44
+ if (!isRestApiError(current)) {
45
+ const { sqlState, sqlMessage, errno, severity, code } = /** @type {any} */ (current)
46
+ // mysql2 (and socket) errors, pg errors, and better-sqlite3 or sqlite3 errors.
47
+ if (typeof sqlState === 'string' || typeof sqlMessage === 'string' || typeof errno === 'number' ||
48
+ typeof severity === 'string' || /^SQLITE_/.test(String(code ?? ''))) return current
49
+ }
50
+ current = /** @type {any} */ (current).cause
51
+ }
28
52
  }
29
53
 
30
54
  const JSON_API_WRITE_CONTENT_TYPES = [
@@ -115,11 +139,15 @@ export function mapRestApiErrorToHttp (error) {
115
139
  // Read hooks can reject with null or undefined.
116
140
  error ??= {}
117
141
  const statusHint = error.statusCode ?? error.status
142
+ // Only the library's own errors carry messages meant for clients; a write
143
+ // error wraps what failed. Other messages, a database's above all, can
144
+ // repeat SQL and stored values.
145
+ const origin = isRestApiError(error) && error instanceof RestApiWriteError ? error.cause : error
118
146
  let status = 500
119
147
  let errors = [{
120
148
  status: '500',
121
149
  title: 'Internal Server Error',
122
- detail: error.message
150
+ detail: isRestApiError(origin) ? error.message : 'An unexpected error occurred'
123
151
  }]
124
152
 
125
153
  if (error?.validation) {
@@ -172,6 +200,9 @@ export function mapRestApiErrorToHttp (error) {
172
200
  ...(error.details ? { source: error.details } : {})
173
201
  }]
174
202
  }
203
+ } else if (error.code === 'REST_API_BULK_WRITE_UNSUPPORTED') {
204
+ status = 422
205
+ errors = [{ status: '422', code: error.code, title: 'Unsupported Bulk Write', detail: error.message }]
175
206
  } else if (error.code === 'REST_API_PRECONDITION_FAILED') {
176
207
  status = 412
177
208
  errors = [{ status: '412', code: error.code, title: 'Precondition Failed', detail: error.message }]
@@ -1,6 +1,6 @@
1
1
  /** File detection, uploads and cleanup tied to resource writes. */
2
2
 
3
- import { RestApiValidationError } from '../../lib/rest-api-errors.js'
3
+ import { RestApiBulkWriteUnsupportedError, RestApiValidationError } from '../../lib/rest-api-errors.js'
4
4
  import { getOperationDiagnosticContext, wrapUnexpectedError } from '../../lib/error-context.js'
5
5
  import { createEnhancedLogger } from '../../lib/enhanced-logger.js'
6
6
 
@@ -218,11 +218,26 @@ export const FileHandlingPlugin = {
218
218
  }
219
219
  }
220
220
 
221
- addHook('beforeProcessing', 'processFiles', {}, async ({ context }) => {
221
+ addHook('beforeProcessing', 'processFiles', { batch: true }, async ({ context }) => {
222
222
  if (!['post', 'put', 'patch'].includes(context.method)) return
223
223
  await processFiles(context.scopeName, context.params, context)
224
224
  })
225
225
 
226
+ // Uploads belong to one record each; many-row writes refuse rows that set file fields.
227
+ const refuseFileFields = ({ context, scopeName }) => {
228
+ const fileFields = getFileFields(scopeName)
229
+ const set = fileFields.filter(field => context.rawFields?.includes(field.field) ||
230
+ context.inputRecords.some(record => record?.data?.attributes?.[field.field] !== undefined))
231
+ if (!set.length) return
232
+ throw new RestApiBulkWriteUnsupportedError({
233
+ resourceType: scopeName,
234
+ operation: context.method,
235
+ reason: `rows set file fields (${set.map(field => field.field).join(', ')}); upload files with single writes`
236
+ })
237
+ }
238
+ addHook('beforeProcessingPostMany', 'refuse-file-fields-post-many', {}, refuseFileFields)
239
+ addHook('beforeProcessingPatchMany', 'refuse-file-fields-patch-many', {}, refuseFileFields)
240
+
226
241
  addHook('afterRollback', 'cleanupUploadedFiles', {}, async ({ context }) => {
227
242
  await cleanupUploadedFiles(context)
228
243
  })
@@ -20,7 +20,8 @@ const DEFAULT_CACHE_SIZE = 100
20
20
  const EMPTY_RUNTIME = Object.freeze({ schema: {}, searchSchema: {}, callbacks: [] })
21
21
 
22
22
  // Errors from two servers inserting the same new resource at once.
23
- const WRITE_CONFLICT_CODES = new Set(['23505', '40001', '40P01', 'ER_DUP_ENTRY', 'ER_LOCK_DEADLOCK', 'SQLITE_BUSY', 'SQLITE_CONSTRAINT_UNIQUE'])
23
+ // ER_CHECKREAD (1020): MariaDB with innodb_snapshot_isolation refuses a row changed since the snapshot.
24
+ const WRITE_CONFLICT_CODES = new Set(['23505', '40001', '40P01', 'ER_DUP_ENTRY', 'ER_LOCK_DEADLOCK', 'ER_CHECKREAD', 'SQLITE_BUSY', 'SQLITE_CONSTRAINT_UNIQUE'])
24
25
  const creationRaces = new WeakSet()
25
26
 
26
27
  const resolveIdProperty = (schema = {}, explicitIdProperty = null) => {
@@ -113,7 +114,7 @@ const rowsSignature = (rows, columns) => JSON.stringify(rows.map(row => columns.
113
114
  return value ?? null
114
115
  })).sort((left, right) => String(left[0]).localeCompare(String(right[0]))))
115
116
 
116
- const isWriteConflict = error => WRITE_CONFLICT_CODES.has(error?.code) || [1062, 1213].includes(error?.errno)
117
+ const isWriteConflict = error => WRITE_CONFLICT_CODES.has(error?.code) || [1020, 1062, 1213].includes(error?.errno)
117
118
 
118
119
  /**
119
120
  * Canonical resource metadata. One registry can serve every tenant API that
@@ -432,7 +432,10 @@ export function createCanonicalLinkStore ({ api, getDescriptor, getScopeStorageA
432
432
  resolveId: row => row.relationship === options.relationshipKey && row.left_resource === options.descriptor.resource && normalizeId(row.left_id) === options.leftId ? row.right_id : row.left_id
433
433
  })
434
434
 
435
- const invalidateDeletedLinkTargets = async (scopeName, context) => {
435
+ // Advances the versions of parents linked to a record about to be deleted.
436
+ // When there are such parents, `lockRow` first locks the record's row against
437
+ // link writers, and answers not found when it has gone.
438
+ const invalidateDeletedLinkTargets = async (scopeName, context, lockRow) => {
436
439
  const relationships = []
437
440
  for (const [parentType, parent] of Object.entries(api.resources)) {
438
441
  if (!parent.vars.schemaInfo?.versionField) continue
@@ -444,7 +447,7 @@ export function createCanonicalLinkStore ({ api, getDescriptor, getScopeStorageA
444
447
  }
445
448
  if (!relationships.length) return
446
449
  assertVersionedRelationshipTransaction(api, relationships[0].inverse, context.transaction)
447
- await lockRelationshipTargets(api, context.transaction, [{ type: scopeName, id: context.id }], { exclusive: true })
450
+ await lockRow()
448
451
  const id = normalizeId(context.id)
449
452
  for (const { inverse, info } of relationships) {
450
453
  const query = buildLinkMutationQuery({ ...info, db: context.transaction, allParents: true }, (query, column) => query.where(column, id))