json-rest-api 2.4.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +7 -2
  2. package/docs/API.md +262 -54
  3. package/docs/GUIDE/01-getting-started.md +1 -1
  4. package/docs/GUIDE/07-polymorphic-relationships.md +4 -2
  5. package/docs/GUIDE/09-pagination-and-sorting.md +4 -2
  6. package/docs/GUIDE/13-hooks-and-lifecycle.md +34 -6
  7. package/docs/GUIDE/14-custom-resource-methods.md +3 -2
  8. package/docs/GUIDE/16-autofiltering.md +54 -2
  9. package/docs/GUIDE/17-row-policies.md +15 -5
  10. package/docs/GUIDE/19-managed-transactions.md +8 -9
  11. package/docs/GUIDE/20-transaction-outcomes.md +3 -4
  12. package/docs/GUIDE/21-schema-and-migrations.md +7 -1
  13. package/docs/GUIDE/22-version-field-migrations.md +3 -3
  14. package/docs/GUIDE/27-many-row-writes.md +458 -0
  15. package/docs/GUIDE/28-socketio.md +10 -2
  16. package/docs/GUIDE/30-backend-capabilities.md +42 -2
  17. package/docs/GUIDE/31-positioning.md +3 -1
  18. package/docs/GUIDE/33-migrating-to-v2.md +2 -2
  19. package/docs/GUIDE/34-scoped-reads-and-locks.md +464 -0
  20. package/docs/GUIDE/35-migrating-to-v3.md +325 -0
  21. package/docs/GUIDE/index.md +5 -2
  22. package/docs/QUICKSTART.md +2 -2
  23. package/docs/contributing.md +1 -1
  24. package/index.d.ts +8 -1
  25. package/index.js +4 -0
  26. package/lib/rest-api-errors.js +79 -0
  27. package/lib/runtime/json-rest-api.js +18 -3
  28. package/lib/system-context.js +42 -0
  29. package/package.json +3 -2
  30. package/plugins/core/bulk-operations-plugin.js +54 -436
  31. package/plugins/core/connectors/lib/transport-http-helpers.js +34 -3
  32. package/plugins/core/file-handling-plugin.js +17 -2
  33. package/plugins/core/lib/anyapi/anyapi-registry.js +3 -2
  34. package/plugins/core/lib/anyapi/canonical-link-store.js +5 -2
  35. package/plugins/core/lib/anyapi/canonical-relationship-reader.js +56 -60
  36. package/plugins/core/lib/dbIntrospection.js +106 -6
  37. package/plugins/core/lib/querying/aggregate-query.js +215 -22
  38. package/plugins/core/lib/querying/include-query-helpers.js +11 -13
  39. package/plugins/core/lib/querying/include-to-one.js +54 -59
  40. package/plugins/core/lib/querying/knex-pagination-helpers.js +9 -2
  41. package/plugins/core/lib/querying/query-constraint.d.ts +11 -0
  42. package/plugins/core/lib/querying/query-constraint.js +45 -0
  43. package/plugins/core/lib/querying/read-only-query.js +92 -0
  44. package/plugins/core/lib/querying-writing/compile-schemas.js +14 -0
  45. package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
  46. package/plugins/core/lib/storage/canonical-data-helpers.js +103 -39
  47. package/plugins/core/lib/storage/many-row-data-helpers.js +738 -0
  48. package/plugins/core/lib/storage/ordinary-data-helpers.js +63 -79
  49. package/plugins/core/lib/storage/single-row-writes.js +71 -0
  50. package/plugins/core/lib/storage/storage-adapter.js +18 -8
  51. package/plugins/core/lib/storage/storage-mapping.js +4 -1
  52. package/plugins/core/lib/storage/storage-types.d.ts +21 -3
  53. package/plugins/core/lib/writing/many-to-many-manipulations.js +10 -5
  54. package/plugins/core/lib/writing/relationship-processor.js +33 -37
  55. package/plugins/core/rest-api-anyapi-knex-plugin.js +3 -0
  56. package/plugins/core/rest-api-autofilter-plugin.js +137 -10
  57. package/plugins/core/rest-api-knex-plugin.js +2 -0
  58. package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
  59. package/plugins/core/rest-api-plugin-methods/common.js +144 -84
  60. package/plugins/core/rest-api-plugin-methods/delete-many.js +132 -0
  61. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +22 -0
  62. package/plugins/core/rest-api-plugin-methods/get.js +48 -42
  63. package/plugins/core/rest-api-plugin-methods/many-row-common.js +274 -0
  64. package/plugins/core/rest-api-plugin-methods/patch-many.js +333 -0
  65. package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
  66. package/plugins/core/rest-api-plugin-methods/post-many.js +295 -0
  67. package/plugins/core/rest-api-plugin-methods/put.js +5 -1
  68. package/plugins/core/rest-api-plugin-methods/scoped-reads.js +221 -0
  69. package/plugins/core/rest-api-plugin.js +17 -0
  70. package/plugins/core/rest-api-positioning-plugin.js +22 -4
  71. package/plugins/core/rest-api-query-projections-plugin.js +9 -1
  72. package/plugins/core/rest-api-row-policy-plugin.js +4 -0
  73. package/plugins/core/socketio-plugin.js +80 -2
  74. package/types/autofilter.d.ts +5 -1
  75. package/types/data-access-methods.d.ts +141 -0
  76. package/types/errors.d.ts +45 -0
  77. package/types/hook-context.d.ts +21 -0
  78. package/types/plugin-options.d.ts +3 -1
  79. package/types/resource-methods.d.ts +27 -4
  80. package/types/row-policy.d.ts +3 -0
  81. package/types/runtime.d.ts +7 -1
  82. package/docs/GUIDE/27-bulk-operations.md +0 -277
  83. package/types/bulk-methods.d.ts +0 -63
@@ -1,8 +1,12 @@
1
- import { RestApiValidationError } from '../../lib/rest-api-errors.js'
1
+ import { RestApiResourceError, RestApiValidationError } from '../../lib/rest-api-errors.js'
2
+ import { assertGenuineSystemFlag, isSystemContext } from '../../lib/system-context.js'
2
3
  import { snapshotResourceConfiguration } from './lib/querying-writing/schema-helpers.js'
3
4
  import { createStorageAdapterUtilities } from './lib/querying/storage-adapter-utils.js'
5
+ import { ANY_OWNER, recordWriteCondition } from './lib/querying/query-constraint.js'
4
6
 
5
7
  const PUBLIC_PRESET_NAME = 'public'
8
+ // Queries that write or lock the rows they select: shared rows are never theirs.
9
+ const WRITE_PURPOSES = new Set(['bulk-write', 'lock', 'claim'])
6
10
 
7
11
  function valuesEqual (left, right) {
8
12
  if (left === right) return true
@@ -108,12 +112,20 @@ function normalizeFilterDefinition ({ filterDef, resolvers, schemaStructure, sco
108
112
  ? fieldDef.as
109
113
  : null
110
114
 
115
+ if (filterDef.shared !== undefined && typeof filterDef.shared !== 'boolean') {
116
+ throw new Error(`Autofilter field '${field}' on resource '${scopeName}' must set shared to true or false.`)
117
+ }
118
+ if (filterDef.shared === true && fieldDef.nullable !== true) {
119
+ throw new Error(`Autofilter field '${field}' on resource '${scopeName}' is shared, so it must be nullable: NULL marks a shared row.`)
120
+ }
121
+
111
122
  return {
112
123
  field,
113
124
  fieldDef,
114
125
  resolve,
115
126
  resolverName,
116
127
  required: filterDef.required !== false,
128
+ shared: filterDef.shared === true,
117
129
  relationshipName,
118
130
  relationshipType: relationshipName ? fieldDef.belongsTo : null,
119
131
  inputPath: relationshipName
@@ -247,8 +259,56 @@ function applyResolvedInputValue ({ context, filter, resolvedValue, injectMissin
247
259
  }
248
260
  }
249
261
 
262
+ // A system context owns no workspace or user, so a create must say whose row it is.
263
+ function requireExplicitOwnership ({ compiledConfig, inputRecord, scopeName, index }) {
264
+ const data = inputRecord?.data || {}
265
+ const missing = compiledConfig.filters.filter(filter => {
266
+ if (filter.relationshipName && data.relationships?.[filter.relationshipName]?.data !== undefined) return false
267
+ return data.attributes?.[filter.field] === undefined
268
+ })
269
+ if (!missing.length) return
270
+ const prefix = index === undefined ? '' : `data.${index}.`
271
+ const violations = missing.map(filter => ({
272
+ field: `${prefix}${filter.inputPath}`,
273
+ rule: 'system_ownership_required',
274
+ message: `A create in a system context must set '${filter.field}' explicitly${filter.shared ? ' (null for a shared row)' : ''}`
275
+ }))
276
+ throw new RestApiValidationError(`Creating '${scopeName}' records in a system context needs explicit ownership values`, {
277
+ fields: violations.map(violation => violation.field),
278
+ violations
279
+ })
280
+ }
281
+
282
+ // Without an owner value a shared filter would stamp NULL, creating a row every owner sees.
283
+ function refuseSharedCreate ({ filter, resolvedValue, scopeName }) {
284
+ if (!filter.shared || resolvedValue !== null) return
285
+ throw new RestApiResourceError(`Shared ${scopeName} records can only be created in a system context`, {
286
+ subtype: 'forbidden', resourceType: scopeName
287
+ })
288
+ }
289
+
290
+ // An optional owner that did not resolve stamps nothing; the row must still not end up shared.
291
+ function refuseSharedResult ({ compiledConfig, record, scopeName }) {
292
+ for (const filter of compiledConfig.filters) {
293
+ if (filter.shared && sharedFieldValue(record, filter) == null) refuseSharedCreate({ filter, resolvedValue: null, scopeName })
294
+ }
295
+ }
296
+
297
+ function sharedFieldValue (record, filter) {
298
+ if (filter.relationshipName) {
299
+ const linkage = record?.relationships?.[filter.relationshipName]?.data
300
+ if (linkage !== undefined) return linkage === null ? null : linkage.id
301
+ }
302
+ return record?.attributes?.[filter.field]
303
+ }
304
+
250
305
  async function enforceScopedInput ({ compiledConfig, context, scopeName, injectMissing, api, helpers, scopes, vars, log }) {
251
306
  if (!compiledConfig || compiledConfig.filters.length === 0) return
307
+ assertGenuineSystemFlag(context)
308
+ if (isSystemContext(context)) {
309
+ if (injectMissing) requireExplicitOwnership({ compiledConfig, inputRecord: context.inputRecord, scopeName })
310
+ return
311
+ }
252
312
 
253
313
  for (const filter of compiledConfig.filters) {
254
314
  const resolvedValue = await resolveFilterValue({
@@ -263,6 +323,7 @@ async function enforceScopedInput ({ compiledConfig, context, scopeName, injectM
263
323
  })
264
324
 
265
325
  if (resolvedValue === undefined) continue
326
+ if (injectMissing) refuseSharedCreate({ filter, resolvedValue, scopeName })
266
327
 
267
328
  applyResolvedInputValue({
268
329
  context,
@@ -271,6 +332,7 @@ async function enforceScopedInput ({ compiledConfig, context, scopeName, injectM
271
332
  injectMissing
272
333
  })
273
334
  }
335
+ if (injectMissing) refuseSharedResult({ compiledConfig, record: context.inputRecord?.data, scopeName })
274
336
  }
275
337
 
276
338
  export const AutoFilterPlugin = {
@@ -304,14 +366,19 @@ export const AutoFilterPlugin = {
304
366
  resolvers: state.resolvers,
305
367
  presets: state.presets
306
368
  })
369
+ // Many-row writes refuse these fields and require them in upsert conflict keys.
370
+ schemaInfo.ownershipFields = (schemaInfo.autofilter?.filters || []).map(filter => filter.field)
307
371
  })
308
372
 
309
373
  addHook('knexQueryFiltering', 'autofilter-scope', {}, async ({ context }) => {
310
- const { query, tableName, scopeName } = context.knexQuery || {}
374
+ const { query, tableName, scopeName, queryPurpose } = context.knexQuery || {}
311
375
  if (!query || !scopeName) return
312
376
 
313
377
  const compiledConfig = scopes[scopeName]?.vars?.schemaInfo?.autofilter
314
378
  if (!compiledConfig || compiledConfig.filters.length === 0) return
379
+ assertGenuineSystemFlag(context)
380
+ if (isSystemContext(context)) return
381
+ const writes = WRITE_PURPOSES.has(queryPurpose)
315
382
  const adapterUtils = createStorageAdapterUtilities({ context }, {
316
383
  getStorageAdapter: helpers.getStorageAdapter
317
384
  })
@@ -328,20 +395,39 @@ export const AutoFilterPlugin = {
328
395
  log
329
396
  })
330
397
 
331
- if (resolvedValue === undefined) continue
332
-
333
398
  const columnRef = adapterUtils.translateColumn(scopeName, filter.field, tableName)
399
+ // Every scoped read records the owner a later write of its rows must
400
+ // still match, so a single write that reads first carries it too.
401
+ if (resolvedValue === undefined) {
402
+ // An optional owner that did not resolve: reads are not narrowed, but
403
+ // shared rows stay out of writes and locks.
404
+ if (filter.shared) {
405
+ if (writes) query.whereNotNull(columnRef)
406
+ recordWriteCondition(context, scopeName, filter.field, ANY_OWNER)
407
+ }
408
+ continue
409
+ }
334
410
  const translatedValue = adapterUtils.translateFilterValue(scopeName, filter.field, resolvedValue)
335
411
 
336
- if (translatedValue === null) {
337
- query.whereNull(columnRef)
412
+ if (filter.shared) {
413
+ // Reads see the owner's rows and the shared (NULL) ones; writes and locks only the owner's.
414
+ if (translatedValue === null) {
415
+ if (writes) query.whereRaw('1 = 0')
416
+ else query.whereNull(columnRef)
417
+ } else {
418
+ if (writes) query.where(columnRef, translatedValue)
419
+ else query.where(builder => builder.where(columnRef, translatedValue).orWhereNull(columnRef))
420
+ recordWriteCondition(context, scopeName, filter.field, translatedValue)
421
+ }
338
422
  } else {
339
- query.where(columnRef, translatedValue)
423
+ if (translatedValue === null) query.whereNull(columnRef)
424
+ else query.where(columnRef, translatedValue)
425
+ recordWriteCondition(context, scopeName, filter.field, translatedValue)
340
426
  }
341
427
  }
342
428
  })
343
429
 
344
- addHook('beforeProcessingPost', 'autofilter-stamp-post', {}, async ({ context, scopeName }) => {
430
+ addHook('beforeProcessingPost', 'autofilter-stamp-post', { batch: true }, async ({ context, scopeName }) => {
345
431
  await enforceScopedInput({
346
432
  compiledConfig: scopes[scopeName]?.vars?.schemaInfo?.autofilter,
347
433
  context,
@@ -369,7 +455,7 @@ export const AutoFilterPlugin = {
369
455
  })
370
456
  })
371
457
 
372
- addHook('beforeProcessingPatch', 'autofilter-validate-patch', {}, async ({ context, scopeName }) => {
458
+ addHook('beforeProcessingPatch', 'autofilter-validate-patch', { batch: true }, async ({ context, scopeName }) => {
373
459
  await enforceScopedInput({
374
460
  compiledConfig: scopes[scopeName]?.vars?.schemaInfo?.autofilter,
375
461
  context,
@@ -383,6 +469,46 @@ export const AutoFilterPlugin = {
383
469
  })
384
470
  })
385
471
 
472
+ // Batch form of autofilter-stamp-post: resolve each owner value once, stamp every row.
473
+ addHook('beforeProcessingPostMany', 'autofilter-stamp-post-many', {}, async ({ context, scopeName }) => {
474
+ const compiledConfig = scopes[scopeName]?.vars?.schemaInfo?.autofilter
475
+ if (!compiledConfig || compiledConfig.filters.length === 0) return
476
+ assertGenuineSystemFlag(context)
477
+ if (isSystemContext(context)) {
478
+ context.inputRecords.forEach((inputRecord, index) => requireExplicitOwnership({ compiledConfig, inputRecord, scopeName, index }))
479
+ return
480
+ }
481
+ for (const filter of compiledConfig.filters) {
482
+ const resolvedValue = await resolveFilterValue({ filter, context, scopeName, api, helpers, scopes, vars, log })
483
+ if (resolvedValue === undefined) continue
484
+ refuseSharedCreate({ filter, resolvedValue, scopeName })
485
+ for (const inputRecord of context.inputRecords) {
486
+ applyResolvedInputValue({ context: { inputRecord }, filter, resolvedValue, injectMissing: true })
487
+ }
488
+ }
489
+ for (const inputRecord of context.inputRecords) refuseSharedResult({ compiledConfig, record: inputRecord?.data, scopeName })
490
+ })
491
+
492
+ // Shared rows are readable by every owner but writable only in a system context.
493
+ const refuseSharedRowWrite = ({ context, scopeName }) => {
494
+ const compiledConfig = scopes[scopeName]?.vars?.schemaInfo?.autofilter
495
+ const sharedFilters = compiledConfig?.filters.filter(filter => filter.shared) || []
496
+ if (!sharedFilters.length || !context.minimalRecord || isSystemContext(context)) return
497
+ for (const filter of sharedFilters) {
498
+ if (sharedFieldValue(context.minimalRecord, filter) === null) {
499
+ throw new RestApiResourceError(`Shared ${scopeName} records can only be changed in a system context`, {
500
+ subtype: 'forbidden', resourceType: scopeName, resourceId: context.id
501
+ })
502
+ }
503
+ }
504
+ }
505
+ addHook('beforeDataCallPatch', 'autofilter-shared-row-patch', { batch: true }, refuseSharedRowWrite)
506
+ addHook('beforeDataCallPut', 'autofilter-shared-row-put', { batch: true }, refuseSharedRowWrite)
507
+ addHook('beforeDataCallDelete', 'autofilter-shared-row-delete', { batch: true }, refuseSharedRowWrite)
508
+ // Links of a shared row belong to every owner that sees it.
509
+ addHook('beforeDataCallPostRelationship', 'autofilter-shared-row-post-relationship', {}, refuseSharedRowWrite)
510
+ addHook('beforeDataCallDeleteRelationship', 'autofilter-shared-row-delete-relationship', {}, refuseSharedRowWrite)
511
+
386
512
  api.autofilter = {
387
513
  getConfig: () => ({
388
514
  presets: Object.keys(state.presets),
@@ -398,7 +524,8 @@ export const AutoFilterPlugin = {
398
524
  filters: compiledConfig.filters.map((filter) => ({
399
525
  field: filter.field,
400
526
  resolver: filter.resolverName,
401
- required: filter.required
527
+ required: filter.required,
528
+ shared: filter.shared
402
529
  }))
403
530
  }
404
531
  }
@@ -1,4 +1,5 @@
1
1
  import { createOrdinaryDataHelpers } from './lib/storage/ordinary-data-helpers.js'
2
+ import { createManyRowDataHelpers } from './lib/storage/many-row-data-helpers.js'
2
3
  import { createKnexTransaction } from '../../lib/knex-transaction.js'
3
4
  import { createEnhancedLogger } from '../../lib/enhanced-logger.js'
4
5
  import { throwMissingPackage } from '../../lib/missing-package.js'
@@ -160,6 +161,7 @@ export const RestApiKnexPlugin = {
160
161
  helpers.newTransaction = context => createKnexTransaction(knex, context)
161
162
 
162
163
  Object.assign(helpers, createOrdinaryDataHelpers({ api, scopes, knex, log, getScopeStorageAdapter }))
164
+ Object.assign(helpers, createManyRowDataHelpers({ api, scopes, log, getScopeStorageAdapter }))
163
165
 
164
166
  log.info('RestApiKnexPlugin installed - basic CRUD operations ready')
165
167
  }
@@ -1,6 +1,7 @@
1
1
  import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
2
2
  import { getEffectiveSortableFields } from '../lib/querying/query-field-sort-helpers.js'
3
3
  import { compileAggregateRequest } from '../lib/querying/aggregate-query.js'
4
+ import { isSystemContext } from '../../../lib/system-context.js'
4
5
 
5
6
  /**
6
7
  * Group a filtered collection and compute measures for each group.
@@ -18,7 +19,8 @@ export default async function aggregateMethod ({ params, context, vars, helpers,
18
19
  schemaInfo: context.schemaInfo,
19
20
  scopeName,
20
21
  defaultLimit: vars.queryDefaultLimit,
21
- maxLimit: vars.queryMaxLimit
22
+ maxLimit: vars.queryMaxLimit,
23
+ system: isSystemContext(context)
22
24
  })
23
25
  const requestContracts = getRequestContracts({
24
26
  scopeName,
@@ -6,7 +6,6 @@
6
6
  * Schema, WriteContext
7
7
  * } from './lifecycle-types.js' */
8
8
  /** @import { StorageRow } from '../lib/storage/storage-types.js' */
9
- /** @import { DiagnosticWriter, EnhancedDiagnosticLogger } from '../../../lib/logger-types.js' */
10
9
  /** @import { RuntimeLogger } from '../../../types/runtime.js' */
11
10
  import { initializeResourceVersion } from '../lib/writing/resource-version.js'
12
11
  import {
@@ -16,7 +15,6 @@ import {
16
15
  } from '../../../lib/rest-api-errors.js'
17
16
  import { transformSimplifiedToJsonApi } from '../lib/querying-writing/simplified-helpers.js'
18
17
  import { normalizeRelationshipIdentifiers } from '../lib/querying-writing/resource-id-normalization.js'
19
- import { createEnhancedLogger } from '../../../lib/enhanced-logger.js'
20
18
  import { rejectRemovedOptions, resolveFormat, resolveReturning } from '../lib/querying-writing/response-options.js'
21
19
  import {
22
20
  getRequestContracts,
@@ -27,26 +25,11 @@ import { normalizeRecordAttributes } from '../lib/querying-writing/database-valu
27
25
  import { beginWriteTransaction, commitTransaction, getOperationDiagnosticContext, isTransactionOwner, rollbackAfterError, wrapUnexpectedError } from '../../../lib/error-context.js'
28
26
  import { filterVisibleIdentifiers } from '../lib/querying/include-query-helpers.js'
29
27
  import { findRelationshipDefinition } from '../lib/querying-writing/relationship-contracts.js'
30
- import { RELATIONSHIP_READ_BATCH_SIZE } from '../lib/querying-writing/knex-constants.js'
31
28
  import { getResourceFieldset, parseFieldset } from '../lib/querying-writing/field-utils.js'
32
29
 
33
30
  // A trusted connector callback, evaluated after normal write validation and authorization.
34
31
  export const writePrecondition = Symbol('writePrecondition')
35
32
 
36
- /**
37
- * Gets an enhanced logger instance with full error details and stack traces
38
- * @param {RuntimeLogger} log - The base logger instance
39
- * @param {{ schemaInfo?: LifecycleSchema }} context - Request context carrying compiled field visibility
40
- * @returns {EnhancedDiagnosticLogger & { error: DiagnosticWriter }} Enhanced logger instance
41
- */
42
- export const getEnhancedLogger = (log, context = {}) => {
43
- return createEnhancedLogger(log, {
44
- logFullErrors: true,
45
- includeStack: true,
46
- schemaInfo: context.schemaInfo
47
- })
48
- }
49
-
50
33
  /**
51
34
  * Resolves write input, response options and the managed transaction.
52
35
  * @param {Pick<LifecycleArguments, 'params' | 'vars' | 'scopes' | 'scopeName' | 'api' | 'helpers' | 'runHooks'> & { context: LifecycleContext<Resource | Resource[] | null, unknown> }} options
@@ -107,11 +90,18 @@ export const handleWriteMethodError = async (error, context, method, scopeName,
107
90
  await rollbackAfterError(error, context, context.transaction)
108
91
  const cleanupErrors = context.cleanupErrors || (context.cleanupErrors = [])
109
92
 
93
+ // The resource, the operation and error codes only: input, error messages and
94
+ // SQL can carry the values being written (payment state, ciphertext).
95
+ /** @param {unknown} value */
96
+ const codeOf = value => {
97
+ const { code, name } = /** @type {{ code?: unknown, name?: unknown }} */ (Object(value))
98
+ return typeof code === 'string' || typeof code === 'number' ? code : typeof name === 'string' ? name : null
99
+ }
110
100
  try {
111
- await getEnhancedLogger(log, context).logError(`Error in ${method} method`, error, {
101
+ await log.error(`Error in ${method} method`, {
112
102
  ...getOperationDiagnosticContext(context, { phase: 'writeFailure', scopeName, method: context.method ?? method.toLowerCase() }),
113
- inputRecord: context.inputRecord,
114
- cleanupErrors
103
+ code: codeOf(error),
104
+ cleanupErrors: cleanupErrors.map(({ phase, error }) => ({ phase, code: codeOf(error) }))
115
105
  })
116
106
  } catch (error) {
117
107
  cleanupErrors.push({ phase: 'logging', error })
@@ -212,6 +202,8 @@ export const validateCompleteReplacePayload = ({
212
202
  for (const [fieldName, fieldDef] of Object.entries(schemaStructure)) {
213
203
  if (!fieldDef) continue
214
204
  if (fieldName === idProperty) continue
205
+ // The library owns the lock version: every update advances it, whatever the input says.
206
+ if (fieldName === schemaInfo.lockVersionField) continue
215
207
  if (Object.hasOwn(belongsToUpdates, fieldName)) continue
216
208
  if (fieldDef.computed === true || fieldDef.virtual === true || fieldDef.retired === true) continue
217
209
  if (fieldDef.type === undefined) continue
@@ -292,7 +284,11 @@ export const validateResourceAttributesBeforeWrite = async ({
292
284
  ...(hasLogicalIdField && resourceId !== undefined ? { id: resourceId } : {})
293
285
  }
294
286
 
295
- // Physical foreign keys may enter storage only through relationship planning.
287
+ // Physical foreign keys, and the type and id fields behind a polymorphic
288
+ // belongs-to, may enter storage only through relationship planning.
289
+ /** @type {Map<string, string>} */
290
+ const polymorphicFields = new Map(Object.entries(context.schemaInfo.schemaRelationships || {}).flatMap(([name, relationship]) =>
291
+ relationship.belongsToPolymorphic ? [[relationship.belongsToPolymorphic.typeField, name], [relationship.belongsToPolymorphic.idField, name]] : []))
296
292
  /** @type {StorageRow} */
297
293
  const attributesForValidation = { ...attributesToValidate }
298
294
  const foreignKeyViolations = []
@@ -306,13 +302,14 @@ export const validateResourceAttributesBeforeWrite = async ({
306
302
  message: `Field '${field}' is retired and accepts no new values`
307
303
  })
308
304
  }
309
- if (!definition?.belongsTo || !definition.as || Object.hasOwn(belongsToUpdates, field)) continue
305
+ const relationshipName = definition?.belongsTo && definition.as ? definition.as : polymorphicFields.get(field)
306
+ if (!relationshipName || Object.hasOwn(belongsToUpdates, field)) continue
310
307
  delete attributesForValidation[field]
311
308
  if (value === undefined) continue
312
309
  foreignKeyViolations.push({
313
310
  field: `data.attributes.${field}`,
314
311
  rule: 'foreign_key_in_attributes',
315
- message: `Foreign key field '${field}' should not be in attributes. Use 'data.relationships.${definition.as}' instead.`
312
+ message: `Foreign key field '${field}' should not be in attributes. Use 'data.relationships.${relationshipName}' instead.`
316
313
  })
317
314
  }
318
315
  const [firstRetired] = retiredViolations
@@ -387,17 +384,25 @@ export const validateResourceAttributesBeforeWrite = async ({
387
384
  */
388
385
  export const validateRelationshipAccess = async (context, inputRecord, helpers, api) => {
389
386
  if (!inputRecord?.data?.relationships) return
390
-
391
- // Targets of every relationship, so each type is read once per record.
392
387
  const unique = new Map()
393
- for (const [relName, relData] of Object.entries(inputRecord.data.relationships)) {
388
+ collectRelationshipTargets(inputRecord, api, unique)
389
+ await validateRelationshipTargets(context, [...unique.values()], helpers, api)
390
+ }
391
+
392
+ /**
393
+ * Normalizes a document's relationship identifiers and adds each target to
394
+ * `unique`, so a batch of documents reads every related type once.
395
+ * @param {InputDocument} inputRecord
396
+ * @param {LifecycleApi} api
397
+ * @param {Map<string, Identifier>} unique
398
+ */
399
+ export const collectRelationshipTargets = (inputRecord, api, unique) => {
400
+ const relationships = /** @type {NonNullable<InputDocument['data']['relationships']>} */ (inputRecord?.data?.relationships || {})
401
+ for (const [relName, relData] of Object.entries(relationships)) {
394
402
  if (!relData?.data) continue
395
403
 
396
404
  const normalizedRelationshipData = /** @type {Identifier | Identifier[]} */ (normalizeRelationshipIdentifiers(relData.data, { api }))
397
- inputRecord.data.relationships[relName] = {
398
- ...relData,
399
- data: normalizedRelationshipData
400
- }
405
+ relationships[relName] = { ...relData, data: normalizedRelationshipData }
401
406
 
402
407
  // Handle both single and array relationships
403
408
  const relatedItems = Array.isArray(normalizedRelationshipData)
@@ -406,60 +411,69 @@ export const validateRelationshipAccess = async (context, inputRecord, helpers,
406
411
 
407
412
  for (const item of relatedItems) unique.set(JSON.stringify([item.type, item.id]), item)
408
413
  }
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])
414
+ }
415
+
416
+ /**
417
+ * Checks that every target is visible to the caller, with one scoped read per
418
+ * related type, however many targets there are.
419
+ * @param {ResourceContext} context
420
+ * @param {Identifier[]} targets
421
+ * @param {LifecycleHelpers} helpers
422
+ * @param {LifecycleApi} api
423
+ */
424
+ export const validateRelationshipTargets = async (context, targets, helpers, api) => {
425
+ /** @type {Map<string, Identifier[]>} */
426
+ const byType = new Map()
427
+ for (const item of targets) {
428
+ const items = byType.get(item.type)
429
+ if (items) items.push(item)
430
+ else byType.set(item.type, [item])
431
+ }
432
+ const lookups = new Map()
433
+ for (const [type, items] of byType) {
434
+ const relatedScope = api.resources[type]
435
+ if (!relatedScope) throw new Error(`Unknown resource type: ${type}`)
436
+ const getContext = {
437
+ ...context,
438
+ id: undefined,
439
+ minimalRecord: undefined,
440
+ schemaInfo: relatedScope.vars.schemaInfo,
441
+ scopeName: type,
442
+ queryParams: {},
443
+ method: 'get',
444
+ isUpdate: false
418
445
  }
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
432
- }
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'
442
- }
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])) })
446
+ const lookup = {
447
+ scopeName: type,
448
+ context: getContext,
449
+ /** @param {import('../lib/storage/storage-types.js').QueryFilteringState} filterParams */
450
+ applyQueryFilters: filterParams => relatedScope.applyQueryFilters({
451
+ ...filterParams, schemaInfo: getContext.schemaInfo
452
+ }, getContext),
453
+ filters: {},
454
+ queryPurpose: 'relationship-validation'
445
455
  }
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
- )
457
- }
458
- await relatedScope.checkPermissions({
459
- method: 'get',
460
- originalContext: { ...lookup.context, id: item.id, minimalRecord: record }
461
- })
456
+ const records = new Map()
457
+ for (const record of await helpers.dataGetMinimal({ ...lookup, ids: items.map(item => item.id) })) records.set(String(record.id), record)
458
+ lookups.set(type, { relatedScope, lookup, records })
459
+ }
460
+ // In the order given, so the first missing or forbidden target is the one reported.
461
+ for (const item of targets) {
462
+ const { relatedScope, lookup, records } = lookups.get(item.type)
463
+ // A database collation can equate IDs with different spellings.
464
+ const record = records.get(String(item.id)) || await helpers.dataGetMinimal({
465
+ ...lookup, context: { ...lookup.context, id: item.id }
466
+ })
467
+ if (!record) {
468
+ throw new RestApiResourceError(
469
+ `Cannot create relationship to non-existent ${item.type} with id ${item.id}`,
470
+ { subtype: 'not_found', resourceType: item.type, resourceId: item.id }
471
+ )
462
472
  }
473
+ await relatedScope.checkPermissions({
474
+ method: 'get',
475
+ originalContext: { ...lookup.context, id: item.id, minimalRecord: record }
476
+ })
463
477
  }
464
478
  }
465
479
 
@@ -637,15 +651,61 @@ export const filterToOneResponseLinkage = async (context, scopes) => {
637
651
  if ((definition?.belongsTo || definition?.belongsToPolymorphic) && relationship.data) linkages.push(relationship)
638
652
  }
639
653
  }
640
- if (linkages.length === 0) return
654
+ // A target the response already includes is no secret, so it needs no read.
655
+ const key = (/** @type {Identifier} */ { type, id }) => `${type}:${id}`
656
+ const included = new Set((document?.included || []).map(key))
657
+ // The links collected above are to-one links with a target.
658
+ const unchecked = linkages.filter(relationship => !included.has(key(/** @type {Identifier} */ (relationship.data))))
659
+ if (unchecked.length === 0) return
641
660
  const visible = new Set(await filterVisibleIdentifiers({
642
- identifiers: linkages.map(relationship => relationship.data), scopes, knex: context.db, context
661
+ identifiers: unchecked.map(relationship => relationship.data), scopes, knex: context.db, context
643
662
  }))
644
- for (const relationship of linkages) {
663
+ for (const relationship of unchecked) {
645
664
  if (!visible.has(relationship.data)) relationship.data = null
646
665
  }
647
666
  }
648
667
 
668
+ /**
669
+ * A read shows a link whose target its reader cannot see as null (above), so a
670
+ * reader that writes the record back sends that null for a link it never saw.
671
+ * The write keeps such a link: a belongs-to that `updates` sets to null, whose
672
+ * stored target exists but is hidden from the writer, is dropped from
673
+ * `updates` and from the input, and stays as stored. A link to
674
+ * a visible target, or to one that no longer exists, is cleared as asked.
675
+ * @param {{ context: ResourceContext, scopes: Record<string, LifecycleResource>, schemaInfo: any, rows: Array<{ updates: Record<string, unknown>, stored: any, inputRecord?: any }> }} request
676
+ */
677
+ export const keepHiddenLinks = async ({ context, scopes, schemaInfo, rows }) => {
678
+ const links = [
679
+ ...Object.entries(schemaInfo.schemaStructure)
680
+ .filter(([, field]) => field.belongsTo && field.as)
681
+ .map(([name, field]) => ({ name: field.as, fields: [name] })),
682
+ ...Object.entries(schemaInfo.schemaRelationships || {})
683
+ .filter(([, relationship]) => relationship.belongsToPolymorphic)
684
+ .map(([name, { belongsToPolymorphic: { typeField, idField } }]) => ({ name, fields: [typeField, idField] }))
685
+ ]
686
+ const cleared = []
687
+ for (const row of rows) {
688
+ for (const link of links) {
689
+ const data = row.stored?.relationships?.[link.name]?.data
690
+ if (data && link.fields.every(field => Object.hasOwn(row.updates, field) && row.updates[field] === null)) cleared.push({ row, link, data })
691
+ }
692
+ }
693
+ if (!cleared.length) return
694
+ const visible = new Set(await filterVisibleIdentifiers({ identifiers: cleared.map(entry => entry.data), scopes, knex: context.db, context }))
695
+ const hidden = cleared.filter(entry => !visible.has(entry.data))
696
+ if (!hidden.length) return
697
+ const existing = new Set(await filterVisibleIdentifiers({ identifiers: hidden.map(entry => entry.data), scopes, knex: context.db, context, scoped: false }))
698
+ for (const { row, link, data } of hidden) {
699
+ if (!existing.has(data)) continue
700
+ for (const field of link.fields) {
701
+ delete row.updates[field]
702
+ // Validation has merged the belongs-to updates into the input attributes.
703
+ delete row.inputRecord?.data?.attributes?.[field]
704
+ }
705
+ delete row.inputRecord?.data?.relationships?.[link.name]
706
+ }
707
+ }
708
+
649
709
  /** @param {ResourceContext} context @param {Record<string, LifecycleResource>} scopes @param {string[]} [resourceTypes] */
650
710
  export const validateRequestedIncludes = (context, scopes, resourceTypes = [context.scopeName]) => {
651
711
  const paths = context.queryParams?.include || []