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 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 // Database 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 polymorphic targets are rechecked while locking them. Collection
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
- `SELECT ... FOR UPDATE` statements when configuring the application's database
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'`. It happens before finish hooks and
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "json-rest-api",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "main": "index.js",
5
5
  "types": "index.d.ts",
6
6
  "type": "module",
@@ -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 loadBatch = async values => {
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
- .whereIn(column, values)
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
- export const applyPaginationToQuery = ({ query, page = {}, vars, sortDescriptors, storageAdapter }) => {
11
- const pageSize = Math.min(page.size ?? (vars.queryDefaultLimit || DEFAULT_QUERY_LIMIT), vars.queryMaxLimit || DEFAULT_MAX_QUERY_LIMIT)
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'],
@@ -503,6 +503,7 @@ export function createCanonicalDataHelpers ({
503
503
  query: queryBuilder,
504
504
  page: queryParams.page,
505
505
  vars: scope.vars,
506
+ pageSizeLimit: context.pageSizeLimit,
506
507
  sortDescriptors,
507
508
  storageAdapter,
508
509
  })
@@ -385,6 +385,7 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
385
385
  query,
386
386
  page: queryParams.page,
387
387
  vars: scope.vars,
388
+ pageSizeLimit: context.pageSizeLimit,
388
389
  sortDescriptors,
389
390
  storageAdapter
390
391
  })
@@ -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
- export const lockRelationshipTargets = async (api, transaction, identifiers) => {
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}`).forUpdate().select({ [idColumn]: databaseIdentityExpression(transaction, 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).forUpdate().first({ [adapter.getIdColumn()]: databaseIdentityExpression(transaction, adapter.getIdColumn()) }))
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
- // Capture actual stored references after authorization; hooks/setters may change the input.
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 = context.schemaInfo.schemaRelationships[relationship.via]?.belongsToPolymorphic
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
- const targets = [...unique.values()]
408
- for (let offset = 0; offset < targets.length; offset += RELATIONSHIP_READ_BATCH_SIZE) {
409
- const batch = targets.slice(offset, offset + RELATIONSHIP_READ_BATCH_SIZE)
410
- /** @type {Map<string, Identifier[]>} */
411
- const byType = new Map()
412
- for (const item of batch) {
413
- const items = byType.get(item.type)
414
- if (items) items.push(item)
415
- else byType.set(item.type, [item])
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 lookups = new Map()
418
- for (const [type, items] of byType) {
419
- const relatedScope = api.resources[type]
420
- if (!relatedScope) throw new Error(`Unknown resource type: ${type}`)
421
- const getContext = {
422
- ...context,
423
- id: undefined,
424
- minimalRecord: undefined,
425
- schemaInfo: relatedScope.vars.schemaInfo,
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
- for (const item of batch) {
445
- const { relatedScope, lookup, records } = lookups.get(item.type)
446
- // A database collation can equate IDs with different spellings.
447
- const record = records.get(String(item.id)) || await helpers.dataGetMinimal({
448
- ...lookup, context: { ...lookup.context, id: item.id }
449
- })
450
- if (!record) {
451
- throw new RestApiResourceError(
452
- `Cannot create relationship to non-existent ${item.type} with id ${item.id}`,
453
- { subtype: 'not_found', resourceType: item.type, resourceId: item.id }
454
- )
455
- }
456
- await relatedScope.checkPermissions({
457
- method: 'get',
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
- * @param {Pick<LifecycleArguments, 'scopeName' | 'api' | 'scopes' | 'runHooks' | 'helpers'> & { context: CompletedWriteContext }} options
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
- try {
547
- context.minimalRecord = await helpers.dataGetMinimal({ scopeName, context, runHooks })
548
- } catch (error) {
549
- throw wrapUnexpectedError(error, {
550
- message: `Failed to refresh record after ${context.method}`,
551
- context: { scopeName, phase: 'postWriteRead' }
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.originalRecord = structuredClone(context.record)
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
- await lockRelationshipTargets(api, context.transaction, belongsToTargets)
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
- await lockRelationshipTargets(api, context.transaction, belongsToTargets)
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
- await lockRelationshipTargets(api, context.transaction, belongsToTargets)
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.originalRecord = structuredClone(context.record)
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 & { format?: Format; queryParams?: QueryParams }, context?: Context
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>(
@@ -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 }