json-rest-api 2.3.1 → 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
 
@@ -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.1",
3
+ "version": "2.4.0",
4
4
  "main": "index.js",
5
5
  "types": "index.d.ts",
6
6
  "type": "module",
@@ -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?: {
@@ -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
@@ -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,
@@ -170,6 +171,7 @@ export default async function patchMethod ({
170
171
  api,
171
172
  scopes,
172
173
  runHooks,
174
+ hasHooks,
173
175
  helpers
174
176
  })
175
177
 
@@ -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,
@@ -169,6 +170,7 @@ export default async function postMethod ({
169
170
  api,
170
171
  scopes,
171
172
  runHooks,
173
+ hasHooks,
172
174
  helpers
173
175
  })
174
176
 
@@ -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,
@@ -214,6 +215,7 @@ export default async function putMethod ({
214
215
  api,
215
216
  scopes,
216
217
  runHooks,
218
+ hasHooks,
217
219
  helpers
218
220
  })
219
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 }