json-rest-api 2.3.0 → 2.4.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/docs/API.md +3 -1
- package/docs/GUIDE/09-pagination-and-sorting.md +7 -0
- package/docs/GUIDE/13-hooks-and-lifecycle.md +12 -3
- package/docs/GUIDE/33-migrating-to-v2.md +7 -3
- package/lib/runtime/json-rest-api.js +7 -1
- package/package.json +1 -1
- package/plugins/core/lib/anyapi/canonical-link-store.js +4 -2
- package/plugins/core/lib/querying/include-query-helpers.js +8 -11
- package/plugins/core/lib/querying/knex-pagination-helpers.js +3 -2
- package/plugins/core/lib/storage/canonical-data-helpers.js +1 -0
- package/plugins/core/lib/storage/ordinary-data-helpers.js +1 -0
- package/plugins/core/lib/storage/storage-types.d.ts +2 -0
- package/plugins/core/lib/writing/many-to-many-manipulations.js +3 -3
- package/plugins/core/lib/writing/relationship-processor.js +22 -5
- package/plugins/core/lib/writing/resource-version.js +16 -3
- package/plugins/core/lib/writing/reverse-relationship-manipulations.js +1 -1
- package/plugins/core/rest-api-plugin-methods/common.js +94 -62
- package/plugins/core/rest-api-plugin-methods/get.js +3 -2
- package/plugins/core/rest-api-plugin-methods/lifecycle-types.d.ts +3 -0
- package/plugins/core/rest-api-plugin-methods/patch.js +7 -2
- package/plugins/core/rest-api-plugin-methods/post.js +7 -2
- package/plugins/core/rest-api-plugin-methods/put.js +7 -2
- package/plugins/core/rest-api-plugin-methods/query.js +18 -2
- package/types/resource-methods.d.ts +7 -1
- package/types/runtime.d.ts +2 -0
package/docs/API.md
CHANGED
|
@@ -257,7 +257,8 @@ All parameters are passed within a single `params` object:
|
|
|
257
257
|
page: Object // Pagination parameters
|
|
258
258
|
},
|
|
259
259
|
format: String, // 'plain' (default) or 'jsonapi'
|
|
260
|
-
transaction: Object
|
|
260
|
+
transaction: Object, // Database transaction object
|
|
261
|
+
pageSizeLimit: Number // Server code only: this call's largest page size
|
|
261
262
|
}
|
|
262
263
|
```
|
|
263
264
|
|
|
@@ -275,6 +276,7 @@ All parameters are passed within a single `params` object:
|
|
|
275
276
|
| `queryParams.page.before` | String | No | Cursor for backward pagination |
|
|
276
277
|
| `format` | String | No | `'plain'` or `'jsonapi'`; uses the configured default when omitted |
|
|
277
278
|
| `transaction` | Object | No | Database transaction object |
|
|
279
|
+
| `pageSizeLimit` | Number | No | A positive integer replacing the resource's `queryMaxLimit` for this call, so server code reading every row can use larger pages. HTTP and Socket.IO never pass it, so their page sizes stay capped |
|
|
278
280
|
|
|
279
281
|
#### Return Value
|
|
280
282
|
|
|
@@ -68,6 +68,13 @@ Page 10 returns an empty array. `enablePaginationCounts: false` suppresses the
|
|
|
68
68
|
count query and count-derived metadata; do not assume every collection response
|
|
69
69
|
contains totals. Every mode respects `queryMaxLimit`.
|
|
70
70
|
|
|
71
|
+
Server code that reads every row, for example an export or a nightly job, can
|
|
72
|
+
raise the cap for one call with `pageSizeLimit`, a parameter beside
|
|
73
|
+
`queryParams`: `query({ queryParams: { page: { size: 1000 } }, pageSizeLimit: 1000 })`
|
|
74
|
+
reads up to 1,000 rows a page, so each page's row policy and linkage checks run
|
|
75
|
+
once per 1,000 rows instead of once per `queryMaxLimit`. The transports never
|
|
76
|
+
pass it, so HTTP and Socket.IO page sizes stay capped by `queryMaxLimit`.
|
|
77
|
+
|
|
71
78
|
## Cursor pages
|
|
72
79
|
|
|
73
80
|
```javascript
|
|
@@ -284,7 +284,7 @@ context or enforce ownership through proxies.
|
|
|
284
284
|
| `responseRecord` | Selected write output, available at write finish; absent for `returning: 'none'`. | Finish hooks can adjust the response before final normalization and commit. A returned write result is copied before completion hooks; mutating this field in `afterCommit` does not change that result. |
|
|
285
285
|
| `method`, `scopeName`, `id`, `relationshipName`, `isUpdate` | Library-owned operation identity and branch selection. Availability varies by method and stage. | Inspect rather than overwrite. Changing them does not select a different operation or safely retarget a write. POST's ID is available after storage; normalize it with `String(context.id)` when comparing it to public IDs. |
|
|
286
286
|
| `schemaInfo`, `params`, `queryParams`, `format`, `returning`, `simplified` | Library-owned compiled schema, request parameters and selected response behavior. | Set public options in method arguments. Do not overwrite these fields to redirect processing. Use schema declarations, row policies and documented query hooks for customization. |
|
|
287
|
-
| `minimalRecord`, `originalMinimalRecord`, `originalInputAttributes`, `originalRecord` | Library-owned stored data and snapshots; availability depends on the stage. | Inspect them when available. Do not change snapshots to influence validation, permissions or persistence. |
|
|
287
|
+
| `minimalRecord`, `originalMinimalRecord`, `originalInputAttributes`, `originalRecord` | Library-owned stored data and snapshots; availability depends on the stage. A get or query sets `originalRecord`, a copy of its records before enrichment, only when an `enrichRecord`, `enrichAttributes`, `finish`, `finishGet` or `finishQuery`, `enrichRecordWithRelationships` (get) or `transport:response` hook is registered for the resource or globally, or an `enrichAttributes` hook for an included resource: copying every row is a read's largest CPU cost. | Inspect them when available. Do not change snapshots to influence validation, permissions or persistence. |
|
|
288
288
|
| `transaction`, `db`, `shouldCommit`, `transactionOutcome`, `transactionCommitted`, `error`, `cleanupErrors` | Library-owned transaction and failure state. | Inspect through the documented transaction/error contract. Pass transaction handles through method arguments; do not replace these fields, forge outcomes or erase diagnostics. |
|
|
289
289
|
| `knexQuery`, `storageAdapter` | Temporary state at native query hook boundaries, also visible to nested query permission checks. | `knexQuery` is a filtering envelope; its `query` property holds the native builder. Use the [documented filtering contract](29-writing-plugins.md#knexqueryfiltering), and do not retain a builder for a later operation. |
|
|
290
290
|
|
|
@@ -375,7 +375,9 @@ is `Post`, `Put` or `Patch`, for example `beforeDataCallPatch`.
|
|
|
375
375
|
method-specific hook.
|
|
376
376
|
5. Acquire required relationship locks, apply setters and write to storage.
|
|
377
377
|
6. Run the method-specific `afterDataCall`, then `afterDataCall`.
|
|
378
|
-
7. Apply relationship changes and refresh the minimal stored record
|
|
378
|
+
7. Apply relationship changes and refresh the minimal stored record when a
|
|
379
|
+
`finish`, method-specific finish, `afterCommit`, `afterRollback` or
|
|
380
|
+
`transport:response` hook is registered to see it.
|
|
379
381
|
8. Prepare the response; run `finish`, then its method-specific hook.
|
|
380
382
|
9. Commit an owned transaction and await its `afterCommit` chain.
|
|
381
383
|
|
|
@@ -422,7 +424,14 @@ its eligibility queries also run the ordinary query lifecycle in the write's
|
|
|
422
424
|
transaction with the subscriber's trusted context.
|
|
423
425
|
|
|
424
426
|
Relationship writes acquire database locks after the `beforeDataCall` hooks.
|
|
425
|
-
Belongs-to and
|
|
427
|
+
Belongs-to, polymorphic and many-to-many targets are rechecked under a shared
|
|
428
|
+
lock (`FOR SHARE`, or `LOCK IN SHARE MODE` on MySQL): writers that reference the
|
|
429
|
+
same row do not wait for each other, while its deletion waits for them. A target
|
|
430
|
+
the write also changes, such as a versioned parent whose revision it advances or
|
|
431
|
+
a child moved by a reverse relationship, is locked `FOR UPDATE` instead. A hook
|
|
432
|
+
that updates a referenced row upgrades the shared lock and can deadlock with a
|
|
433
|
+
concurrent writer doing the same; lock that row `FOR UPDATE` in `beforeDataCall`
|
|
434
|
+
first. Collection
|
|
426
435
|
mutations also preserve a parent row version so concurrent replacements cannot
|
|
427
436
|
use stale membership; a database UPDATE trigger may run even when the parent's
|
|
428
437
|
attribute values did not change. These internal operations do not invoke another
|
|
@@ -734,8 +734,8 @@ Database UPDATE triggers on the parent may therefore run for relationship-only
|
|
|
734
734
|
writes. Resource hooks still follow their existing operation lifecycle; these
|
|
735
735
|
internal locks do not invoke a second resource PATCH hook.
|
|
736
736
|
If you use per-table database grants, account for parent UPDATEs and target
|
|
737
|
-
|
|
738
|
-
role.
|
|
737
|
+
locking reads (`FOR UPDATE`, or `FOR SHARE` / `LOCK IN SHARE MODE` for
|
|
738
|
+
existence checks) when configuring the application's database role.
|
|
739
739
|
|
|
740
740
|
Treat native deadlock, lock-timeout and serialization errors as failed transaction
|
|
741
741
|
attempts. Let `api.transaction` roll back, then retry the complete callback in a
|
|
@@ -958,7 +958,11 @@ POST, PUT and PATCH also reject an unexpected failure while refreshing
|
|
|
958
958
|
and ignored, allowing a successful response and completion hooks with stale
|
|
959
959
|
state. The cause chain retains the failure and resource context with phase
|
|
960
960
|
`postWriteRead`; typed errors retain their identity within that chain. This refresh runs for
|
|
961
|
-
`returning: 'full'`, `'minimal'` and `'none'
|
|
961
|
+
`returning: 'full'`, `'minimal'` and `'none'` whenever a hook can see it: a `finish`,
|
|
962
|
+
method-specific finish, `afterCommit`, `afterRollback` or `transport:response` hook
|
|
963
|
+
registered for the resource or globally. Without one, nothing reads the refreshed
|
|
964
|
+
record, so the write skips that query and its row policy run; a `'full'` response is
|
|
965
|
+
read by its own get either way. It happens before finish hooks and
|
|
962
966
|
owned commit, so a failure rolls back owned changes, including relationship
|
|
963
967
|
updates. Borrowed transactions remain active for the caller to complete.
|
|
964
968
|
|
|
@@ -19,6 +19,11 @@ export class Hooks {
|
|
|
19
19
|
this.#events.set(event, handlers)
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// Whether run(event) for this resource would call any handler.
|
|
23
|
+
has (event, resource) {
|
|
24
|
+
return (this.#events.get(event) || []).some(entry => !entry.resource || entry.resource === resource)
|
|
25
|
+
}
|
|
26
|
+
|
|
22
27
|
async run (event, args) {
|
|
23
28
|
for (const entry of [...(this.#events.get(event) || [])]) {
|
|
24
29
|
if (entry.resource && entry.resource !== args.scope) continue
|
|
@@ -81,7 +86,8 @@ export class JsonRestApi {
|
|
|
81
86
|
vars: resource?.vars || this.vars,
|
|
82
87
|
helpers: resource?.helpers || this.helpers,
|
|
83
88
|
log: this.log,
|
|
84
|
-
runHooks: (event, nextContext = context) => this.#hooks.run(event, this.#arguments(resource, {}, nextContext))
|
|
89
|
+
runHooks: (event, nextContext = context) => this.#hooks.run(event, this.#arguments(resource, {}, nextContext)),
|
|
90
|
+
hasHooks: (event, scope = resource) => this.#hooks.has(event, scope)
|
|
85
91
|
}
|
|
86
92
|
}
|
|
87
93
|
|
package/package.json
CHANGED
|
@@ -247,7 +247,9 @@ export function createCanonicalLinkStore ({ api, getDescriptor, getScopeStorageA
|
|
|
247
247
|
if (relArray.length === 0) return
|
|
248
248
|
assertVersionedRelationshipTransaction(api, relInfo, db)
|
|
249
249
|
|
|
250
|
-
await lockRelationshipTargets(api, db, relArray.map(identifier => ({ type: relInfo.target, id: identifier.id }))
|
|
250
|
+
await lockRelationshipTargets(api, db, relArray.map(identifier => ({ type: relInfo.target, id: identifier.id })), {
|
|
251
|
+
exclusive: hasVersionedInverse(api, relInfo)
|
|
252
|
+
})
|
|
251
253
|
|
|
252
254
|
const ids = [...new Set(relArray.map(identifier => normalizeId(identifier.id)).filter(id => id !== null))]
|
|
253
255
|
const readLinks = batchIds => buildLinkMutationQuery(options, (query, column) => query.whereIn(column, batchIds))
|
|
@@ -442,7 +444,7 @@ export function createCanonicalLinkStore ({ api, getDescriptor, getScopeStorageA
|
|
|
442
444
|
}
|
|
443
445
|
if (!relationships.length) return
|
|
444
446
|
assertVersionedRelationshipTransaction(api, relationships[0].inverse, context.transaction)
|
|
445
|
-
await lockRelationshipTargets(api, context.transaction, [{ type: scopeName, id: context.id }])
|
|
447
|
+
await lockRelationshipTargets(api, context.transaction, [{ type: scopeName, id: context.id }], { exclusive: true })
|
|
446
448
|
const id = normalizeId(context.id)
|
|
447
449
|
for (const { inverse, info } of relationships) {
|
|
448
450
|
const query = buildLinkMutationQuery({ ...info, db: context.transaction, allParents: true }, (query, column) => query.where(column, id))
|
|
@@ -188,6 +188,10 @@ export const applyScopeFiltersToIncludeQuery = async ({
|
|
|
188
188
|
}
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// Linked ids checked per visibility query: a 1,000-row page costs one query and
|
|
192
|
+
// one row-policy run per linked type, and any number of ids stays bounded SQL.
|
|
193
|
+
const VISIBILITY_BATCH_SIZE = 1000
|
|
194
|
+
|
|
191
195
|
export const filterVisibleIdentifiers = async ({ identifiers, scopes, knex, context }) => {
|
|
192
196
|
const idsByType = new Map()
|
|
193
197
|
for (const { type, id } of identifiers) {
|
|
@@ -204,9 +208,11 @@ export const filterVisibleIdentifiers = async ({ identifiers, scopes, knex, cont
|
|
|
204
208
|
const column = `${tableName}.${idColumn}`
|
|
205
209
|
const visibleIds = new Set()
|
|
206
210
|
visible.set(type, visibleIds)
|
|
207
|
-
const
|
|
211
|
+
const values = [...ids].map(id => adapter.translateFilterValue('id', id))
|
|
212
|
+
for (let start = 0; start < values.length; start += VISIBILITY_BATCH_SIZE) {
|
|
213
|
+
// whereInIdentifiers binds a long id list as one parameter where the database allows it.
|
|
208
214
|
const query = adapter.buildBaseQuery({ transaction: context.transaction })
|
|
209
|
-
.
|
|
215
|
+
.modify(whereInIdentifiers, column, values.slice(start, start + VISIBILITY_BATCH_SIZE))
|
|
210
216
|
.distinct({ [idColumn]: adapter.isCanonical() ? column : databaseIdentityExpression(knex, column) })
|
|
211
217
|
const state = await applyScopeFiltersToIncludeQuery({
|
|
212
218
|
query,
|
|
@@ -219,15 +225,6 @@ export const filterVisibleIdentifiers = async ({ identifiers, scopes, knex, cont
|
|
|
219
225
|
})
|
|
220
226
|
for (const row of await state.query) visibleIds.add(String(row[idColumn]))
|
|
221
227
|
}
|
|
222
|
-
let values = []
|
|
223
|
-
for (const id of ids) {
|
|
224
|
-
values.push(adapter.translateFilterValue('id', id))
|
|
225
|
-
if (values.length === RELATIONSHIP_READ_BATCH_SIZE) {
|
|
226
|
-
await loadBatch(values)
|
|
227
|
-
values = []
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
if (values.length) await loadBatch(values)
|
|
231
228
|
}
|
|
232
229
|
return identifiers.filter(({ type, id }) => visible.get(type)?.has(String(id)))
|
|
233
230
|
}
|
|
@@ -7,8 +7,9 @@ import { RestApiValidationError } from '../../../../lib/rest-api-errors.js'
|
|
|
7
7
|
|
|
8
8
|
// Page parameters have passed the shared request contract. Physical sort
|
|
9
9
|
// columns and built-in value conversion remain with descriptors and adapters.
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
// pageSizeLimit, a server caller's query param, replaces queryMaxLimit for that call.
|
|
11
|
+
export const applyPaginationToQuery = ({ query, page = {}, vars, pageSizeLimit, sortDescriptors, storageAdapter }) => {
|
|
12
|
+
const pageSize = Math.min(page.size ?? (vars.queryDefaultLimit || DEFAULT_QUERY_LIMIT), pageSizeLimit || vars.queryMaxLimit || DEFAULT_MAX_QUERY_LIMIT)
|
|
12
13
|
if (!Number.isInteger(pageSize) || pageSize <= 0) {
|
|
13
14
|
throw new RestApiValidationError('Page size must be a positive integer', {
|
|
14
15
|
fields: ['page.size'],
|
|
@@ -265,6 +265,8 @@ export interface DataReadContext extends TransactionContext, StorageContext {
|
|
|
265
265
|
sort?: string[]
|
|
266
266
|
page?: { size?: number; number?: number; before?: string; after?: string }
|
|
267
267
|
}
|
|
268
|
+
// Replaces the resource's queryMaxLimit for this query (a server caller's query param).
|
|
269
|
+
pageSizeLimit?: number
|
|
268
270
|
knexQuery?: QueryFilteringState | null
|
|
269
271
|
computedDependencies?: string[]
|
|
270
272
|
returnMeta?: {
|
|
@@ -17,7 +17,7 @@ export async function invalidateDeletedPivotTargets (api, scopeName, context) {
|
|
|
17
17
|
}
|
|
18
18
|
if (!relationships.length) return
|
|
19
19
|
assertVersionedRelationshipTransaction(api, relationships[0], context.transaction)
|
|
20
|
-
await lockRelationshipTargets(api, context.transaction, [{ type: scopeName, id: context.id }])
|
|
20
|
+
await lockRelationshipTargets(api, context.transaction, [{ type: scopeName, id: context.id }], { exclusive: true })
|
|
21
21
|
const id = api.knex.helpers.getStorageAdapter(scopeName).translateFilterValue('id', context.id)
|
|
22
22
|
for (const inverse of relationships) {
|
|
23
23
|
const pivot = getPivotStorage(api, inverse)
|
|
@@ -110,7 +110,7 @@ export const updateManyToManyRelationship = async (scope, { api, context }) => {
|
|
|
110
110
|
assertVersionedRelationshipTransaction(api, relDef, trx)
|
|
111
111
|
const relData = normalizeRelationshipIdentifiers(context.relData || [], { api })
|
|
112
112
|
const pivot = getPivotStorage(api, relDef)
|
|
113
|
-
const targets = await lockRelationshipTargets(api, trx, relData)
|
|
113
|
+
const targets = await lockRelationshipTargets(api, trx, relData, { exclusive: hasVersionedInverse(api, relDef) })
|
|
114
114
|
const ids = [...new Set(targets.map(record => String(record.id)))]
|
|
115
115
|
// Negate the complete keep-list once; independent batch deletions lose wanted links.
|
|
116
116
|
const removals = trx(pivot.tableName).where(pivot.foreignKey, resourceId)
|
|
@@ -127,7 +127,7 @@ export const createPivotRecords = async (api, resourceId, relDef, relData, trx)
|
|
|
127
127
|
if (relData.length === 0) return
|
|
128
128
|
assertVersionedRelationshipTransaction(api, relDef, trx)
|
|
129
129
|
const pivot = getPivotStorage(api, relDef)
|
|
130
|
-
const targets = await lockRelationshipTargets(api, trx, relData)
|
|
130
|
+
const targets = await lockRelationshipTargets(api, trx, relData, { exclusive: hasVersionedInverse(api, relDef) })
|
|
131
131
|
await insertMissingPivotRecords(trx, pivot, resourceId, targets.map(record => record.id))
|
|
132
132
|
await invalidateManyToManyVersions(api, relDef, targets.map(record => record.id), trx)
|
|
133
133
|
}
|
|
@@ -17,8 +17,25 @@ export const lockRelationshipParent = async ({ context, helpers, scopeName }) =>
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// Not PostgreSQL's FOR KEY SHARE: it would not conflict with lockRelationshipParent's
|
|
21
|
+
// UPDATE, so writes from opposite ends of one many-to-many edge could both insert it.
|
|
22
|
+
const lockTargetRows = (query, exclusive) => exclusive ? query.forUpdate() : query.forShare()
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Lock referenced rows and fail when one no longer exists.
|
|
26
|
+
* Targets are share-locked so writers referencing one row do not queue behind
|
|
27
|
+
* each other. A caller that later modifies a target must lock it exclusively:
|
|
28
|
+
* upgrading a shared lock deadlocks against a concurrent writer doing the same.
|
|
29
|
+
*
|
|
30
|
+
* @param {any} api
|
|
31
|
+
* @param {any} transaction
|
|
32
|
+
* @param {Array<{ type: string, id: string }>} identifiers
|
|
33
|
+
* @param {{ exclusive?: boolean | Set<string> }} [options] - Lock every target, or targets of these types, for update
|
|
34
|
+
* @returns {Promise<Array<{ type: string, id: string }>>} Each database identity once, in its first submitted spelling
|
|
35
|
+
*/
|
|
36
|
+
export const lockRelationshipTargets = async (api, transaction, identifiers, { exclusive = false } = {}) => {
|
|
21
37
|
if (!transaction) return identifiers
|
|
38
|
+
const isExclusive = type => exclusive instanceof Set ? exclusive.has(type) : exclusive
|
|
22
39
|
const targets = []
|
|
23
40
|
const resolved = []
|
|
24
41
|
const resources = new Map()
|
|
@@ -41,17 +58,17 @@ export const lockRelationshipTargets = async (api, transaction, identifiers) =>
|
|
|
41
58
|
for (const [type, group] of byType) {
|
|
42
59
|
const { adapter } = group[0]
|
|
43
60
|
const idColumn = adapter.getIdColumn()
|
|
44
|
-
const rows = await applyDatabaseReadOptions(adapter.buildBaseQuery({ transaction })
|
|
61
|
+
const rows = await applyDatabaseReadOptions(lockTargetRows(adapter.buildBaseQuery({ transaction })
|
|
45
62
|
.whereIn(idColumn, group.map(target => target.value))
|
|
46
|
-
.orderBy(`${adapter.getTableName()}.${idColumn}`)
|
|
63
|
+
.orderBy(`${adapter.getTableName()}.${idColumn}`), isExclusive(type)).select({ [idColumn]: databaseIdentityExpression(transaction, idColumn) }))
|
|
47
64
|
found.set(type, new Set(rows.map(row => String(row[idColumn]))))
|
|
48
65
|
}
|
|
49
66
|
for (const { type, id, value, adapter } of batch) {
|
|
50
67
|
let storedId = String(value)
|
|
51
68
|
// Let the database resolve alternate spellings under its column collation.
|
|
52
69
|
if (!found.get(type).has(storedId)) {
|
|
53
|
-
const record = await applyDatabaseReadOptions(adapter.buildBaseQuery({ transaction })
|
|
54
|
-
.where(adapter.getIdColumn(), value)
|
|
70
|
+
const record = await applyDatabaseReadOptions(lockTargetRows(adapter.buildBaseQuery({ transaction })
|
|
71
|
+
.where(adapter.getIdColumn(), value), isExclusive(type)).first({ [adapter.getIdColumn()]: databaseIdentityExpression(transaction, adapter.getIdColumn()) }))
|
|
55
72
|
if (!record) {
|
|
56
73
|
throw new RestApiResourceError(`Related ${type} with id ${id} not found`, {
|
|
57
74
|
subtype: 'not_found', resourceType: type, resourceId: id
|
|
@@ -47,8 +47,7 @@ async function readInverseReferences (state, context) {
|
|
|
47
47
|
.where(adapter.getIdColumn(), adapter.translateFilterValue('id', context.id)).forUpdate().first(selection))
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
export async function captureInverseVersions ({ api, helpers, context, scopeName, isCreate = false }) {
|
|
50
|
+
function inverseVersionReferences (api, scopeName, scopeSchemaInfo) {
|
|
52
51
|
const references = []
|
|
53
52
|
for (const [parentType, parent] of Object.entries(api.resources)) {
|
|
54
53
|
const schemaInfo = parent.vars.schemaInfo
|
|
@@ -60,11 +59,25 @@ export async function captureInverseVersions ({ api, helpers, context, scopeName
|
|
|
60
59
|
}
|
|
61
60
|
if (!['hasMany', 'hasOne'].includes(relationship.type) || relationship.target !== scopeName) continue
|
|
62
61
|
if (relationship.via) {
|
|
63
|
-
const inverse =
|
|
62
|
+
const inverse = scopeSchemaInfo.schemaRelationships[relationship.via]?.belongsToPolymorphic
|
|
64
63
|
if (inverse?.types.includes(parentType)) references.push({ parentType, idField: inverse.idField, typeField: inverse.typeField })
|
|
65
64
|
} else if (relationship.foreignKey) references.push({ parentType, idField: relationship.foreignKey })
|
|
66
65
|
}
|
|
67
66
|
}
|
|
67
|
+
return references
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resource types whose revision a write to scopeName can advance.
|
|
72
|
+
* @returns {Set<string>}
|
|
73
|
+
*/
|
|
74
|
+
export function inverseVersionTypes (api, scopeName, scopeSchemaInfo) {
|
|
75
|
+
return new Set(inverseVersionReferences(api, scopeName, scopeSchemaInfo).map(reference => reference.parentType))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Capture actual stored references after authorization; hooks/setters may change the input.
|
|
79
|
+
export async function captureInverseVersions ({ api, helpers, context, scopeName, isCreate = false }) {
|
|
80
|
+
const references = inverseVersionReferences(api, scopeName, context.schemaInfo)
|
|
68
81
|
if (!references.length) return undefined
|
|
69
82
|
const fields = new Set()
|
|
70
83
|
for (const { idField, typeField, valueField } of references) {
|
|
@@ -44,7 +44,7 @@ export async function updateReverseRelationship ({ api, helpers, context, scopeN
|
|
|
44
44
|
} else if (relData !== null) {
|
|
45
45
|
identifiers = [relData]
|
|
46
46
|
}
|
|
47
|
-
const targets = operation === 'remove' ? identifiers : await lockRelationshipTargets(api, context.transaction, identifiers)
|
|
47
|
+
const targets = operation === 'remove' ? identifiers : await lockRelationshipTargets(api, context.transaction, identifiers, { exclusive: true })
|
|
48
48
|
const requested = new Map()
|
|
49
49
|
for (const { id } of targets) {
|
|
50
50
|
const value = adapter.translateFilterValue('id', id)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/** @import {
|
|
3
3
|
* CompletedWriteContext, Field, Identifier, IdentityContext, InputDocument, LifecycleApi,
|
|
4
4
|
* LifecycleArguments, LifecycleContext, LifecycleHelpers, LifecycleResource, LifecycleSchema,
|
|
5
|
-
* LifecycleVars, ProcessingContext, RelationshipContext, Resource, ResourceContext, RunHooks,
|
|
5
|
+
* LifecycleVars, ProcessingContext, ReadContext, RelationshipContext, Resource, ResourceContext, RunHooks,
|
|
6
6
|
* Schema, WriteContext
|
|
7
7
|
* } from './lifecycle-types.js' */
|
|
8
8
|
/** @import { StorageRow } from '../lib/storage/storage-types.js' */
|
|
@@ -388,6 +388,8 @@ export const validateResourceAttributesBeforeWrite = async ({
|
|
|
388
388
|
export const validateRelationshipAccess = async (context, inputRecord, helpers, api) => {
|
|
389
389
|
if (!inputRecord?.data?.relationships) return
|
|
390
390
|
|
|
391
|
+
// Targets of every relationship, so each type is read once per record.
|
|
392
|
+
const unique = new Map()
|
|
391
393
|
for (const [relName, relData] of Object.entries(inputRecord.data.relationships)) {
|
|
392
394
|
if (!relData?.data) continue
|
|
393
395
|
|
|
@@ -402,62 +404,61 @@ export const validateRelationshipAccess = async (context, inputRecord, helpers,
|
|
|
402
404
|
? normalizedRelationshipData
|
|
403
405
|
: [normalizedRelationshipData]
|
|
404
406
|
|
|
405
|
-
const unique = new Map()
|
|
406
407
|
for (const item of relatedItems) unique.set(JSON.stringify([item.type, item.id]), item)
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
408
|
+
}
|
|
409
|
+
const targets = [...unique.values()]
|
|
410
|
+
for (let offset = 0; offset < targets.length; offset += RELATIONSHIP_READ_BATCH_SIZE) {
|
|
411
|
+
const batch = targets.slice(offset, offset + RELATIONSHIP_READ_BATCH_SIZE)
|
|
412
|
+
/** @type {Map<string, Identifier[]>} */
|
|
413
|
+
const byType = new Map()
|
|
414
|
+
for (const item of batch) {
|
|
415
|
+
const items = byType.get(item.type)
|
|
416
|
+
if (items) items.push(item)
|
|
417
|
+
else byType.set(item.type, [item])
|
|
418
|
+
}
|
|
419
|
+
const lookups = new Map()
|
|
420
|
+
for (const [type, items] of byType) {
|
|
421
|
+
const relatedScope = api.resources[type]
|
|
422
|
+
if (!relatedScope) throw new Error(`Unknown resource type: ${type}`)
|
|
423
|
+
const getContext = {
|
|
424
|
+
...context,
|
|
425
|
+
id: undefined,
|
|
426
|
+
minimalRecord: undefined,
|
|
427
|
+
schemaInfo: relatedScope.vars.schemaInfo,
|
|
428
|
+
scopeName: type,
|
|
429
|
+
queryParams: {},
|
|
430
|
+
method: 'get',
|
|
431
|
+
isUpdate: false
|
|
416
432
|
}
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
...
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
scopeName: type,
|
|
427
|
-
queryParams: {},
|
|
428
|
-
method: 'get',
|
|
429
|
-
isUpdate: false
|
|
430
|
-
}
|
|
431
|
-
const lookup = {
|
|
432
|
-
scopeName: type,
|
|
433
|
-
context: getContext,
|
|
434
|
-
/** @param {import('../lib/storage/storage-types.js').QueryFilteringState} filterParams */
|
|
435
|
-
applyQueryFilters: filterParams => relatedScope.applyQueryFilters({
|
|
436
|
-
...filterParams, schemaInfo: getContext.schemaInfo
|
|
437
|
-
}, getContext),
|
|
438
|
-
filters: {},
|
|
439
|
-
queryPurpose: 'relationship-validation'
|
|
440
|
-
}
|
|
441
|
-
const records = await helpers.dataGetMinimal({ ...lookup, ids: items.map(item => item.id) })
|
|
442
|
-
lookups.set(type, { relatedScope, lookup, records: new Map(records.map(record => [String(record.id), record])) })
|
|
433
|
+
const lookup = {
|
|
434
|
+
scopeName: type,
|
|
435
|
+
context: getContext,
|
|
436
|
+
/** @param {import('../lib/storage/storage-types.js').QueryFilteringState} filterParams */
|
|
437
|
+
applyQueryFilters: filterParams => relatedScope.applyQueryFilters({
|
|
438
|
+
...filterParams, schemaInfo: getContext.schemaInfo
|
|
439
|
+
}, getContext),
|
|
440
|
+
filters: {},
|
|
441
|
+
queryPurpose: 'relationship-validation'
|
|
443
442
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
originalContext: { ...lookup.context, id: item.id, minimalRecord: record }
|
|
459
|
-
})
|
|
443
|
+
const records = await helpers.dataGetMinimal({ ...lookup, ids: items.map(item => item.id) })
|
|
444
|
+
lookups.set(type, { relatedScope, lookup, records: new Map(records.map(record => [String(record.id), record])) })
|
|
445
|
+
}
|
|
446
|
+
for (const item of batch) {
|
|
447
|
+
const { relatedScope, lookup, records } = lookups.get(item.type)
|
|
448
|
+
// A database collation can equate IDs with different spellings.
|
|
449
|
+
const record = records.get(String(item.id)) || await helpers.dataGetMinimal({
|
|
450
|
+
...lookup, context: { ...lookup.context, id: item.id }
|
|
451
|
+
})
|
|
452
|
+
if (!record) {
|
|
453
|
+
throw new RestApiResourceError(
|
|
454
|
+
`Cannot create relationship to non-existent ${item.type} with id ${item.id}`,
|
|
455
|
+
{ subtype: 'not_found', resourceType: item.type, resourceId: item.id }
|
|
456
|
+
)
|
|
460
457
|
}
|
|
458
|
+
await relatedScope.checkPermissions({
|
|
459
|
+
method: 'get',
|
|
460
|
+
originalContext: { ...lookup.context, id: item.id, minimalRecord: record }
|
|
461
|
+
})
|
|
461
462
|
}
|
|
462
463
|
}
|
|
463
464
|
}
|
|
@@ -537,19 +538,23 @@ export const applyFieldSetters = async (context, api, helpers) => {
|
|
|
537
538
|
/**
|
|
538
539
|
* Refreshes post-write state and prepares POST/PATCH/PUT responses before commit.
|
|
539
540
|
* Runs finish hooks once and normalizes their result; borrows the write transaction.
|
|
540
|
-
*
|
|
541
|
+
* The refreshed minimalRecord is read only when a later hook can see it.
|
|
542
|
+
* @param {Pick<LifecycleArguments, 'scopeName' | 'api' | 'scopes' | 'runHooks' | 'hasHooks' | 'helpers'> & { context: CompletedWriteContext }} options
|
|
541
543
|
*/
|
|
542
|
-
export async function handleRecordReturnAfterWrite ({ context, scopeName, api, scopes, runHooks, helpers }) {
|
|
544
|
+
export async function handleRecordReturnAfterWrite ({ context, scopeName, api, scopes, runHooks, hasHooks, helpers }) {
|
|
543
545
|
const hookSuffix = getMethodHookSuffix(context.method)
|
|
544
546
|
if (context.minimalRecord) context.originalMinimalRecord = context.minimalRecord
|
|
545
547
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
548
|
+
// A full response reads the record again through get; only hooks see the refresh.
|
|
549
|
+
if (['finish', `finish${hookSuffix}`, 'afterCommit', 'afterRollback', 'transport:response'].some(event => hasHooks(event))) {
|
|
550
|
+
try {
|
|
551
|
+
context.minimalRecord = await helpers.dataGetMinimal({ scopeName, context, runHooks })
|
|
552
|
+
} catch (error) {
|
|
553
|
+
throw wrapUnexpectedError(error, {
|
|
554
|
+
message: `Failed to refresh record after ${context.method}`,
|
|
555
|
+
context: { scopeName, phase: 'postWriteRead' }
|
|
556
|
+
})
|
|
557
|
+
}
|
|
553
558
|
}
|
|
554
559
|
|
|
555
560
|
const returnMode = context.returning
|
|
@@ -590,6 +595,33 @@ export async function handleRecordReturnAfterWrite ({ context, scopeName, api, s
|
|
|
590
595
|
return structuredClone(context.responseRecord)
|
|
591
596
|
}
|
|
592
597
|
|
|
598
|
+
// The hooks that run after a read's snapshot is taken and can see it: the read's
|
|
599
|
+
// own context, or the parentContext of an enrichAttributes wrapper.
|
|
600
|
+
const READ_SNAPSHOT_EVENTS = {
|
|
601
|
+
get: ['enrichRecord', 'enrichAttributes', 'enrichRecordWithRelationships', 'finish', 'finishGet', 'transport:response'],
|
|
602
|
+
query: ['enrichRecord', 'enrichAttributes', 'finish', 'finishQuery', 'transport:response']
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Copies a read's records, as stored, to context.originalRecord before
|
|
607
|
+
* enrichment. A deep copy of every row is the largest CPU cost of a read
|
|
608
|
+
* without hooks, so it is taken only when a hook that runs afterwards is
|
|
609
|
+
* registered for the resource or globally, or an enrichAttributes hook for an
|
|
610
|
+
* included resource.
|
|
611
|
+
* @param {ReadContext} context
|
|
612
|
+
* @param {Record<string, LifecycleResource>} scopes
|
|
613
|
+
* @param {LifecycleArguments['hasHooks']} hasHooks
|
|
614
|
+
* @param {'get' | 'query'} method
|
|
615
|
+
*/
|
|
616
|
+
export function snapshotReadRecord (context, scopes, hasHooks, method) {
|
|
617
|
+
if (!context.record) return
|
|
618
|
+
const includedScopes = new Set((context.record.included || []).map(entry => scopes[entry.type]))
|
|
619
|
+
if (READ_SNAPSHOT_EVENTS[method].some(event => hasHooks(event)) ||
|
|
620
|
+
[...includedScopes].some(scope => hasHooks('enrichAttributes', scope))) {
|
|
621
|
+
context.originalRecord = structuredClone(context.record)
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
593
625
|
/** @param {ResourceContext} context @param {Record<string, LifecycleResource>} scopes */
|
|
594
626
|
export const filterToOneResponseLinkage = async (context, scopes) => {
|
|
595
627
|
const document = context.record
|
|
@@ -11,7 +11,7 @@ import { getRequestedComputedFields, validateRequestedFieldsets } from '../lib/q
|
|
|
11
11
|
import { transformJsonApiToSimplified } from '../lib/querying-writing/simplified-helpers.js'
|
|
12
12
|
import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
|
|
13
13
|
import { requireExistingResourceId } from '../lib/querying-writing/resource-id-normalization.js'
|
|
14
|
-
import { validateRequestedIncludes, filterToOneResponseLinkage } from './common.js'
|
|
14
|
+
import { validateRequestedIncludes, filterToOneResponseLinkage, snapshotReadRecord } from './common.js'
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Read one resource using params.id and params.queryParams. Missing resources
|
|
@@ -26,6 +26,7 @@ export default async function getMethod ({
|
|
|
26
26
|
scope,
|
|
27
27
|
scopes,
|
|
28
28
|
runHooks,
|
|
29
|
+
hasHooks,
|
|
29
30
|
scopeOptions,
|
|
30
31
|
scopeName,
|
|
31
32
|
api
|
|
@@ -129,7 +130,7 @@ export default async function getMethod ({
|
|
|
129
130
|
await runHooks('checkDataPermissions')
|
|
130
131
|
await runHooks('checkDataPermissionsGet')
|
|
131
132
|
|
|
132
|
-
context
|
|
133
|
+
snapshotReadRecord(context, scopes, hasHooks, 'get')
|
|
133
134
|
|
|
134
135
|
await runHooks('enrichRecord')
|
|
135
136
|
|
|
@@ -112,6 +112,8 @@ export interface LifecycleContext<Data = Resource | Resource[] | null, Input = I
|
|
|
112
112
|
computedDependencies?: string[]
|
|
113
113
|
returnMeta?: Record<string, unknown>
|
|
114
114
|
sortableFields?: string[]
|
|
115
|
+
// A server caller's query page cap, replacing queryMaxLimit for that query.
|
|
116
|
+
pageSizeLimit?: number
|
|
115
117
|
auth?: unknown
|
|
116
118
|
[customProperty: string]: unknown
|
|
117
119
|
knexQuery?: QueryFilteringState | null
|
|
@@ -166,6 +168,7 @@ export interface LifecycleArguments<Data = Resource | Resource[] | null> {
|
|
|
166
168
|
scope: LifecycleResource
|
|
167
169
|
scopes: Record<string, LifecycleResource>
|
|
168
170
|
runHooks: RunHooks
|
|
171
|
+
hasHooks: (event: string, scope?: LifecycleResource) => boolean
|
|
169
172
|
scopeOptions: RestApiPluginOptions
|
|
170
173
|
scopeName: string
|
|
171
174
|
api: LifecycleApi
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/** @import {
|
|
3
3
|
* CompletedWriteContext, LifecycleArguments
|
|
4
4
|
* } from './lifecycle-types.js' */
|
|
5
|
-
import { applyResourceVersion, captureInverseVersions, invalidateInverseVersions } from '../lib/writing/resource-version.js'
|
|
5
|
+
import { applyResourceVersion, captureInverseVersions, inverseVersionTypes, invalidateInverseVersions } from '../lib/writing/resource-version.js'
|
|
6
6
|
import { RestApiResourceError } from '../../../lib/rest-api-errors.js'
|
|
7
7
|
import { lockRelationshipParent, lockRelationshipTargets, processRelationships } from '../lib/writing/relationship-processor.js'
|
|
8
8
|
import { updateReverseRelationship } from '../lib/writing/reverse-relationship-manipulations.js'
|
|
@@ -35,6 +35,7 @@ export default async function patchMethod ({
|
|
|
35
35
|
scope,
|
|
36
36
|
scopes,
|
|
37
37
|
runHooks,
|
|
38
|
+
hasHooks,
|
|
38
39
|
scopeOptions,
|
|
39
40
|
scopeName,
|
|
40
41
|
api,
|
|
@@ -108,7 +109,10 @@ export default async function patchMethod ({
|
|
|
108
109
|
await runHooks('beforeDataCall')
|
|
109
110
|
await runHooks('beforeDataCallPatch')
|
|
110
111
|
|
|
111
|
-
|
|
112
|
+
// A parent whose revision this write advances is updated, not just referenced.
|
|
113
|
+
await lockRelationshipTargets(api, context.transaction, belongsToTargets, {
|
|
114
|
+
exclusive: inverseVersionTypes(api, scopeName, context.schemaInfo)
|
|
115
|
+
})
|
|
112
116
|
|
|
113
117
|
if (manyToManyRelationships.length || reverseRelationships.length) {
|
|
114
118
|
await lockRelationshipParent({ context, helpers, scopeName })
|
|
@@ -167,6 +171,7 @@ export default async function patchMethod ({
|
|
|
167
171
|
api,
|
|
168
172
|
scopes,
|
|
169
173
|
runHooks,
|
|
174
|
+
hasHooks,
|
|
170
175
|
helpers
|
|
171
176
|
})
|
|
172
177
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/** @import {
|
|
3
3
|
* CompletedWriteContext, LifecycleArguments, WriteContext
|
|
4
4
|
* } from './lifecycle-types.js' */
|
|
5
|
-
import { applyResourceVersion, captureInverseVersions, invalidateInverseVersions } from '../lib/writing/resource-version.js'
|
|
5
|
+
import { applyResourceVersion, captureInverseVersions, inverseVersionTypes, invalidateInverseVersions } from '../lib/writing/resource-version.js'
|
|
6
6
|
import { lockRelationshipTargets, processRelationships } from '../lib/writing/relationship-processor.js'
|
|
7
7
|
import { updateReverseRelationship } from '../lib/writing/reverse-relationship-manipulations.js'
|
|
8
8
|
import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
|
|
@@ -33,6 +33,7 @@ export default async function postMethod ({
|
|
|
33
33
|
scope,
|
|
34
34
|
scopes,
|
|
35
35
|
runHooks,
|
|
36
|
+
hasHooks,
|
|
36
37
|
scopeOptions,
|
|
37
38
|
scopeName,
|
|
38
39
|
api,
|
|
@@ -115,7 +116,10 @@ export default async function postMethod ({
|
|
|
115
116
|
await runHooks('beforeDataCall')
|
|
116
117
|
await runHooks('beforeDataCallPost')
|
|
117
118
|
|
|
118
|
-
|
|
119
|
+
// A parent whose revision this write advances is updated, not just referenced.
|
|
120
|
+
await lockRelationshipTargets(api, context.transaction, belongsToTargets, {
|
|
121
|
+
exclusive: inverseVersionTypes(api, scopeName, context.schemaInfo)
|
|
122
|
+
})
|
|
119
123
|
|
|
120
124
|
await applyFieldSetters(context, api, helpers)
|
|
121
125
|
const inverseVersions = await captureInverseVersions({ api, helpers, context, scopeName, isCreate: true })
|
|
@@ -166,6 +170,7 @@ export default async function postMethod ({
|
|
|
166
170
|
api,
|
|
167
171
|
scopes,
|
|
168
172
|
runHooks,
|
|
173
|
+
hasHooks,
|
|
169
174
|
helpers
|
|
170
175
|
})
|
|
171
176
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/** @import {
|
|
3
3
|
* CompletedWriteContext, LifecycleArguments, PivotField
|
|
4
4
|
* } from './lifecycle-types.js' */
|
|
5
|
-
import { applyResourceVersion, captureInverseVersions, invalidateInverseVersions } from '../lib/writing/resource-version.js'
|
|
5
|
+
import { applyResourceVersion, captureInverseVersions, inverseVersionTypes, invalidateInverseVersions } from '../lib/writing/resource-version.js'
|
|
6
6
|
import { RestApiResourceError } from '../../../lib/rest-api-errors.js'
|
|
7
7
|
import { lockRelationshipParent, lockRelationshipTargets, processRelationships } from '../lib/writing/relationship-processor.js'
|
|
8
8
|
import { updateReverseRelationship } from '../lib/writing/reverse-relationship-manipulations.js'
|
|
@@ -37,6 +37,7 @@ export default async function putMethod ({
|
|
|
37
37
|
scope,
|
|
38
38
|
scopes,
|
|
39
39
|
runHooks,
|
|
40
|
+
hasHooks,
|
|
40
41
|
scopeOptions,
|
|
41
42
|
scopeName,
|
|
42
43
|
api,
|
|
@@ -148,7 +149,10 @@ export default async function putMethod ({
|
|
|
148
149
|
await runHooks('beforeDataCall')
|
|
149
150
|
await runHooks('beforeDataCallPut')
|
|
150
151
|
|
|
151
|
-
|
|
152
|
+
// A parent whose revision this write advances is updated, not just referenced.
|
|
153
|
+
await lockRelationshipTargets(api, context.transaction, belongsToTargets, {
|
|
154
|
+
exclusive: inverseVersionTypes(api, scopeName, context.schemaInfo)
|
|
155
|
+
})
|
|
152
156
|
|
|
153
157
|
if (!context.isCreate && (manyToManyRelationships.length || reverseRelationships.length)) {
|
|
154
158
|
await lockRelationshipParent({ context, helpers, scopeName })
|
|
@@ -211,6 +215,7 @@ export default async function putMethod ({
|
|
|
211
215
|
api,
|
|
212
216
|
scopes,
|
|
213
217
|
runHooks,
|
|
218
|
+
hasHooks,
|
|
214
219
|
helpers
|
|
215
220
|
})
|
|
216
221
|
|
|
@@ -10,8 +10,21 @@ import { getRequestedComputedFields, validateRequestedFieldsets } from '../lib/q
|
|
|
10
10
|
import { getEffectiveSortableFields } from '../lib/querying/query-field-sort-helpers.js'
|
|
11
11
|
import { transformJsonApiToSimplified } from '../lib/querying-writing/simplified-helpers.js'
|
|
12
12
|
import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
|
|
13
|
-
import { validateRequestedIncludes, filterToOneResponseLinkage } from './common.js'
|
|
13
|
+
import { validateRequestedIncludes, filterToOneResponseLinkage, snapshotReadRecord } from './common.js'
|
|
14
14
|
import { queryConstraint } from '../lib/querying/query-constraint.js'
|
|
15
|
+
import { RestApiValidationError } from '../../../lib/rest-api-errors.js'
|
|
16
|
+
|
|
17
|
+
/** @param {unknown} value */
|
|
18
|
+
const validatePageSizeLimit = value => {
|
|
19
|
+
if (value === undefined) return undefined
|
|
20
|
+
if (!Number.isSafeInteger(value) || /** @type {number} */ (value) <= 0) {
|
|
21
|
+
throw new RestApiValidationError('pageSizeLimit must be a positive integer', {
|
|
22
|
+
fields: ['pageSizeLimit'],
|
|
23
|
+
violations: [{ field: 'pageSizeLimit', rule: 'min_value', message: 'pageSizeLimit must be a positive integer' }]
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
return /** @type {number} */ (value)
|
|
27
|
+
}
|
|
15
28
|
|
|
16
29
|
/**
|
|
17
30
|
* Read a filtered collection using params.queryParams and the selected format.
|
|
@@ -27,12 +40,15 @@ export default async function queryMethod ({
|
|
|
27
40
|
scope,
|
|
28
41
|
scopes,
|
|
29
42
|
runHooks,
|
|
43
|
+
hasHooks,
|
|
30
44
|
scopeName,
|
|
31
45
|
api
|
|
32
46
|
}) {
|
|
33
47
|
callerContext.method = 'query'
|
|
34
48
|
// Nested queries only inherit membership when it is explicitly supplied again.
|
|
35
49
|
callerContext[queryConstraint] = params[queryConstraint]
|
|
50
|
+
// A server caller's page cap for this call; connectors never pass it.
|
|
51
|
+
callerContext.pageSizeLimit = validatePageSizeLimit(params.pageSizeLimit)
|
|
36
52
|
|
|
37
53
|
rejectRemovedOptions(params)
|
|
38
54
|
callerContext.format = resolveFormat(params.format, vars.format)
|
|
@@ -90,7 +106,7 @@ export default async function queryMethod ({
|
|
|
90
106
|
|
|
91
107
|
context.record = normalizeRecordAttributes(context.record, scopes)
|
|
92
108
|
|
|
93
|
-
context
|
|
109
|
+
snapshotReadRecord(context, scopes, hasHooks, 'query')
|
|
94
110
|
|
|
95
111
|
await runHooks('enrichRecord')
|
|
96
112
|
|
|
@@ -94,7 +94,13 @@ export interface ResourceCoreMethods<
|
|
|
94
94
|
params: ReadOptions<Format> & { id: DirectResourceId }, context?: Context
|
|
95
95
|
): Promise<SingleResourceResult<Format, Fields, Type>>
|
|
96
96
|
query<Format extends ResourceFormat = DefaultFormat>(
|
|
97
|
-
params?: RemovedResourceOptions & TransactionParam & {
|
|
97
|
+
params?: RemovedResourceOptions & TransactionParam & {
|
|
98
|
+
format?: Format
|
|
99
|
+
queryParams?: QueryParams
|
|
100
|
+
// Server code only: the largest page size for this call, replacing the resource's queryMaxLimit.
|
|
101
|
+
// HTTP and Socket.IO connectors never pass it.
|
|
102
|
+
pageSizeLimit?: number
|
|
103
|
+
}, context?: Context
|
|
98
104
|
): Promise<CollectionResult<Format, Fields, Type>>
|
|
99
105
|
aggregate(params: AggregateParams, context?: Context): Promise<AggregateResult>
|
|
100
106
|
post<Format extends ResourceFormat = DefaultFormat, Returning extends WriteReturning = DefaultReturning>(
|
package/types/runtime.d.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface RuntimeArguments<Context extends object = Record<string, unknow
|
|
|
31
31
|
helpers: Record<string, unknown>
|
|
32
32
|
log: RuntimeLogger
|
|
33
33
|
runHooks(event: string, context?: Record<string, unknown>): Promise<boolean>
|
|
34
|
+
/** Whether runHooks(event) for this resource, or for scope when given, would call any handler. */
|
|
35
|
+
hasHooks(event: string, scope?: RuntimeResource): boolean
|
|
34
36
|
}
|
|
35
37
|
export type RuntimeMethod<Context extends object = Record<string, unknown>> = (args: RuntimeArguments<Context>) => unknown
|
|
36
38
|
export type HookPlacement = { beforeFunction?: string; afterFunction?: never } | { beforeFunction?: never; afterFunction?: string }
|