json-rest-api 3.0.0-rc.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 (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 +195 -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 +47 -79
  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 +8 -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 +47 -5
  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 +139 -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
@@ -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,
@@ -78,52 +78,58 @@ export default async function getMethod ({
78
78
 
79
79
  const identityContext = /** @type {IdentityContext} */ (context)
80
80
 
81
- // Fetch minimal record for authorization checks
82
- const minimalRecord = await helpers.dataGetMinimal({
83
- scopeName,
84
- context: identityContext,
85
- runHooks
81
+ const notFound = () => new RestApiResourceError('Resource not found', {
82
+ subtype: 'not_found',
83
+ resourceType: scopeName,
84
+ resourceId: context.id
86
85
  })
87
86
 
88
- if (!minimalRecord) {
89
- throw new RestApiResourceError(
90
- 'Resource not found',
91
- {
92
- subtype: 'not_found',
93
- resourceType: scopeName,
94
- resourceId: context.id
95
- }
96
- )
87
+ // The minimal record serves the permission checks and the hooks before the
88
+ // data call. A resource without them reads once: the scoped full read also
89
+ // answers whether the row exists. Any permission hook keeps both reads.
90
+ const readOnce = ['checkPermissions', 'checkDataPermissions', 'checkDataPermissionsGet', 'beforeData', 'beforeDataGet'].some(event => hasHooks(event))
91
+ ? undefined
92
+ : helpers.dataGetScoped
93
+
94
+ if (readOnce) {
95
+ try {
96
+ await validateRequestedFieldsets(context, scopes)
97
+ validateRequestedIncludes(context, scopes)
98
+ } catch (error) {
99
+ // A missing row answers 404 before a bad fieldset, as with two reads.
100
+ if (!(await helpers.dataGetMinimal({ scopeName, context: identityContext, runHooks }))) throw notFound()
101
+ throw error
102
+ }
103
+ context.record = await readOnce({ scopeName, context: identityContext, runHooks })
104
+ } else {
105
+ // Fetch minimal record for authorization checks
106
+ const minimalRecord = await helpers.dataGetMinimal({
107
+ scopeName,
108
+ context: identityContext,
109
+ runHooks
110
+ })
111
+ if (!minimalRecord) throw notFound()
112
+
113
+ context.minimalRecord = minimalRecord
114
+
115
+ await scope.checkPermissions({
116
+ method: 'get',
117
+ originalContext: context,
118
+ })
119
+
120
+ await validateRequestedFieldsets(context, scopes)
121
+ validateRequestedIncludes(context, scopes)
122
+ await runHooks('beforeData')
123
+ await runHooks('beforeDataGet')
124
+
125
+ context.record = await helpers.dataGet({
126
+ scopeName,
127
+ context: identityContext,
128
+ runHooks
129
+ })
97
130
  }
98
131
 
99
- context.minimalRecord = minimalRecord
100
-
101
- await scope.checkPermissions({
102
- method: 'get',
103
- originalContext: context,
104
- })
105
-
106
- await validateRequestedFieldsets(context, scopes)
107
- validateRequestedIncludes(context, scopes)
108
- await runHooks('beforeData')
109
- await runHooks('beforeDataGet')
110
-
111
- context.record = await helpers.dataGet({
112
- scopeName,
113
- context: identityContext,
114
- runHooks
115
- })
116
-
117
- if (!context.record || !context.record.data) {
118
- throw new RestApiResourceError(
119
- 'Resource not found',
120
- {
121
- subtype: 'not_found',
122
- resourceType: scopeName,
123
- resourceId: context.id
124
- }
125
- )
126
- }
132
+ if (!context.record || !context.record.data) throw notFound()
127
133
 
128
134
  context.record = normalizeRecordAttributes(context.record, scopes)
129
135
 
@@ -70,8 +70,13 @@ export function refuseUnsupportedResource ({ api, helpers, perRecordHooks, scope
70
70
  }
71
71
  const hooks = perRecordHooks(PER_RECORD_EVENTS[kind])
72
72
  if (hooks.length) {
73
- const names = hooks.map(hook => `${hook.event}:${hook.name}`).join(', ')
74
- throw unsupported(scopeName, operation, `per-record hooks without a batch form run for it (${names}); register them with { batch: true } and a batch hook`)
73
+ // The hook names stay in the error details, which HTTP responses leave out.
74
+ throw new RestApiBulkWriteUnsupportedError({
75
+ resourceType: scopeName,
76
+ operation,
77
+ reason: 'per-record hooks without a batch form run for it; register them with { batch: true } and a batch hook',
78
+ hooks: hooks.map(hook => `${hook.event}:${hook.name}`)
79
+ })
75
80
  }
76
81
  }
77
82
 
@@ -84,6 +89,20 @@ export function resolveChunkSize (value) {
84
89
  return /** @type {number} */ (value)
85
90
  }
86
91
 
92
+ /**
93
+ * What a many-row write returns: nothing (the default), identifiers, the
94
+ * records (not for deletes) or `{ meta: { count } }`, the number of rows it
95
+ * wrote.
96
+ * @param {unknown} value
97
+ * @param {{ deletes?: boolean }} [options]
98
+ * @returns {'none' | 'minimal' | 'full' | 'count'}
99
+ */
100
+ export function resolveManyRowReturning (value, { deletes = false } = {}) {
101
+ const returning = value === undefined ? 'none' : value
102
+ if (returning === 'none' || returning === 'minimal' || returning === 'count' || (returning === 'full' && !deletes)) return returning
103
+ throw new RestApiValidationError(deletes ? "A delete returns 'none', 'minimal' or 'count'" : "returning must be 'none', 'minimal', 'full' or 'count'", { fields: ['returning'] })
104
+ }
105
+
87
106
  /** @param {unknown} value */
88
107
  export function resolveMissing (value) {
89
108
  if (value === undefined) return 'throw'
@@ -129,7 +148,13 @@ export function toDocument ({ record, inputKey, scopeName, schemaInfo, allowRaw
129
148
  let attributes = inputKey === 'data' ? record : record.attributes
130
149
  if (attributes && typeof attributes === 'object') {
131
150
  for (const [field, value] of Object.entries(attributes)) {
132
- if (!isRawValue(value)) continue
151
+ if (!isRawValue(value)) {
152
+ // Knex would run any object carrying the flag as SQL; JSON input can carry the flag.
153
+ if (value && typeof value === 'object' && Object.hasOwn(value, 'isRawInstance')) {
154
+ throw new RestApiValidationError(`Field '${field}' holds an object that is not a Knex raw value`, { fields: [field] })
155
+ }
156
+ continue
157
+ }
133
158
  if (!allowRaw) throw new RestApiValidationError(`Field '${field}' cannot take a raw SQL value here`, { fields: [field] })
134
159
  raw[field] = value
135
160
  }
@@ -191,6 +216,22 @@ export function requireLockTransaction ({ transaction, scopeName, operation }) {
191
216
  }
192
217
  }
193
218
 
219
+ /**
220
+ * patchWhere and deleteWhere need filters, or `all: true` to act on every row
221
+ * the caller can see (in a system context, the whole table).
222
+ * @param {{ filters: Record<string, unknown> | undefined, all: unknown, missing?: unknown, operation: string }} request
223
+ */
224
+ export function requireWhereScope ({ filters, all, missing, operation }) {
225
+ // A selection writes the rows it finds and answers with those; no row is missing.
226
+ if (missing !== undefined) throw new RestApiValidationError(`${operation} writes the rows its selection finds; missing applies to lists of ids`, { fields: ['missing'] })
227
+ if (all !== undefined && all !== true) throw new RestApiValidationError('all must be true when given', { fields: ['all'] })
228
+ const filtered = Boolean(filters && Object.keys(filters).length)
229
+ if (all && filtered) throw new RestApiValidationError(`${operation} takes filters or all: true, not both`, { fields: ['all'] })
230
+ if (!all && !filtered) {
231
+ throw new RestApiValidationError(`${operation} needs filters, or all: true to act on every row the caller can see`, { fields: ['filters'] })
232
+ }
233
+ }
234
+
194
235
  /**
195
236
  * Validates filters as `query` would, so many-row operations accept the same
196
237
  * search fields and values.
@@ -202,13 +243,14 @@ export function validateFilters ({ filters, scopeName, schemaInfo, vars }) {
202
243
  }
203
244
 
204
245
  /**
205
- * The response of a many-row write: identifiers, full records read through
206
- * `query` in the write's transaction, or nothing.
246
+ * The response of a many-row write: the count of written rows, identifiers,
247
+ * full records read through `query` in the write's transaction, or nothing.
207
248
  * @param {{ api: any, scopeName: string, context: any, ids: unknown[], returning: string, format: string, queryParams?: any }} request
208
249
  */
209
250
  export async function manyRowResponse ({ api, scopeName, context, ids, returning, format, queryParams = {} }) {
210
251
  if (returning === 'none') return undefined
211
252
  const present = ids.filter(id => id !== undefined && id !== null).map(String)
253
+ if (returning === 'count') return { meta: { count: present.length } }
212
254
  if (returning === 'minimal') return { data: present.map(id => ({ type: scopeName, id })) }
213
255
 
214
256
  const byId = new Map()