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.
- package/README.md +7 -2
- package/docs/API.md +262 -54
- package/docs/GUIDE/01-getting-started.md +1 -1
- package/docs/GUIDE/07-polymorphic-relationships.md +4 -2
- package/docs/GUIDE/09-pagination-and-sorting.md +4 -2
- package/docs/GUIDE/13-hooks-and-lifecycle.md +34 -6
- package/docs/GUIDE/14-custom-resource-methods.md +3 -2
- package/docs/GUIDE/16-autofiltering.md +54 -2
- package/docs/GUIDE/17-row-policies.md +15 -5
- package/docs/GUIDE/19-managed-transactions.md +8 -9
- package/docs/GUIDE/20-transaction-outcomes.md +3 -4
- package/docs/GUIDE/21-schema-and-migrations.md +7 -1
- package/docs/GUIDE/22-version-field-migrations.md +3 -3
- package/docs/GUIDE/27-many-row-writes.md +458 -0
- package/docs/GUIDE/28-socketio.md +10 -2
- package/docs/GUIDE/30-backend-capabilities.md +42 -2
- package/docs/GUIDE/31-positioning.md +3 -1
- package/docs/GUIDE/33-migrating-to-v2.md +2 -2
- package/docs/GUIDE/34-scoped-reads-and-locks.md +464 -0
- package/docs/GUIDE/35-migrating-to-v3.md +325 -0
- package/docs/GUIDE/index.md +5 -2
- package/docs/QUICKSTART.md +2 -2
- package/docs/contributing.md +1 -1
- package/index.d.ts +8 -1
- package/index.js +4 -0
- package/lib/rest-api-errors.js +79 -0
- package/lib/runtime/json-rest-api.js +18 -3
- package/lib/system-context.js +42 -0
- package/package.json +3 -2
- package/plugins/core/bulk-operations-plugin.js +54 -436
- package/plugins/core/connectors/lib/transport-http-helpers.js +34 -3
- package/plugins/core/file-handling-plugin.js +17 -2
- package/plugins/core/lib/anyapi/anyapi-registry.js +3 -2
- package/plugins/core/lib/anyapi/canonical-link-store.js +5 -2
- package/plugins/core/lib/anyapi/canonical-relationship-reader.js +56 -60
- package/plugins/core/lib/dbIntrospection.js +106 -6
- package/plugins/core/lib/querying/aggregate-query.js +215 -22
- package/plugins/core/lib/querying/include-query-helpers.js +11 -13
- package/plugins/core/lib/querying/include-to-one.js +54 -59
- package/plugins/core/lib/querying/knex-pagination-helpers.js +9 -2
- package/plugins/core/lib/querying/query-constraint.d.ts +11 -0
- package/plugins/core/lib/querying/query-constraint.js +45 -0
- package/plugins/core/lib/querying/read-only-query.js +92 -0
- package/plugins/core/lib/querying-writing/compile-schemas.js +14 -0
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
- package/plugins/core/lib/storage/canonical-data-helpers.js +103 -39
- package/plugins/core/lib/storage/many-row-data-helpers.js +738 -0
- package/plugins/core/lib/storage/ordinary-data-helpers.js +63 -79
- package/plugins/core/lib/storage/single-row-writes.js +71 -0
- package/plugins/core/lib/storage/storage-adapter.js +18 -8
- package/plugins/core/lib/storage/storage-mapping.js +4 -1
- package/plugins/core/lib/storage/storage-types.d.ts +21 -3
- package/plugins/core/lib/writing/many-to-many-manipulations.js +10 -5
- package/plugins/core/lib/writing/relationship-processor.js +33 -37
- package/plugins/core/rest-api-anyapi-knex-plugin.js +3 -0
- package/plugins/core/rest-api-autofilter-plugin.js +137 -10
- package/plugins/core/rest-api-knex-plugin.js +2 -0
- package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
- package/plugins/core/rest-api-plugin-methods/common.js +144 -84
- package/plugins/core/rest-api-plugin-methods/delete-many.js +132 -0
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +22 -0
- package/plugins/core/rest-api-plugin-methods/get.js +48 -42
- package/plugins/core/rest-api-plugin-methods/many-row-common.js +274 -0
- package/plugins/core/rest-api-plugin-methods/patch-many.js +333 -0
- package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
- package/plugins/core/rest-api-plugin-methods/post-many.js +295 -0
- package/plugins/core/rest-api-plugin-methods/put.js +5 -1
- package/plugins/core/rest-api-plugin-methods/scoped-reads.js +221 -0
- package/plugins/core/rest-api-plugin.js +17 -0
- package/plugins/core/rest-api-positioning-plugin.js +22 -4
- package/plugins/core/rest-api-query-projections-plugin.js +9 -1
- package/plugins/core/rest-api-row-policy-plugin.js +4 -0
- package/plugins/core/socketio-plugin.js +80 -2
- package/types/autofilter.d.ts +5 -1
- package/types/data-access-methods.d.ts +141 -0
- package/types/errors.d.ts +45 -0
- package/types/hook-context.d.ts +21 -0
- package/types/plugin-options.d.ts +3 -1
- package/types/resource-methods.d.ts +27 -4
- package/types/row-policy.d.ts +3 -0
- package/types/runtime.d.ts +7 -1
- package/docs/GUIDE/27-bulk-operations.md +0 -277
- 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
|
-
|
|
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,
|
|
15
|
+
async install ({ api, log, addHook, pluginOptions }) {
|
|
54
16
|
const bulkOptions = pluginOptions || {}
|
|
55
|
-
const {
|
|
56
|
-
|
|
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(', ')}.
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
55
|
+
// Body: { data: [resource objects] }; responds 201 { data: [records] }.
|
|
457
56
|
await api.addRoute({
|
|
458
57
|
method: 'POST',
|
|
459
|
-
path
|
|
460
|
-
handler:
|
|
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
|
|
466
|
-
handler:
|
|
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
|
|
472
|
-
handler:
|
|
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
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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))
|