json-rest-api 3.0.0-rc.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +2 -2
  2. package/docs/API.md +60 -27
  3. package/docs/GUIDE/07-polymorphic-relationships.md +4 -2
  4. package/docs/GUIDE/09-pagination-and-sorting.md +4 -2
  5. package/docs/GUIDE/13-hooks-and-lifecycle.md +6 -1
  6. package/docs/GUIDE/16-autofiltering.md +1 -1
  7. package/docs/GUIDE/17-row-policies.md +8 -6
  8. package/docs/GUIDE/21-schema-and-migrations.md +7 -1
  9. package/docs/GUIDE/27-many-row-writes.md +201 -28
  10. package/docs/GUIDE/30-backend-capabilities.md +16 -2
  11. package/docs/GUIDE/33-migrating-to-v2.md +2 -2
  12. package/docs/GUIDE/34-scoped-reads-and-locks.md +165 -26
  13. package/docs/GUIDE/35-migrating-to-v3.md +141 -10
  14. package/lib/rest-api-errors.js +6 -5
  15. package/package.json +2 -2
  16. package/plugins/core/connectors/lib/transport-http-helpers.js +31 -3
  17. package/plugins/core/file-handling-plugin.js +2 -1
  18. package/plugins/core/lib/anyapi/anyapi-registry.js +3 -2
  19. package/plugins/core/lib/anyapi/canonical-link-store.js +5 -2
  20. package/plugins/core/lib/anyapi/canonical-relationship-reader.js +56 -60
  21. package/plugins/core/lib/dbIntrospection.js +106 -6
  22. package/plugins/core/lib/querying/aggregate-query.js +12 -6
  23. package/plugins/core/lib/querying/include-query-helpers.js +11 -13
  24. package/plugins/core/lib/querying/include-to-one.js +54 -59
  25. package/plugins/core/lib/querying/knex-pagination-helpers.js +9 -2
  26. package/plugins/core/lib/querying/query-constraint.d.ts +11 -0
  27. package/plugins/core/lib/querying/query-constraint.js +45 -0
  28. package/plugins/core/lib/querying/read-only-query.js +50 -13
  29. package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
  30. package/plugins/core/lib/storage/canonical-data-helpers.js +72 -41
  31. package/plugins/core/lib/storage/many-row-data-helpers.js +409 -124
  32. package/plugins/core/lib/storage/ordinary-data-helpers.js +67 -81
  33. package/plugins/core/lib/storage/single-row-writes.js +71 -0
  34. package/plugins/core/lib/storage/storage-adapter.js +18 -8
  35. package/plugins/core/lib/storage/storage-mapping.js +4 -1
  36. package/plugins/core/lib/storage/storage-types.d.ts +12 -1
  37. package/plugins/core/lib/writing/many-to-many-manipulations.js +5 -2
  38. package/plugins/core/lib/writing/relationship-processor.js +33 -37
  39. package/plugins/core/rest-api-autofilter-plugin.js +30 -8
  40. package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
  41. package/plugins/core/rest-api-plugin-methods/common.js +117 -77
  42. package/plugins/core/rest-api-plugin-methods/delete-many.js +54 -28
  43. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +22 -0
  44. package/plugins/core/rest-api-plugin-methods/get.js +48 -42
  45. package/plugins/core/rest-api-plugin-methods/many-row-common.js +51 -8
  46. package/plugins/core/rest-api-plugin-methods/patch-many.js +81 -34
  47. package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
  48. package/plugins/core/rest-api-plugin-methods/post-many.js +149 -21
  49. package/plugins/core/rest-api-plugin-methods/put.js +5 -1
  50. package/plugins/core/rest-api-plugin-methods/scoped-reads.js +72 -27
  51. package/plugins/core/rest-api-plugin.js +2 -1
  52. package/plugins/core/rest-api-positioning-plugin.js +1 -1
  53. package/types/data-access-methods.d.ts +32 -11
  54. package/types/errors.d.ts +5 -4
  55. package/types/hook-context.d.ts +2 -0
@@ -2,6 +2,7 @@ import { RestApiResourceError, RestApiValidationError } from '../../lib/rest-api
2
2
  import { assertGenuineSystemFlag, isSystemContext } from '../../lib/system-context.js'
3
3
  import { snapshotResourceConfiguration } from './lib/querying-writing/schema-helpers.js'
4
4
  import { createStorageAdapterUtilities } from './lib/querying/storage-adapter-utils.js'
5
+ import { ANY_OWNER, recordWriteCondition } from './lib/querying/query-constraint.js'
5
6
 
6
7
  const PUBLIC_PRESET_NAME = 'public'
7
8
  // Queries that write or lock the rows they select: shared rows are never theirs.
@@ -286,6 +287,13 @@ function refuseSharedCreate ({ filter, resolvedValue, scopeName }) {
286
287
  })
287
288
  }
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
+
289
297
  function sharedFieldValue (record, filter) {
290
298
  if (filter.relationshipName) {
291
299
  const linkage = record?.relationships?.[filter.relationshipName]?.data
@@ -324,6 +332,7 @@ async function enforceScopedInput ({ compiledConfig, context, scopeName, injectM
324
332
  injectMissing
325
333
  })
326
334
  }
335
+ if (injectMissing) refuseSharedResult({ compiledConfig, record: context.inputRecord?.data, scopeName })
327
336
  }
328
337
 
329
338
  export const AutoFilterPlugin = {
@@ -386,9 +395,18 @@ export const AutoFilterPlugin = {
386
395
  log
387
396
  })
388
397
 
389
- if (resolvedValue === undefined) continue
390
-
391
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
+ }
392
410
  const translatedValue = adapterUtils.translateFilterValue(scopeName, filter.field, resolvedValue)
393
411
 
394
412
  if (filter.shared) {
@@ -396,15 +414,15 @@ export const AutoFilterPlugin = {
396
414
  if (translatedValue === null) {
397
415
  if (writes) query.whereRaw('1 = 0')
398
416
  else query.whereNull(columnRef)
399
- } else if (writes) {
400
- query.where(columnRef, translatedValue)
401
417
  } else {
402
- query.where(builder => builder.where(columnRef, translatedValue).orWhereNull(columnRef))
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)
403
421
  }
404
- } else if (translatedValue === null) {
405
- query.whereNull(columnRef)
406
422
  } else {
407
- query.where(columnRef, translatedValue)
423
+ if (translatedValue === null) query.whereNull(columnRef)
424
+ else query.where(columnRef, translatedValue)
425
+ recordWriteCondition(context, scopeName, filter.field, translatedValue)
408
426
  }
409
427
  }
410
428
  })
@@ -468,6 +486,7 @@ export const AutoFilterPlugin = {
468
486
  applyResolvedInputValue({ context: { inputRecord }, filter, resolvedValue, injectMissing: true })
469
487
  }
470
488
  }
489
+ for (const inputRecord of context.inputRecords) refuseSharedResult({ compiledConfig, record: inputRecord?.data, scopeName })
471
490
  })
472
491
 
473
492
  // Shared rows are readable by every owner but writable only in a system context.
@@ -486,6 +505,9 @@ export const AutoFilterPlugin = {
486
505
  addHook('beforeDataCallPatch', 'autofilter-shared-row-patch', { batch: true }, refuseSharedRowWrite)
487
506
  addHook('beforeDataCallPut', 'autofilter-shared-row-put', { batch: true }, refuseSharedRowWrite)
488
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)
489
511
 
490
512
  api.autofilter = {
491
513
  getConfig: () => ({
@@ -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 })
@@ -294,7 +284,11 @@ export const validateResourceAttributesBeforeWrite = async ({
294
284
  ...(hasLogicalIdField && resourceId !== undefined ? { id: resourceId } : {})
295
285
  }
296
286
 
297
- // 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]] : []))
298
292
  /** @type {StorageRow} */
299
293
  const attributesForValidation = { ...attributesToValidate }
300
294
  const foreignKeyViolations = []
@@ -308,13 +302,14 @@ export const validateResourceAttributesBeforeWrite = async ({
308
302
  message: `Field '${field}' is retired and accepts no new values`
309
303
  })
310
304
  }
311
- 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
312
307
  delete attributesForValidation[field]
313
308
  if (value === undefined) continue
314
309
  foreignKeyViolations.push({
315
310
  field: `data.attributes.${field}`,
316
311
  rule: 'foreign_key_in_attributes',
317
- 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.`
318
313
  })
319
314
  }
320
315
  const [firstRetired] = retiredViolations
@@ -420,66 +415,65 @@ export const collectRelationshipTargets = (inputRecord, api, unique) => {
420
415
 
421
416
  /**
422
417
  * Checks that every target is visible to the caller, with one scoped read per
423
- * related type and batch.
418
+ * related type, however many targets there are.
424
419
  * @param {ResourceContext} context
425
420
  * @param {Identifier[]} targets
426
421
  * @param {LifecycleHelpers} helpers
427
422
  * @param {LifecycleApi} api
428
423
  */
429
424
  export const validateRelationshipTargets = async (context, targets, helpers, api) => {
430
- for (let offset = 0; offset < targets.length; offset += RELATIONSHIP_READ_BATCH_SIZE) {
431
- const batch = targets.slice(offset, offset + RELATIONSHIP_READ_BATCH_SIZE)
432
- /** @type {Map<string, Identifier[]>} */
433
- const byType = new Map()
434
- for (const item of batch) {
435
- const items = byType.get(item.type)
436
- if (items) items.push(item)
437
- else byType.set(item.type, [item])
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
438
445
  }
439
- const lookups = new Map()
440
- for (const [type, items] of byType) {
441
- const relatedScope = api.resources[type]
442
- if (!relatedScope) throw new Error(`Unknown resource type: ${type}`)
443
- const getContext = {
444
- ...context,
445
- id: undefined,
446
- minimalRecord: undefined,
447
- schemaInfo: relatedScope.vars.schemaInfo,
448
- scopeName: type,
449
- queryParams: {},
450
- method: 'get',
451
- isUpdate: false
452
- }
453
- const lookup = {
454
- scopeName: type,
455
- context: getContext,
456
- /** @param {import('../lib/storage/storage-types.js').QueryFilteringState} filterParams */
457
- applyQueryFilters: filterParams => relatedScope.applyQueryFilters({
458
- ...filterParams, schemaInfo: getContext.schemaInfo
459
- }, getContext),
460
- filters: {},
461
- queryPurpose: 'relationship-validation'
462
- }
463
- const records = await helpers.dataGetMinimal({ ...lookup, ids: items.map(item => item.id) })
464
- 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'
465
455
  }
466
- for (const item of batch) {
467
- const { relatedScope, lookup, records } = lookups.get(item.type)
468
- // A database collation can equate IDs with different spellings.
469
- const record = records.get(String(item.id)) || await helpers.dataGetMinimal({
470
- ...lookup, context: { ...lookup.context, id: item.id }
471
- })
472
- if (!record) {
473
- throw new RestApiResourceError(
474
- `Cannot create relationship to non-existent ${item.type} with id ${item.id}`,
475
- { subtype: 'not_found', resourceType: item.type, resourceId: item.id }
476
- )
477
- }
478
- await relatedScope.checkPermissions({
479
- method: 'get',
480
- originalContext: { ...lookup.context, id: item.id, minimalRecord: record }
481
- })
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
+ )
482
472
  }
473
+ await relatedScope.checkPermissions({
474
+ method: 'get',
475
+ originalContext: { ...lookup.context, id: item.id, minimalRecord: record }
476
+ })
483
477
  }
484
478
  }
485
479
 
@@ -657,15 +651,61 @@ export const filterToOneResponseLinkage = async (context, scopes) => {
657
651
  if ((definition?.belongsTo || definition?.belongsToPolymorphic) && relationship.data) linkages.push(relationship)
658
652
  }
659
653
  }
660
- 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
661
660
  const visible = new Set(await filterVisibleIdentifiers({
662
- identifiers: linkages.map(relationship => relationship.data), scopes, knex: context.db, context
661
+ identifiers: unchecked.map(relationship => relationship.data), scopes, knex: context.db, context
663
662
  }))
664
- for (const relationship of linkages) {
663
+ for (const relationship of unchecked) {
665
664
  if (!visible.has(relationship.data)) relationship.data = null
666
665
  }
667
666
  }
668
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
+
669
709
  /** @param {ResourceContext} context @param {Record<string, LifecycleResource>} scopes @param {string[]} [resourceTypes] */
670
710
  export const validateRequestedIncludes = (context, scopes, resourceTypes = [context.scopeName]) => {
671
711
  const paths = context.queryParams?.include || []
@@ -1,17 +1,21 @@
1
1
  // Delete many records by id: one scoped read of the ids the caller may delete
2
- // per chunk, then one DELETE per chunk and the pivot rows of the deleted records.
2
+ // per chunk (or, with missing: 'skip', the scope inside the DELETE), then one
3
+ // DELETE per chunk and the pivot rows of the deleted records. deleteWhere
4
+ // deletes the rows its selection finds and answers with those it deleted.
3
5
  import { beginWriteTransaction } from '../../../lib/error-context.js'
4
- import { RestApiValidationError } from '../../../lib/rest-api-errors.js'
5
- import { rejectRemovedOptions, resolveFormat, resolveReturning } from '../lib/querying-writing/response-options.js'
6
+ import { RestApiRecordsNotFoundError, RestApiValidationError } from '../../../lib/rest-api-errors.js'
7
+ import { rejectRemovedOptions, resolveFormat } from '../lib/querying-writing/response-options.js'
6
8
  import { requireExistingResourceId } from '../lib/querying-writing/resource-id-normalization.js'
7
- import { chunk } from '../lib/storage/many-row-data-helpers.js'
9
+ import { chunk, databaseFamily } from '../lib/storage/many-row-data-helpers.js'
8
10
  import { commitOwnedTransaction, handleWriteMethodError } from './common.js'
9
11
  import {
10
12
  keepVisible,
11
13
  refuseDuplicateIds,
12
14
  refuseUnsupportedResource,
13
15
  rejectBulkOptions,
16
+ requireWhereScope,
14
17
  resolveChunkSize,
18
+ resolveManyRowReturning,
15
19
  resolveMissing,
16
20
  unsupported,
17
21
  validateFilters
@@ -26,46 +30,67 @@ function setup ({ params, context, scopes, scopeName }, method) {
26
30
  context.schemaInfo = scopes[scopeName].vars.schemaInfo
27
31
  // Deletes return no records; a format is still checked like a single delete's.
28
32
  if (params.format !== undefined) resolveFormat(params.format)
29
- context.returning = resolveReturning(params.returning, 'none')
30
- if (context.returning === 'full') {
31
- throw new RestApiValidationError("A delete returns 'none' or 'minimal'", { fields: ['returning'] })
32
- }
33
+ context.returning = resolveManyRowReturning(params.returning, { deletes: true })
33
34
  context.params = params
34
35
  }
35
36
 
36
- async function runDeleteMany (args, { operation, ids, started = false }) {
37
- const { params, context, helpers, scope, runHooks, perRecordHooks, scopeName, api } = args
37
+ /**
38
+ * @param {any} args
39
+ * @param {{ operation: string, ids: unknown[], started?: boolean, selection?: { filters?: Record<string, unknown> } }} batch
40
+ */
41
+ async function runDeleteMany (args, { operation, ids, started = false, selection }) {
42
+ const { params, context, helpers, scope, runHooks, hasHooks, perRecordHooks, scopeName, api } = args
38
43
  const storageAdapter = helpers.getStorageAdapter(scopeName)
39
44
  const chunkSize = resolveChunkSize(params.chunkSize)
40
- const missing = resolveMissing(params.missing)
45
+ const missing = selection ? 'skip' : resolveMissing(params.missing)
41
46
  refuseUnsupportedResource({ api, helpers, perRecordHooks, scopeName, schemaInfo: context.schemaInfo, operation, kind: 'delete', storageHelper: 'dataDeleteByIds' })
42
47
  refuseDuplicateIds(ids, storageAdapter, 'ids')
43
48
 
44
49
  if (!started) await beginWriteTransaction(context, params.transaction, helpers.newTransaction, runHooks)
45
50
  context.db = context.transaction
46
51
 
47
- const visible = new Map()
48
- for (const part of chunk(ids, VISIBLE_READ_CHUNK)) {
49
- const records = await helpers.dataGetMinimal({ scopeName, context, runHooks, ids: part, filters: {}, queryPurpose: 'bulk-write' })
50
- for (const record of records) visible.set(String(storageAdapter.translateFilterValue('id', record.id)), record)
51
- }
52
- const kept = keepVisible({ requested: ids, visible, missing, scopeName, storageAdapter })
53
- // bulkIndex is the id's position in the request.
54
- const positions = new Map(ids.map((id, index) => [String(storageAdapter.translateFilterValue('id', id)), index]))
55
- for (const id of kept) {
56
- const key = String(storageAdapter.translateFilterValue('id', id))
57
- const minimalRecord = visible.get(key)
58
- const bulkIndex = positions.get(key)
59
- await scope.checkPermissions({ method: 'delete', originalContext: { ...context, method: 'delete', id, minimalRecord, originalMinimalRecord: minimalRecord, bulkIndex } })
52
+ // With missing: 'skip' and no hook that needs the rows first, the DELETE
53
+ // decides which rows the caller may delete, carrying the scope (see patchMany,
54
+ // also for MySQL and selections).
55
+ const readFirst = !(missing === 'skip' && !hasHooks('checkPermissions') && !hasHooks('beforeDataCallDeleteMany') &&
56
+ (selection || databaseFamily(context.db) !== 'mysql'))
57
+ const scoped = Boolean(selection) || !readFirst
58
+ let kept = ids
59
+ if (readFirst) {
60
+ const visible = new Map()
61
+ for (const part of chunk(ids, VISIBLE_READ_CHUNK)) {
62
+ const records = await helpers.dataGetMinimal({ scopeName, context, runHooks, ids: part, filters: {}, queryPurpose: 'bulk-write' })
63
+ for (const record of records) visible.set(String(storageAdapter.translateFilterValue('id', record.id)), record)
64
+ }
65
+ kept = keepVisible({ requested: ids, visible, missing, scopeName, storageAdapter })
66
+ // bulkIndex is the id's position in the request.
67
+ const positions = new Map(ids.map((id, index) => [String(storageAdapter.translateFilterValue('id', id)), index]))
68
+ for (const id of kept) {
69
+ const key = String(storageAdapter.translateFilterValue('id', id))
70
+ const minimalRecord = visible.get(key)
71
+ const bulkIndex = positions.get(key)
72
+ await scope.checkPermissions({ method: 'delete', originalContext: { ...context, method: 'delete', id, minimalRecord, originalMinimalRecord: minimalRecord, bulkIndex } })
73
+ }
60
74
  }
61
75
 
62
76
  context.ids = kept.map(String)
63
77
  await runHooks('beforeDataCallDeleteMany')
64
- if (kept.length) await helpers.dataDeleteByIds({ scopeName, context, ids: kept, chunkSize })
78
+ const deleted = new Set((kept.length
79
+ ? await helpers.dataDeleteByIds({ scopeName, context, runHooks, ids: kept, chunkSize, scoped, filters: selection?.filters })
80
+ : []).map(String))
81
+ // A row that left the caller's scope after the visibility read is not deleted.
82
+ if (deleted.size < kept.length && missing === 'throw') throw new RestApiRecordsNotFoundError({ resourceType: scopeName, missing: kept.length - deleted.size })
83
+ context.ids = kept.map(id => String(storageAdapter.translateFilterValue('id', id))).filter(id => deleted.has(id))
65
84
  await runHooks('afterDataCallDeleteMany')
66
85
 
67
86
  await commitOwnedTransaction(context)
68
- return context.returning === 'none' ? undefined : { data: context.ids.map(id => ({ type: scopeName, id })) }
87
+ return deleteResponse(context, scopeName, context.ids)
88
+ }
89
+
90
+ /** @param {any} context @param {string} scopeName @param {string[]} ids */
91
+ function deleteResponse (context, scopeName, ids) {
92
+ if (context.returning === 'count') return { meta: { count: ids.length } }
93
+ return context.returning === 'none' ? undefined : { data: ids.map(id => ({ type: scopeName, id })) }
69
94
  }
70
95
 
71
96
  /** @param {any} args */
@@ -92,14 +117,15 @@ export async function deleteWhereMethod (args) {
92
117
  throw unsupported(scopeName, 'deleteWhere', 'its storage plugin has no many-row support (AnyAPI storage takes single writes)')
93
118
  }
94
119
  const filters = validateFilters({ filters: params.filters, scopeName, schemaInfo: context.schemaInfo, vars })
120
+ requireWhereScope({ filters, all: params.all, missing: params.missing, operation: 'deleteWhere' })
95
121
  await beginWriteTransaction(context, params.transaction, helpers.newTransaction, args.runHooks)
96
122
  context.db = context.transaction
97
123
  const ids = await visibleIdsByFilters(args, filters)
98
124
  if (!ids.length) {
99
125
  await commitOwnedTransaction(context)
100
- return context.returning === 'none' ? undefined : { data: [] }
126
+ return deleteResponse(context, scopeName, [])
101
127
  }
102
- return await runDeleteMany(args, { operation: 'deleteWhere', ids, started: true })
128
+ return await runDeleteMany(args, { operation: 'deleteWhere', ids, started: true, selection: { filters } })
103
129
  } catch (error) {
104
130
  await handleWriteMethodError(error, context, 'DELETE_WHERE', scopeName, log)
105
131
  }
@@ -22,6 +22,27 @@ import { updateReverseRelationship } from '../lib/writing/reverse-relationship-m
22
22
  import { invalidateRemovedPivotVersions } from '../lib/writing/many-to-many-manipulations.js'
23
23
  import { getStorageColumn } from '../lib/storage/storage-mapping.js'
24
24
  import { RELATIONSHIP_WRITE_BATCH_SIZE } from '../lib/querying-writing/knex-constants.js'
25
+ import { filterVisibleIdentifiers } from '../lib/querying/include-query-helpers.js'
26
+ import { isSystemContext } from '../../../lib/system-context.js'
27
+
28
+ /**
29
+ * The identifiers a many-to-many removal may unlink. Targets that ownership
30
+ * filters or a row policy scope are read for visibility (one read per type and
31
+ * 1,000 ids), and links to targets the caller cannot see stay, as links to
32
+ * missing ones always did. Other targets, and every target in a system context,
33
+ * cost no read. (Reverse hasMany removals check each child's visibility themselves.)
34
+ * @param {WriteRelationshipContext} context
35
+ * @param {Identifier[]} identifiers
36
+ * @param {any} api
37
+ */
38
+ async function removableTargets (context, identifiers, api) {
39
+ if (isSystemContext(context)) return identifiers
40
+ const scoped = (/** @type {Identifier} */ { type }) => Boolean(api.resources[type]?.vars?.schemaInfo?.ownershipFields?.length || api.resources[type]?.vars?.rowPolicy)
41
+ const checked = identifiers.filter(scoped)
42
+ if (!checked.length) return identifiers
43
+ const visible = new Set(await filterVisibleIdentifiers({ identifiers: checked, scopes: api.resources, knex: context.db, context }))
44
+ return identifiers.filter(identifier => !scoped(identifier) || visible.has(identifier))
45
+ }
25
46
 
26
47
  /**
27
48
  * Remove the identifiers in params.relationshipData from a to-many relationship.
@@ -98,6 +119,7 @@ export default async function deleteRelationshipMethod ({ params, context: calle
98
119
 
99
120
  // Remove relationships
100
121
  if (relDef?.through) {
122
+ params.relationshipData = await removableTargets(context, params.relationshipData, api)
101
123
  if (api.anyapi?.links?.removeMany) {
102
124
  await api.anyapi.links.removeMany({
103
125
  context,