json-rest-api 3.0.0-rc.0 → 3.0.1
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 +2 -2
- package/docs/API.md +60 -27
- 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 +6 -1
- package/docs/GUIDE/16-autofiltering.md +1 -1
- package/docs/GUIDE/17-row-policies.md +8 -6
- package/docs/GUIDE/21-schema-and-migrations.md +7 -1
- package/docs/GUIDE/27-many-row-writes.md +201 -28
- package/docs/GUIDE/30-backend-capabilities.md +16 -2
- package/docs/GUIDE/33-migrating-to-v2.md +2 -2
- package/docs/GUIDE/34-scoped-reads-and-locks.md +165 -26
- package/docs/GUIDE/35-migrating-to-v3.md +141 -10
- package/lib/rest-api-errors.js +6 -5
- package/package.json +2 -2
- package/plugins/core/connectors/lib/transport-http-helpers.js +31 -3
- package/plugins/core/file-handling-plugin.js +2 -1
- 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 +12 -6
- 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 +50 -13
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
- package/plugins/core/lib/storage/canonical-data-helpers.js +72 -41
- package/plugins/core/lib/storage/many-row-data-helpers.js +409 -124
- package/plugins/core/lib/storage/ordinary-data-helpers.js +67 -81
- 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 +12 -1
- package/plugins/core/lib/writing/many-to-many-manipulations.js +5 -2
- package/plugins/core/lib/writing/relationship-processor.js +33 -37
- package/plugins/core/rest-api-autofilter-plugin.js +30 -8
- package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
- package/plugins/core/rest-api-plugin-methods/common.js +117 -77
- package/plugins/core/rest-api-plugin-methods/delete-many.js +54 -28
- 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 +51 -8
- package/plugins/core/rest-api-plugin-methods/patch-many.js +81 -34
- package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
- package/plugins/core/rest-api-plugin-methods/post-many.js +149 -21
- package/plugins/core/rest-api-plugin-methods/put.js +5 -1
- package/plugins/core/rest-api-plugin-methods/scoped-reads.js +72 -27
- package/plugins/core/rest-api-plugin.js +2 -1
- package/plugins/core/rest-api-positioning-plugin.js +1 -1
- package/types/data-access-methods.d.ts +32 -11
- package/types/errors.d.ts +5 -4
- package/types/hook-context.d.ts +2 -0
|
@@ -4,14 +4,16 @@
|
|
|
4
4
|
import { lockRelationshipTargets, processRelationships } from '../lib/writing/relationship-processor.js'
|
|
5
5
|
import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
|
|
6
6
|
import { requireDocumentResourceId } from '../lib/querying-writing/resource-id-normalization.js'
|
|
7
|
-
import { rejectRemovedOptions, resolveFormat
|
|
7
|
+
import { rejectRemovedOptions, resolveFormat } from '../lib/querying-writing/response-options.js'
|
|
8
8
|
import { beginWriteTransaction } from '../../../lib/error-context.js'
|
|
9
|
-
import { RestApiValidationError } from '../../../lib/rest-api-errors.js'
|
|
9
|
+
import { RestApiResourceError, RestApiValidationError } from '../../../lib/rest-api-errors.js'
|
|
10
|
+
import { chunk } from '../lib/storage/many-row-data-helpers.js'
|
|
10
11
|
import {
|
|
11
12
|
applyFieldSetters,
|
|
12
13
|
collectRelationshipTargets,
|
|
13
14
|
commitOwnedTransaction,
|
|
14
15
|
handleWriteMethodError,
|
|
16
|
+
keepHiddenLinks,
|
|
15
17
|
validateRelationshipTargets,
|
|
16
18
|
validateResourceAttributesBeforeWrite
|
|
17
19
|
} from './common.js'
|
|
@@ -22,32 +24,45 @@ import {
|
|
|
22
24
|
refuseUnsupportedResource,
|
|
23
25
|
rejectBulkOptions,
|
|
24
26
|
resolveChunkSize,
|
|
27
|
+
resolveManyRowReturning,
|
|
25
28
|
selectManyInput,
|
|
26
29
|
toDocument,
|
|
27
30
|
unsupported
|
|
28
31
|
} from './many-row-common.js'
|
|
32
|
+
import { VISIBLE_READ_CHUNK } from './patch-many.js'
|
|
29
33
|
|
|
30
34
|
/**
|
|
31
35
|
* Upserts may only match the caller's own rows: the conflict keys must hold
|
|
32
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.
|
|
33
39
|
*/
|
|
34
40
|
function resolveOnConflict (onConflict, { scopeName, schemaInfo, scopes }) {
|
|
35
41
|
if (onConflict === undefined) return undefined
|
|
36
42
|
const fail = message => { throw new RestApiValidationError(message, { fields: ['onConflict'] }) }
|
|
37
43
|
if (!onConflict || typeof onConflict !== 'object' || Array.isArray(onConflict)) fail('onConflict must be an object')
|
|
38
|
-
const { keys, action, merge, ...unknown } = onConflict
|
|
44
|
+
const { keys: requestedKeys, action, merge, ...unknown } = onConflict
|
|
45
|
+
let keys = requestedKeys
|
|
39
46
|
if (Object.keys(unknown).length) fail(`Unknown onConflict options: ${Object.keys(unknown).join(', ')}`)
|
|
40
47
|
if (action !== 'ignore' && action !== 'merge') fail("onConflict.action must be 'ignore' or 'merge'")
|
|
41
48
|
const fields = schemaInfo.schemaStructure
|
|
42
49
|
const stored = field => typeof field === 'string' && Object.hasOwn(fields, field) && !fields[field].virtual && !fields[field].computed
|
|
43
|
-
if (!Array.isArray(keys) || !keys.length || !keys.every(stored)) fail('onConflict.keys must list stored fields of the resource')
|
|
44
|
-
|
|
45
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
|
+
|
|
46
61
|
const missingOwners = ownership.filter(field => !keys.includes(field))
|
|
47
62
|
if (missingOwners.length) {
|
|
48
63
|
throw unsupported(scopeName, 'postMany', `onConflict.keys must include the ownership field(s) ${missingOwners.join(', ')}, or a conflict could match another owner's row`)
|
|
49
64
|
}
|
|
50
|
-
if (action === 'merge' &&
|
|
65
|
+
if (action === 'merge' && rowPolicy) {
|
|
51
66
|
throw unsupported(scopeName, 'postMany', 'onConflict merge cannot see the row policy; insert with ignore, then patchMany the rows you may change')
|
|
52
67
|
}
|
|
53
68
|
const protectedFields = new Set(['id', ...keys, ...ownership, schemaInfo.lockVersionField].filter(Boolean))
|
|
@@ -59,11 +74,79 @@ function resolveOnConflict (onConflict, { scopeName, schemaInfo, scopes }) {
|
|
|
59
74
|
fail('onConflict.merge applies only to the merge action')
|
|
60
75
|
}
|
|
61
76
|
// Without a list, merge updates every stored field the rows supply.
|
|
62
|
-
const mergeFields =
|
|
77
|
+
const mergeFields = supplied => (merge ?? [...new Set(supplied.flatMap(fields => [...fields]))])
|
|
63
78
|
.filter(field => stored(field) && !protectedFields.has(field))
|
|
64
79
|
return { keys, action, mergeFields }
|
|
65
80
|
}
|
|
66
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
|
+
// dataIdsByKeys locked these rows, and may have found one that another
|
|
112
|
+
// transaction committed after this one's snapshot: read them as locked.
|
|
113
|
+
for (const part of chunk(matched.map(index => existing[index]), VISIBLE_READ_CHUNK)) {
|
|
114
|
+
const records = await helpers.dataGetMinimal({ scopeName, context, runHooks, ids: part, filters: {}, queryPurpose: 'bulk-write', locked: true })
|
|
115
|
+
for (const record of records) visible.set(String(record.id), record)
|
|
116
|
+
}
|
|
117
|
+
for (const index of matched) {
|
|
118
|
+
const id = String(existing[index])
|
|
119
|
+
const minimalRecord = visible.get(id)
|
|
120
|
+
try {
|
|
121
|
+
if (!minimalRecord) {
|
|
122
|
+
throw new RestApiResourceError(`The ${scopeName} record these conflict keys match cannot be changed by the caller`, { subtype: 'forbidden', resourceType: scopeName })
|
|
123
|
+
}
|
|
124
|
+
await scope.checkPermissions({ method: 'patch', originalContext: { ...rows[index], method: 'patch', id, minimalRecord, originalMinimalRecord: minimalRecord } })
|
|
125
|
+
} catch (error) { throw atIndex(error, index) }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const ids = [...existing]
|
|
129
|
+
if (missing.length) {
|
|
130
|
+
const inserted = await helpers.dataInsertMany({ scopeName, context, rows: missing.map(index => attributes[index]), chunkSize, needIds })
|
|
131
|
+
missing.forEach((index, position) => { ids[index] = inserted?.[position] })
|
|
132
|
+
}
|
|
133
|
+
if (matched.length) {
|
|
134
|
+
const storageAdapter = helpers.getStorageAdapter(scopeName)
|
|
135
|
+
// Only the merge fields a row supplies change; the lock version advances.
|
|
136
|
+
const merges = matched.map(index => Object.fromEntries(onConflict.merge
|
|
137
|
+
.filter(field => supplied[index].has(field))
|
|
138
|
+
.map(field => [field, attributes[index][field]])))
|
|
139
|
+
// A merge clears a link the way a patch does, so it keeps one to a hidden target.
|
|
140
|
+
await keepHiddenLinks({ context, scopes, schemaInfo: context.schemaInfo, rows: matched.map((index, position) => ({ updates: merges[position], stored: visible.get(String(existing[index])) })) })
|
|
141
|
+
const updates = matched.map((index, position) => ({
|
|
142
|
+
id: existing[index],
|
|
143
|
+
row: storageAdapter.toStorageRow(merges[position], { context: rows[index], operation: 'patch' })
|
|
144
|
+
}))
|
|
145
|
+
await helpers.dataUpdateByIds({ scopeName, context, updates, chunkSize })
|
|
146
|
+
}
|
|
147
|
+
return ids
|
|
148
|
+
}
|
|
149
|
+
|
|
67
150
|
/** @param {any} args */
|
|
68
151
|
export default async function postManyMethod ({
|
|
69
152
|
params, context, vars, helpers, scope, scopes, runHooks, hasHooks, perRecordHooks, scopeOptions, scopeName, api, log
|
|
@@ -76,7 +159,7 @@ export default async function postManyMethod ({
|
|
|
76
159
|
context.schemaInfo = scopes[scopeName].vars.schemaInfo
|
|
77
160
|
context.format = resolveFormat(params.format, vars.format)
|
|
78
161
|
context.simplified = context.format === 'plain'
|
|
79
|
-
context.returning =
|
|
162
|
+
context.returning = resolveManyRowReturning(params.returning)
|
|
80
163
|
context.params = params
|
|
81
164
|
const chunkSize = resolveChunkSize(params.chunkSize)
|
|
82
165
|
const { inputKey, records } = selectManyInput(params)
|
|
@@ -84,6 +167,12 @@ export default async function postManyMethod ({
|
|
|
84
167
|
api, helpers, perRecordHooks, scopeName, schemaInfo: context.schemaInfo, operation: 'postMany', kind: 'post', storageHelper: 'dataInsertMany'
|
|
85
168
|
})
|
|
86
169
|
const conflict = resolveOnConflict(params.onConflict, { scopeName, schemaInfo: context.schemaInfo, scopes })
|
|
170
|
+
if (conflict?.action === 'merge') {
|
|
171
|
+
// Merged rows are updates: the resource must take patches as a batch too.
|
|
172
|
+
refuseUnsupportedResource({
|
|
173
|
+
api, helpers, perRecordHooks, scopeName, schemaInfo: context.schemaInfo, operation: 'postMany', kind: 'patch', storageHelper: 'dataUpdateByIds'
|
|
174
|
+
})
|
|
175
|
+
}
|
|
87
176
|
|
|
88
177
|
await beginWriteTransaction(context, params.transaction, helpers.newTransaction, runHooks)
|
|
89
178
|
context.db = context.transaction
|
|
@@ -135,16 +224,20 @@ export default async function postManyMethod ({
|
|
|
135
224
|
} catch (error) { throw atIndex(error, index) }
|
|
136
225
|
}
|
|
137
226
|
|
|
138
|
-
let onConflict
|
|
139
227
|
if (conflict) {
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
if (
|
|
228
|
+
const byId = conflict.keys[0] === 'id'
|
|
229
|
+
rows.forEach((row, index) => {
|
|
230
|
+
const id = row.inputRecord.data.id
|
|
231
|
+
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)
|
|
232
|
+
if (byId || id === undefined || id === null) return
|
|
233
|
+
throw atIndex(new RestApiValidationError('An upsert row cannot carry an id: its conflict keys identify it', { fields: ['data.id'] }), index)
|
|
144
234
|
})
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
235
|
+
if (!byId) {
|
|
236
|
+
rows.forEach((row, index) => {
|
|
237
|
+
const attributes = row.inputRecord.data.attributes || {}
|
|
238
|
+
const empty = conflict.keys.filter(field => attributes[field] === undefined || attributes[field] === null)
|
|
239
|
+
if (empty.length) throw atIndex(new RestApiValidationError(`Conflict keys need values: ${empty.join(', ')}`, { fields: empty.map(field => `data.attributes.${field}`) }), index)
|
|
240
|
+
})
|
|
148
241
|
}
|
|
149
242
|
}
|
|
150
243
|
|
|
@@ -153,22 +246,57 @@ export default async function postManyMethod ({
|
|
|
153
246
|
await lockRelationshipTargets(api, context.transaction, parents)
|
|
154
247
|
|
|
155
248
|
const attributes = []
|
|
249
|
+
const supplied = []
|
|
156
250
|
for (const [index, row] of rows.entries()) {
|
|
251
|
+
const beforeSetters = { ...(row.inputRecord.data.attributes || {}) }
|
|
157
252
|
try {
|
|
158
253
|
await applyFieldSetters(row, api, helpers)
|
|
159
254
|
} catch (error) { throw atIndex(error, index) }
|
|
160
255
|
const data = row.inputRecord.data
|
|
256
|
+
supplied.push(suppliedFields(row, beforeSetters, data.attributes || {}))
|
|
161
257
|
attributes.push({ ...(data.attributes || {}), ...(data.id !== undefined ? { id: data.id } : {}) })
|
|
162
258
|
}
|
|
163
259
|
|
|
164
|
-
|
|
165
|
-
|
|
260
|
+
let onConflict
|
|
261
|
+
if (conflict) {
|
|
262
|
+
onConflict = { keys: conflict.keys, action: conflict.action, merge: conflict.action === 'merge' ? conflict.mergeFields(supplied) : [] }
|
|
263
|
+
if (conflict.action === 'merge' && !onConflict.merge.length) {
|
|
264
|
+
throw new RestApiValidationError('onConflict merge has no fields to update; use ignore', { fields: ['onConflict'] })
|
|
265
|
+
}
|
|
266
|
+
const seen = new Map()
|
|
267
|
+
attributes.forEach((row, index) => {
|
|
268
|
+
const key = JSON.stringify(onConflict.keys.map(field => row[field]))
|
|
269
|
+
if (seen.has(key)) {
|
|
270
|
+
throw atIndex(new RestApiValidationError(`Rows ${seen.get(key)} and ${index} have the same conflict keys`, {
|
|
271
|
+
fields: onConflict.keys.map(field => `data.attributes.${field}`)
|
|
272
|
+
}), index)
|
|
273
|
+
}
|
|
274
|
+
seen.set(key, index)
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Without a conflict every row is inserted or the batch fails, so a count needs no ids.
|
|
279
|
+
const countsRows = context.returning === 'count' && !onConflict
|
|
280
|
+
const needIds = (context.returning !== 'none' && !countsRows) || hasHooks('afterDataCallPostMany')
|
|
281
|
+
const ids = onConflict?.action === 'merge'
|
|
282
|
+
? await mergeRows({ context, helpers, scope, scopes, runHooks, scopeName }, { rows, attributes, supplied, onConflict, chunkSize, needIds })
|
|
283
|
+
: await helpers.dataInsertMany({ scopeName, context, rows: attributes, chunkSize, needIds, onConflict })
|
|
166
284
|
context.ids = ids?.map(id => id === undefined || id === null ? undefined : String(id))
|
|
167
285
|
await runHooks('afterDataCallPostMany')
|
|
168
286
|
|
|
169
|
-
const response =
|
|
170
|
-
|
|
171
|
-
|
|
287
|
+
const response = countsRows
|
|
288
|
+
? { meta: { count: rows.length } }
|
|
289
|
+
: await manyRowResponse({
|
|
290
|
+
api,
|
|
291
|
+
scopeName,
|
|
292
|
+
context,
|
|
293
|
+
ids: context.ids || [],
|
|
294
|
+
returning: context.returning,
|
|
295
|
+
format: context.format,
|
|
296
|
+
queryParams: params.queryParams,
|
|
297
|
+
// A merge may match a row committed after this transaction's snapshot and leave it unchanged.
|
|
298
|
+
locked: onConflict?.action === 'merge'
|
|
299
|
+
})
|
|
172
300
|
await commitOwnedTransaction(context)
|
|
173
301
|
return response
|
|
174
302
|
} catch (error) {
|
|
@@ -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) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Reads that keep the resource's scope: the read-only scoped builder, every-row
|
|
2
2
|
// reads in cursor pages, and row locks inside a transaction.
|
|
3
|
-
import { RestApiBulkWriteUnsupportedError, RestApiRowLimitError, RestApiValidationError } from '../../../lib/rest-api-errors.js'
|
|
3
|
+
import { RestApiBulkWriteUnsupportedError, RestApiRecordsNotFoundError, RestApiRowLimitError, RestApiValidationError } from '../../../lib/rest-api-errors.js'
|
|
4
4
|
import { readOnlyQueryBuilder } from '../lib/querying/read-only-query.js'
|
|
5
5
|
import { normalizeRecordAttributes } from '../lib/querying-writing/database-value-normalizers.js'
|
|
6
6
|
import { filterResponseFields, getResourceFieldset } from '../lib/querying-writing/field-utils.js'
|
|
@@ -9,21 +9,24 @@ import { resolveFormat } from '../lib/querying-writing/response-options.js'
|
|
|
9
9
|
import { transformJsonApiToSimplified } from '../lib/querying-writing/simplified-helpers.js'
|
|
10
10
|
import { requireExistingResourceId } from '../lib/querying-writing/resource-id-normalization.js'
|
|
11
11
|
import { chunk } from '../lib/storage/many-row-data-helpers.js'
|
|
12
|
-
import {
|
|
12
|
+
import { filterToOneResponseLinkage, snapshotReadRecord } from './common.js'
|
|
13
|
+
import { refuseDuplicateIds, requireLockTransaction, resolveMissing, validateFilters } from './many-row-common.js'
|
|
13
14
|
|
|
14
15
|
export const SCOPED_QUERY_PURPOSES = new Set(['report', 'bulk-write', 'lock', 'claim'])
|
|
15
16
|
const DEFAULT_QUERY_ALL_PAGE = 1000
|
|
16
17
|
const MAX_CLAIM = 1000
|
|
18
|
+
const MAX_LOCK_WHERE = 1000
|
|
17
19
|
|
|
18
20
|
const invalid = (field, message) => new RestApiValidationError(message, { fields: [field], violations: [{ field, rule: 'scoped_read', message }] })
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
23
|
* The resource's table with ownership and row policy applied, as a read-only
|
|
22
|
-
* Knex builder
|
|
23
|
-
*
|
|
24
|
+
* Knex builder over a derived table named after the alias (or the table), so
|
|
25
|
+
* nothing a caller adds, `orWhere` included, can widen the scope. Resolves to
|
|
26
|
+
* `{ query }`: a builder is thenable, so returning it from an async method would run it.
|
|
24
27
|
* @param {any} args
|
|
25
28
|
*/
|
|
26
|
-
export async function scopedQueryMethod ({ params, context, vars, helpers, scope, scopes, runHooks, scopeName }) {
|
|
29
|
+
export async function scopedQueryMethod ({ params, context, vars, helpers, scope, scopes, runHooks, scopeName, api }) {
|
|
27
30
|
const { transaction, alias, purpose = 'report', filters, ...unknown } = params
|
|
28
31
|
if (Object.keys(unknown).length) throw invalid(Object.keys(unknown)[0], `Unknown scopedQuery options: ${Object.keys(unknown).join(', ')}`)
|
|
29
32
|
if (!SCOPED_QUERY_PURPOSES.has(purpose)) throw invalid('purpose', `purpose must be one of ${[...SCOPED_QUERY_PURPOSES].join(', ')}`)
|
|
@@ -41,8 +44,9 @@ export async function scopedQueryMethod ({ params, context, vars, helpers, scope
|
|
|
41
44
|
context.queryParams = { filters: validateFilters({ filters, scopeName, schemaInfo: context.schemaInfo, vars }) || {} }
|
|
42
45
|
// A caller who may not query the resource may not build reports on it either.
|
|
43
46
|
await scope.checkPermissions({ method: 'query', originalContext: { ...context, method: 'query', scopedQuery: true } })
|
|
44
|
-
const { query } = await helpers.dataScopedQuery({ scopeName, context, runHooks, alias, purpose, filters: context.queryParams.filters })
|
|
45
|
-
|
|
47
|
+
const { query, alias: name } = await helpers.dataScopedQuery({ scopeName, context, runHooks, alias, purpose, filters: context.queryParams.filters })
|
|
48
|
+
const derived = (transaction || api.knex.instance).from(query.select(`${name}.*`).as(name))
|
|
49
|
+
return { query: readOnlyQueryBuilder(derived, { resourceType: scopeName }) }
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
/**
|
|
@@ -76,13 +80,26 @@ export async function queryAllMethod ({ params, context, vars, scopeName, api })
|
|
|
76
80
|
return resolvedFormat === 'jsonapi' && included.size ? { data, included: [...included.values()] } : { data }
|
|
77
81
|
}
|
|
78
82
|
|
|
79
|
-
// The read
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
// The rows a lock read leave through the read pipeline `query` runs after its
|
|
84
|
+
// data call, so masking and denials apply to them too: checkDataPermissions for
|
|
85
|
+
// each row as `get` runs it, enrichRecord, computed fields, the finish hooks,
|
|
86
|
+
// sparse fieldsets and the plain format. Hooks see method 'query' and lock: true.
|
|
87
|
+
async function finishLockedRead ({ document, context, scope, scopes, scopeName, runHooks, hasHooks }) {
|
|
88
|
+
context.method = 'query'
|
|
89
|
+
context.lock = true
|
|
90
|
+
context.record = normalizeRecordAttributes(document, scopes)
|
|
91
|
+
if (hasHooks('checkDataPermissions') || hasHooks('checkDataPermissionsGet')) {
|
|
92
|
+
for (const entry of context.record.data) {
|
|
93
|
+
const recordContext = { ...context, method: 'get', id: entry.id, record: { data: entry } }
|
|
94
|
+
await runHooks('checkDataPermissions', recordContext)
|
|
95
|
+
await runHooks('checkDataPermissionsGet', recordContext)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
snapshotReadRecord(context, scopes, hasHooks, 'query')
|
|
99
|
+
await runHooks('enrichRecord')
|
|
83
100
|
const computedFields = scope.vars.schemaInfo?.computed || {}
|
|
84
101
|
const requestedComputedFields = getRequestedComputedFields(scopeName, getResourceFieldset(context.queryParams.fields, scopeName), computedFields)
|
|
85
|
-
for (const entry of record.data) {
|
|
102
|
+
for (const entry of context.record.data) {
|
|
86
103
|
entry.attributes = await scope.enrichAttributes({
|
|
87
104
|
id: entry.id,
|
|
88
105
|
attributes: entry.attributes,
|
|
@@ -92,7 +109,10 @@ async function finishRecords ({ document, context, scope, scopes, scopeName }) {
|
|
|
92
109
|
computedDependencies: context.computedDependencies
|
|
93
110
|
})
|
|
94
111
|
}
|
|
95
|
-
|
|
112
|
+
await runHooks('finish')
|
|
113
|
+
await runHooks('finishQuery')
|
|
114
|
+
await filterToOneResponseLinkage(context, scopes)
|
|
115
|
+
const record = normalizeRecordAttributes(context.record, scopes, { source: 'response' })
|
|
96
116
|
filterResponseFields(record, context.queryParams.fields)
|
|
97
117
|
if (context.format !== 'plain') return { data: record.data }
|
|
98
118
|
const schemaInfo = scope.vars.schemaInfo
|
|
@@ -123,30 +143,55 @@ async function setupLockRead ({ params, context, vars, helpers, scopes, scopeNam
|
|
|
123
143
|
|
|
124
144
|
/**
|
|
125
145
|
* Locks the visible rows among `ids` until the transaction ends, in id order,
|
|
126
|
-
* and returns them as the locking read saw them.
|
|
146
|
+
* and returns them as the locking read saw them. That read is the first
|
|
147
|
+
* statement on the rows and decides visibility itself, so the transaction's
|
|
148
|
+
* snapshot starts after any lock wait. On MySQL it locks every listed row,
|
|
149
|
+
* hidden ones too, before it checks the scope (see guide 34).
|
|
127
150
|
* @param {any} args
|
|
128
151
|
*/
|
|
129
152
|
export async function lockByIdsMethod (args) {
|
|
130
|
-
const { params, context, helpers, scope, scopes, runHooks, scopeName, scopeOptions, vars } = args
|
|
153
|
+
const { params, context, helpers, scope, scopes, runHooks, hasHooks, scopeName, scopeOptions, vars } = args
|
|
131
154
|
await setupLockRead(args, 'lockByIds', 'dataLockByIds')
|
|
132
155
|
if (!Array.isArray(params.ids) || !params.ids.length) throw invalid('ids', 'ids must be a non-empty array')
|
|
133
156
|
const ids = params.ids.map(id => requireExistingResourceId(id, { scopeOptions, vars, scopeName }))
|
|
134
157
|
const storageAdapter = helpers.getStorageAdapter(scopeName)
|
|
135
158
|
refuseDuplicateIds(ids, storageAdapter, 'ids')
|
|
136
159
|
const missing = resolveMissing(params.missing)
|
|
137
|
-
|
|
138
|
-
const visible = new Set()
|
|
139
|
-
for (const part of chunk(ids, 1000)) {
|
|
140
|
-
for (const id of await helpers.dataVisibleIds({ scopeName, context, runHooks, ids: part, queryPurpose: 'lock' })) visible.add(String(id))
|
|
141
|
-
}
|
|
142
|
-
const kept = keepVisible({ requested: ids, visible, missing, scopeName, storageAdapter })
|
|
143
|
-
if (!kept.length) return { data: [] }
|
|
144
160
|
await scope.checkPermissions({ method: 'query', originalContext: { ...context, method: 'query', lock: true } })
|
|
161
|
+
// Sorted before chunking, so lists longer than a chunk still lock in one
|
|
162
|
+
// order across transactions: a list of integers numerically, any other list
|
|
163
|
+
// by code unit (the database's order for a binary collation).
|
|
164
|
+
const keys = ids.map(id => String(storageAdapter.translateFilterValue('id', id)))
|
|
165
|
+
const numeric = keys.every(key => /^-?\d+$/.test(key))
|
|
166
|
+
const sorted = ids
|
|
167
|
+
.map((id, index) => ({ id, key: numeric ? BigInt(keys[index]) : keys[index] }))
|
|
168
|
+
.sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0)
|
|
169
|
+
.map(entry => entry.id)
|
|
145
170
|
const document = { data: [] }
|
|
146
|
-
for (const part of chunk(
|
|
147
|
-
document.data.push(...(await helpers.dataLockByIds({ scopeName, context, ids: part })).data)
|
|
171
|
+
for (const part of chunk(sorted, 1000)) {
|
|
172
|
+
document.data.push(...(await helpers.dataLockByIds({ scopeName, context, runHooks, ids: part })).data)
|
|
173
|
+
}
|
|
174
|
+
if (document.data.length < ids.length && missing === 'throw') {
|
|
175
|
+
throw new RestApiRecordsNotFoundError({ resourceType: scopeName, missing: ids.length - document.data.length })
|
|
148
176
|
}
|
|
149
|
-
|
|
177
|
+
if (!document.data.length) return { data: [] }
|
|
178
|
+
return finishLockedRead({ document, context, scope, scopes, scopeName, runHooks, hasHooks })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Locks the visible rows the filters select until the transaction ends, in id
|
|
183
|
+
* order, waiting for transactions that hold them, so a row that starts to match
|
|
184
|
+
* while the lock waits is locked and returned too. At most 1,000 rows.
|
|
185
|
+
* @param {any} args
|
|
186
|
+
*/
|
|
187
|
+
export async function lockWhereMethod (args) {
|
|
188
|
+
const { params, context, vars, helpers, scope, scopes, runHooks, hasHooks, scopeName } = args
|
|
189
|
+
await setupLockRead(args, 'lockWhere', 'dataLockWhere')
|
|
190
|
+
const filters = validateFilters({ filters: params.filters, scopeName, schemaInfo: context.schemaInfo, vars })
|
|
191
|
+
await scope.checkPermissions({ method: 'query', originalContext: { ...context, method: 'query', lock: true } })
|
|
192
|
+
const document = await helpers.dataLockWhere({ scopeName, context, runHooks, filters, limit: MAX_LOCK_WHERE + 1 })
|
|
193
|
+
if (document.data.length > MAX_LOCK_WHERE) throw new RestApiRowLimitError({ resourceType: scopeName, rowLimit: MAX_LOCK_WHERE, operation: 'lockWhere' })
|
|
194
|
+
return finishLockedRead({ document, context, scope, scopes, scopeName, runHooks, hasHooks })
|
|
150
195
|
}
|
|
151
196
|
|
|
152
197
|
/**
|
|
@@ -155,7 +200,7 @@ export async function lockByIdsMethod (args) {
|
|
|
155
200
|
* @param {any} args
|
|
156
201
|
*/
|
|
157
202
|
export async function claimMethod (args) {
|
|
158
|
-
const { params, context, vars, helpers, scope, scopes, runHooks, scopeName } = args
|
|
203
|
+
const { params, context, vars, helpers, scope, scopes, runHooks, hasHooks, scopeName } = args
|
|
159
204
|
await setupLockRead(args, 'claim', 'dataClaim')
|
|
160
205
|
const limit = params.limit ?? 1
|
|
161
206
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_CLAIM) throw invalid('limit', `limit must be an integer from 1 to ${MAX_CLAIM}`)
|
|
@@ -172,5 +217,5 @@ export async function claimMethod (args) {
|
|
|
172
217
|
})
|
|
173
218
|
await scope.checkPermissions({ method: 'query', originalContext: { ...context, method: 'query', lock: true } })
|
|
174
219
|
const document = await helpers.dataClaim({ scopeName, context, runHooks, filters, sort, limit })
|
|
175
|
-
return
|
|
220
|
+
return finishLockedRead({ document, context, scope, scopes, scopeName, runHooks, hasHooks })
|
|
176
221
|
}
|
|
@@ -19,7 +19,7 @@ import deleteMethod from './rest-api-plugin-methods/delete.js'
|
|
|
19
19
|
import postManyMethod from './rest-api-plugin-methods/post-many.js'
|
|
20
20
|
import { patchManyMethod, patchWhereIdsMethod, patchWhereMethod } from './rest-api-plugin-methods/patch-many.js'
|
|
21
21
|
import { deleteManyMethod, deleteWhereMethod } from './rest-api-plugin-methods/delete-many.js'
|
|
22
|
-
import { claimMethod, lockByIdsMethod, queryAllMethod, scopedQueryMethod } from './rest-api-plugin-methods/scoped-reads.js'
|
|
22
|
+
import { claimMethod, lockByIdsMethod, lockWhereMethod, queryAllMethod, scopedQueryMethod } from './rest-api-plugin-methods/scoped-reads.js'
|
|
23
23
|
import enrichAttributesMethod from './rest-api-plugin-methods/enrich-attributes.js'
|
|
24
24
|
import checkPermissionsMethod from './rest-api-plugin-methods/check-permissions.js'
|
|
25
25
|
import applyQueryFiltersMethod from './rest-api-plugin-methods/apply-query-filters.js'
|
|
@@ -112,6 +112,7 @@ export const RestApiPlugin = {
|
|
|
112
112
|
addResourceMethod('queryAll', withAvailableContext(queryAllMethod))
|
|
113
113
|
addResourceMethod('scopedQuery', withAvailableContext(scopedQueryMethod))
|
|
114
114
|
addResourceMethod('lockByIds', withAvailableContext(lockByIdsMethod))
|
|
115
|
+
addResourceMethod('lockWhere', withAvailableContext(lockWhereMethod))
|
|
115
116
|
addResourceMethod('claim', withAvailableContext(claimMethod))
|
|
116
117
|
|
|
117
118
|
// Relationship methods
|
|
@@ -214,7 +214,7 @@ export const PositioningPlugin = {
|
|
|
214
214
|
// create no positioned rows and change no positions.
|
|
215
215
|
const refusePositions = ({ context, scopeName }) => {
|
|
216
216
|
if (!enabled(scopeName)) return
|
|
217
|
-
const moves = context.inputRecords.some(record => {
|
|
217
|
+
const moves = [config.field, config.beforeIdField].some(field => context.rawFields?.includes(field)) || context.inputRecords.some(record => {
|
|
218
218
|
const attributes = record?.data?.attributes || {}
|
|
219
219
|
return attributes[config.field] !== undefined || attributes[config.beforeIdField] !== undefined
|
|
220
220
|
})
|
|
@@ -3,11 +3,16 @@ import type { ManagedTransaction } from './transactions.js'
|
|
|
3
3
|
import type { DirectResourceId, InputResourceId, JsonApiResource, PlainResource, ResourceFormat, ResourceIdentifier } from './representations.js'
|
|
4
4
|
import type { JsonApiWriteDocument, QueryParams, RemovedResourceOptions, SelectionParams } from './resource-methods.js'
|
|
5
5
|
|
|
6
|
-
// Many-row writes return nothing by default; 'minimal' returns identifiers, 'full' the records
|
|
7
|
-
|
|
6
|
+
// Many-row writes return nothing by default; 'minimal' returns identifiers, 'full' the records,
|
|
7
|
+
// 'count' the number of rows written.
|
|
8
|
+
export type ManyRowReturning = 'none' | 'minimal' | 'full' | 'count'
|
|
9
|
+
export type ManyRowCount = { meta: { count: number } }
|
|
8
10
|
export type ManyRowResult<Format extends ResourceFormat, Returning extends ManyRowReturning, Fields extends object, Type extends string> =
|
|
9
11
|
Returning extends 'none' ? void
|
|
12
|
+
: Returning extends 'count' ? ManyRowCount
|
|
10
13
|
: { data: Array<Returning extends 'minimal' ? ResourceIdentifier<Type> : Format extends 'plain' ? PlainResource<Fields> : JsonApiResource<Fields, Type>> }
|
|
14
|
+
type DeleteResult<Returning extends 'none' | 'minimal' | 'count', Type extends string> =
|
|
15
|
+
Returning extends 'none' ? void : Returning extends 'count' ? ManyRowCount : { data: ResourceIdentifier<Type>[] }
|
|
11
16
|
export type RecordsResult<Format extends ResourceFormat, Fields extends object, Type extends string> =
|
|
12
17
|
{ data: Array<Format extends 'plain' ? PlainResource<Fields> : JsonApiResource<Fields, Type>>; included?: JsonApiResource[] }
|
|
13
18
|
// knex.raw values: accepted by many-row patches for stored, non-relationship fields.
|
|
@@ -27,12 +32,17 @@ export type ManyRowCreateInput<Input extends object, Type extends string> =
|
|
|
27
32
|
{ data: Array<Partial<Input> & { id?: InputResourceId }>; document?: never } |
|
|
28
33
|
{ data?: never; document: { data: Array<JsonApiWriteDocument<Input, Type>['data']> } }
|
|
29
34
|
export interface UpsertOptions<Input extends object> {
|
|
30
|
-
// Must include every ownership (autofilter) field.
|
|
31
|
-
|
|
35
|
+
// Must include every ownership (autofilter) field. Rows may not carry an id or repeat a key.
|
|
36
|
+
// ['id'] (or the idProperty) alone is allowed on a resource without ownership fields or a
|
|
37
|
+
// row policy; every row then carries its id.
|
|
38
|
+
keys: Array<keyof Input & string> | ['id']
|
|
39
|
+
// merge also runs the patch permission checks on every row it updates.
|
|
32
40
|
action: 'ignore' | 'merge'
|
|
33
41
|
// merge only: fields to update; default every stored field the rows supply but the keys.
|
|
34
42
|
merge?: Array<keyof Input & string>
|
|
35
43
|
}
|
|
44
|
+
// patchWhere and deleteWhere need filters, or all: true to act on every visible row.
|
|
45
|
+
export type WhereScope = { filters: Record<string, unknown>; all?: never } | { filters?: never; all: true }
|
|
36
46
|
type Patchable<Input extends object> = { [Field in keyof Input]?: Input[Field] | RawValue }
|
|
37
47
|
export type ManyRowPatchInput<Input extends object, Type extends string> =
|
|
38
48
|
{ data: Array<Patchable<Input> & { id: DirectResourceId }>; document?: never } |
|
|
@@ -54,7 +64,8 @@ export interface ScopedQueryParams {
|
|
|
54
64
|
purpose?: 'report' | 'bulk-write' | 'lock' | 'claim'
|
|
55
65
|
filters?: Record<string, unknown>
|
|
56
66
|
}
|
|
57
|
-
// A Knex builder
|
|
67
|
+
// A Knex builder over a derived table of the scoped rows. It refuses writes, locks,
|
|
68
|
+
// table changes, set operations, common table expressions and access to the client.
|
|
58
69
|
export type ReadOnlyQueryBuilder = Knex.QueryBuilder
|
|
59
70
|
|
|
60
71
|
export interface DataAccessMethods<
|
|
@@ -88,17 +99,18 @@ export interface DataAccessMethods<
|
|
|
88
99
|
context?: Context
|
|
89
100
|
): Promise<ManyRowResult<Format, Returning, Fields, Type>>
|
|
90
101
|
patchWhere<Format extends ResourceFormat = DefaultFormat, Returning extends ManyRowReturning = 'none'>(
|
|
91
|
-
params: ManyRowWriteOptions<Format, Returning> & SharedPatchInput<Input, Type> & Omit<PatchConditionOptions<Input>, 'missing'> &
|
|
102
|
+
params: ManyRowWriteOptions<Format, Returning> & SharedPatchInput<Input, Type> & Omit<PatchConditionOptions<Input>, 'missing'> & WhereScope,
|
|
92
103
|
context?: Context
|
|
93
104
|
): Promise<ManyRowResult<Format, Returning, Fields, Type>>
|
|
94
|
-
deleteMany<Returning extends 'none' | 'minimal' = 'none'>(
|
|
105
|
+
deleteMany<Returning extends 'none' | 'minimal' | 'count' = 'none'>(
|
|
95
106
|
params: RemovedResourceOptions & { ids: DirectResourceId[]; transaction?: ManagedTransaction; returning?: Returning; chunkSize?: number; missing?: MissingRows },
|
|
96
107
|
context?: Context
|
|
97
|
-
): Promise<Returning
|
|
98
|
-
|
|
99
|
-
|
|
108
|
+
): Promise<DeleteResult<Returning, Type>>
|
|
109
|
+
// Deletes the rows its selection finds and answers with those it deleted; it never throws for missing rows.
|
|
110
|
+
deleteWhere<Returning extends 'none' | 'minimal' | 'count' = 'none'>(
|
|
111
|
+
params: RemovedResourceOptions & WhereScope & { transaction?: ManagedTransaction; returning?: Returning; chunkSize?: number },
|
|
100
112
|
context?: Context
|
|
101
|
-
): Promise<Returning
|
|
113
|
+
): Promise<DeleteResult<Returning, Type>>
|
|
102
114
|
// Locks the visible rows among ids (FOR UPDATE, id order) until the transaction ends.
|
|
103
115
|
lockByIds<Format extends ResourceFormat = DefaultFormat>(params: {
|
|
104
116
|
ids: DirectResourceId[]
|
|
@@ -107,6 +119,15 @@ export interface DataAccessMethods<
|
|
|
107
119
|
format?: Format
|
|
108
120
|
queryParams?: { fields?: Record<string, string> }
|
|
109
121
|
}, context?: Context): Promise<RecordsResult<Format, Fields, Type>>
|
|
122
|
+
// Locks the visible rows the filters select (FOR UPDATE; id order, on MySQL the
|
|
123
|
+
// order of the forced index, which starts with the ownership or a filtered column),
|
|
124
|
+
// waiting for other transactions; throws RestApiRowLimitError past 1000 rows.
|
|
125
|
+
lockWhere<Format extends ResourceFormat = DefaultFormat>(params: {
|
|
126
|
+
transaction: ManagedTransaction | Knex.Transaction
|
|
127
|
+
filters?: Record<string, unknown>
|
|
128
|
+
format?: Format
|
|
129
|
+
queryParams?: { fields?: Record<string, string> }
|
|
130
|
+
}, context?: Context): Promise<RecordsResult<Format, Fields, Type>>
|
|
110
131
|
// Takes up to limit (default 1, at most 1000) matching rows no other transaction holds.
|
|
111
132
|
claim<Format extends ResourceFormat = DefaultFormat>(params: {
|
|
112
133
|
transaction: ManagedTransaction | Knex.Transaction
|
package/types/errors.d.ts
CHANGED
|
@@ -118,11 +118,12 @@ export class RestApiTemporalDataError extends RestApiError {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
export class RestApiBulkWriteUnsupportedError extends RestApiError {
|
|
121
|
-
constructor(options?: { resourceType?: string; operation?: string; reason?: string })
|
|
121
|
+
constructor(options?: { resourceType?: string; operation?: string; reason?: string; hooks?: string[] })
|
|
122
122
|
code: 'REST_API_BULK_WRITE_UNSUPPORTED'
|
|
123
123
|
type: 'rest_api_bulk_write_unsupported'
|
|
124
124
|
statusCode: 422
|
|
125
|
-
|
|
125
|
+
/** `hooks` names the per-record hooks that caused the refusal; HTTP responses leave details out. */
|
|
126
|
+
details: { resourceType?: string; operation?: string; reason?: string; hooks?: string[] }
|
|
126
127
|
toJSON(): SerializedError & Pick<RestApiBulkWriteUnsupportedError, 'code' | 'type' | 'statusCode' | 'details'>
|
|
127
128
|
}
|
|
128
129
|
|
|
@@ -154,9 +155,9 @@ export class RestApiScopedQueryReadOnlyError extends RestApiError {
|
|
|
154
155
|
}
|
|
155
156
|
|
|
156
157
|
export class RestApiRowLimitError extends RestApiError {
|
|
157
|
-
constructor(options?: { resourceType?: string; rowLimit?: number })
|
|
158
|
+
constructor(options?: { resourceType?: string; rowLimit?: number; operation?: 'queryAll' | 'lockWhere' })
|
|
158
159
|
code: 'REST_API_ROW_LIMIT'
|
|
159
160
|
type: 'rest_api_row_limit'
|
|
160
|
-
details: { resourceType?: string; rowLimit?: number }
|
|
161
|
+
details: { resourceType?: string; rowLimit?: number; operation: 'queryAll' | 'lockWhere' }
|
|
161
162
|
toJSON(): SerializedError & Pick<RestApiRowLimitError, 'code' | 'type' | 'details'>
|
|
162
163
|
}
|
package/types/hook-context.d.ts
CHANGED
|
@@ -189,6 +189,8 @@ export type ManyRowHookContext<Context extends object = Record<string, unknown>,
|
|
|
189
189
|
readonly transaction: ManagedTransaction
|
|
190
190
|
// JSON:API documents of the rows, in input order (creates and patches).
|
|
191
191
|
readonly inputRecords?: Array<{ data: { id?: InputResourceId; attributes?: Record<string, unknown>; relationships?: Record<string, unknown> } }>
|
|
192
|
+
// Patches: fields some row sets with a knex.raw value (those values are not in inputRecords).
|
|
193
|
+
readonly rawFields?: string[]
|
|
192
194
|
readonly ids?: Array<string | undefined>
|
|
193
195
|
}
|
|
194
196
|
|