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
@@ -0,0 +1,132 @@
1
+ // Delete many records by id: one scoped read of the ids the caller may delete
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.
5
+ import { beginWriteTransaction } from '../../../lib/error-context.js'
6
+ import { RestApiRecordsNotFoundError, RestApiValidationError } from '../../../lib/rest-api-errors.js'
7
+ import { rejectRemovedOptions, resolveFormat } from '../lib/querying-writing/response-options.js'
8
+ import { requireExistingResourceId } from '../lib/querying-writing/resource-id-normalization.js'
9
+ import { chunk, databaseFamily } from '../lib/storage/many-row-data-helpers.js'
10
+ import { commitOwnedTransaction, handleWriteMethodError } from './common.js'
11
+ import {
12
+ keepVisible,
13
+ refuseDuplicateIds,
14
+ refuseUnsupportedResource,
15
+ rejectBulkOptions,
16
+ requireWhereScope,
17
+ resolveChunkSize,
18
+ resolveManyRowReturning,
19
+ resolveMissing,
20
+ unsupported,
21
+ validateFilters
22
+ } from './many-row-common.js'
23
+ import { VISIBLE_READ_CHUNK, visibleIdsByFilters } from './patch-many.js'
24
+
25
+ function setup ({ params, context, scopes, scopeName }, method) {
26
+ context.method = method
27
+ rejectRemovedOptions(params)
28
+ rejectBulkOptions(params)
29
+ context.scopeName = scopeName
30
+ context.schemaInfo = scopes[scopeName].vars.schemaInfo
31
+ // Deletes return no records; a format is still checked like a single delete's.
32
+ if (params.format !== undefined) resolveFormat(params.format)
33
+ context.returning = resolveManyRowReturning(params.returning, { deletes: true })
34
+ context.params = params
35
+ }
36
+
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
43
+ const storageAdapter = helpers.getStorageAdapter(scopeName)
44
+ const chunkSize = resolveChunkSize(params.chunkSize)
45
+ const missing = selection ? 'skip' : resolveMissing(params.missing)
46
+ refuseUnsupportedResource({ api, helpers, perRecordHooks, scopeName, schemaInfo: context.schemaInfo, operation, kind: 'delete', storageHelper: 'dataDeleteByIds' })
47
+ refuseDuplicateIds(ids, storageAdapter, 'ids')
48
+
49
+ if (!started) await beginWriteTransaction(context, params.transaction, helpers.newTransaction, runHooks)
50
+ context.db = context.transaction
51
+
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
+ }
74
+ }
75
+
76
+ context.ids = kept.map(String)
77
+ await runHooks('beforeDataCallDeleteMany')
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))
84
+ await runHooks('afterDataCallDeleteMany')
85
+
86
+ await commitOwnedTransaction(context)
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 })) }
94
+ }
95
+
96
+ /** @param {any} args */
97
+ export async function deleteManyMethod (args) {
98
+ const { params, context, scopeName, scopeOptions, vars, log } = args
99
+ try {
100
+ setup(args, 'deleteMany')
101
+ if (!Array.isArray(params.ids) || !params.ids.length) {
102
+ throw new RestApiValidationError('ids must be a non-empty array', { fields: ['ids'] })
103
+ }
104
+ const ids = params.ids.map(id => requireExistingResourceId(id, { scopeOptions, vars, scopeName }))
105
+ return await runDeleteMany(args, { operation: 'deleteMany', ids })
106
+ } catch (error) {
107
+ await handleWriteMethodError(error, context, 'DELETE_MANY', scopeName, log)
108
+ }
109
+ }
110
+
111
+ /** @param {any} args */
112
+ export async function deleteWhereMethod (args) {
113
+ const { params, context, scopeName, vars, helpers, log } = args
114
+ try {
115
+ setup(args, 'deleteWhere')
116
+ if (typeof helpers.dataVisibleIds !== 'function') {
117
+ throw unsupported(scopeName, 'deleteWhere', 'its storage plugin has no many-row support (AnyAPI storage takes single writes)')
118
+ }
119
+ const filters = validateFilters({ filters: params.filters, scopeName, schemaInfo: context.schemaInfo, vars })
120
+ requireWhereScope({ filters, all: params.all, missing: params.missing, operation: 'deleteWhere' })
121
+ await beginWriteTransaction(context, params.transaction, helpers.newTransaction, args.runHooks)
122
+ context.db = context.transaction
123
+ const ids = await visibleIdsByFilters(args, filters)
124
+ if (!ids.length) {
125
+ await commitOwnedTransaction(context)
126
+ return deleteResponse(context, scopeName, [])
127
+ }
128
+ return await runDeleteMany(args, { operation: 'deleteWhere', ids, started: true, selection: { filters } })
129
+ } catch (error) {
130
+ await handleWriteMethodError(error, context, 'DELETE_WHERE', scopeName, log)
131
+ }
132
+ }
@@ -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
 
@@ -0,0 +1,274 @@
1
+ // Shared steps of the many-row operations: input selection, refusals, chunking,
2
+ // the in-memory per-row pipeline and the response shape.
3
+ import {
4
+ RestApiBulkWriteUnsupportedError,
5
+ RestApiLockOutsideTransactionError,
6
+ RestApiRecordsNotFoundError,
7
+ RestApiValidationError
8
+ } from '../../../lib/rest-api-errors.js'
9
+ import { transformSimplifiedToJsonApi } from '../lib/querying-writing/simplified-helpers.js'
10
+ import { inverseVersionTypes } from '../lib/writing/resource-version.js'
11
+ import { isRawValue } from '../lib/storage/many-row-data-helpers.js'
12
+ import { queryConstraint } from '../lib/querying/query-constraint.js'
13
+ import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
14
+
15
+ export const DEFAULT_CHUNK_SIZE = 500
16
+ const READ_CHUNK_SIZE = 1000
17
+
18
+ // Per-record events whose handlers many-row operations would skip. A handler
19
+ // must be registered with { batch: true } (its work done by a batch hook or not
20
+ // applying to batches), or the operation refuses the resource.
21
+ const PER_RECORD_EVENTS = {
22
+ post: ['beforeProcessing', 'beforeProcessingPost', 'beforeSchemaValidate', 'beforeSchemaValidatePost', 'afterSchemaValidate', 'afterSchemaValidatePost', 'beforeDataCall', 'beforeDataCallPost', 'afterDataCall', 'afterDataCallPost', 'finish', 'finishPost'],
23
+ patch: ['beforeProcessing', 'beforeProcessingPatch', 'beforeSchemaValidate', 'beforeSchemaValidatePatch', 'afterSchemaValidate', 'afterSchemaValidatePatch', 'beforeDataCall', 'beforeDataCallPatch', 'afterDataCall', 'afterDataCallPatch', 'finish', 'finishPatch'],
24
+ delete: ['beforeDataCall', 'beforeDataCallDelete', 'afterDataCall', 'afterDataCallDelete', 'finish', 'finishDelete']
25
+ }
26
+
27
+ export const noHooks = async () => {}
28
+
29
+ // Options of the 2.x bulk methods that many-row writes do not have.
30
+ const BULK_OPTIONS = {
31
+ atomic: 'Many-row writes are all-or-nothing; remove atomic.',
32
+ expectedVersion: 'Many-row writes refuse versioned resources; use a lockVersionField and onlyIf.',
33
+ expectedVersions: 'Many-row writes refuse versioned resources; use a lockVersionField and onlyIf.',
34
+ operations: 'Pass the rows as data: [{ id, ...attributes }] or document: { data: [...] }.'
35
+ }
36
+
37
+ /** @param {Record<string, unknown>} params */
38
+ export function rejectBulkOptions (params) {
39
+ const fields = Object.keys(BULK_OPTIONS).filter(name => Object.hasOwn(params, name))
40
+ if (fields.length) {
41
+ throw new RestApiValidationError(`Unsupported options: ${fields.join(', ')}. ${[...new Set(fields.map(name => BULK_OPTIONS[name]))].join(' ')}`, { fields })
42
+ }
43
+ }
44
+
45
+ /** @param {string} resourceType @param {string} operation @param {string} reason */
46
+ export const unsupported = (resourceType, operation, reason) => new RestApiBulkWriteUnsupportedError({ resourceType, operation, reason })
47
+
48
+ /**
49
+ * Refuses what a many-row write cannot do in one statement per chunk.
50
+ * @param {{ api: any, helpers: any, perRecordHooks: Function, scopeName: string, schemaInfo: any, operation: string, kind: 'post' | 'patch' | 'delete', storageHelper: string }} request
51
+ */
52
+ export function refuseUnsupportedResource ({ api, helpers, perRecordHooks, scopeName, schemaInfo, operation, kind, storageHelper }) {
53
+ if (typeof helpers[storageHelper] !== 'function') {
54
+ throw unsupported(scopeName, operation, 'its storage plugin has no many-row support (AnyAPI storage takes single writes)')
55
+ }
56
+ if (schemaInfo.versionField) {
57
+ throw unsupported(scopeName, operation, `the resource is versioned by '${schemaInfo.versionField}'; use single writes with expectedVersion`)
58
+ }
59
+ const parents = [...inverseVersionTypes(api, scopeName, schemaInfo)]
60
+ if (parents.length) {
61
+ throw unsupported(scopeName, operation, `writes advance the version of '${parents.join("', '")}'`)
62
+ }
63
+ if (kind === 'delete') {
64
+ for (const [ownerType, owner] of Object.entries(api.resources)) {
65
+ if (!owner.vars.schemaInfo?.versionField) continue
66
+ const versioned = Object.values(owner.vars.schemaInfo.schemaRelationships || {})
67
+ .some(relationship => relationship.type === 'manyToMany' && relationship.target === scopeName)
68
+ if (versioned) throw unsupported(scopeName, operation, `deletes advance the version of '${ownerType}' through a many-to-many relationship`)
69
+ }
70
+ }
71
+ const hooks = perRecordHooks(PER_RECORD_EVENTS[kind])
72
+ if (hooks.length) {
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
+ })
80
+ }
81
+ }
82
+
83
+ /** @param {unknown} value */
84
+ export function resolveChunkSize (value) {
85
+ if (value === undefined) return DEFAULT_CHUNK_SIZE
86
+ if (!Number.isSafeInteger(value) || /** @type {number} */ (value) < 1) {
87
+ throw new RestApiValidationError('chunkSize must be a positive integer', { fields: ['chunkSize'] })
88
+ }
89
+ return /** @type {number} */ (value)
90
+ }
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
+
106
+ /** @param {unknown} value */
107
+ export function resolveMissing (value) {
108
+ if (value === undefined) return 'throw'
109
+ if (value !== 'throw' && value !== 'skip') {
110
+ throw new RestApiValidationError("missing must be 'throw' or 'skip'", { fields: ['missing'] })
111
+ }
112
+ return value
113
+ }
114
+
115
+ /**
116
+ * The records of a many-row write: `data: [...]` in the plain format, or
117
+ * `document: { data: [...] }` in JSON:API.
118
+ * @param {Record<string, any>} params
119
+ * @returns {{ inputKey: 'data' | 'document', records: any[] }}
120
+ */
121
+ export function selectManyInput (params) {
122
+ const hasData = Object.hasOwn(params, 'data')
123
+ if (hasData === Object.hasOwn(params, 'document')) {
124
+ throw new RestApiValidationError('Supply exactly one of data or document', { fields: ['data', 'document'] })
125
+ }
126
+ const records = hasData ? params.data : params.document?.data
127
+ if (!Array.isArray(records) || records.length === 0) {
128
+ const field = hasData ? 'data' : 'document.data'
129
+ throw new RestApiValidationError(`${field} must be a non-empty array of records`, {
130
+ fields: [field], violations: [{ field, rule: 'required_array', message: 'Must be a non-empty array' }]
131
+ })
132
+ }
133
+ records.forEach((record, index) => {
134
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
135
+ throw new RestApiValidationError(`Record ${index} must be an object`, { fields: [`data.${index}`] })
136
+ }
137
+ })
138
+ return { inputKey: hasData ? 'data' : 'document', records }
139
+ }
140
+
141
+ /**
142
+ * One record as a JSON:API document, with knex.raw values set aside for the
143
+ * operations that accept them.
144
+ * @param {{ record: any, inputKey: 'data' | 'document', scopeName: string, schemaInfo: any, allowRaw?: boolean }} request
145
+ */
146
+ export function toDocument ({ record, inputKey, scopeName, schemaInfo, allowRaw = false }) {
147
+ const raw = {}
148
+ let attributes = inputKey === 'data' ? record : record.attributes
149
+ if (attributes && typeof attributes === 'object') {
150
+ for (const [field, value] of Object.entries(attributes)) {
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
+ }
158
+ if (!allowRaw) throw new RestApiValidationError(`Field '${field}' cannot take a raw SQL value here`, { fields: [field] })
159
+ raw[field] = value
160
+ }
161
+ if (Object.keys(raw).length) attributes = Object.fromEntries(Object.entries(attributes).filter(([field]) => !Object.hasOwn(raw, field)))
162
+ }
163
+ const document = inputKey === 'data'
164
+ ? transformSimplifiedToJsonApi({ inputRecord: attributes }, {
165
+ context: { scopeName, schemaStructure: schemaInfo.schemaInstance.structure, schemaRelationships: schemaInfo.schemaRelationships }
166
+ })
167
+ : { data: { ...record, ...(record.attributes ? { attributes } : {}) } }
168
+ return { document, raw }
169
+ }
170
+
171
+ /**
172
+ * Prefixes a row's validation paths with its position in the batch.
173
+ * @param {unknown} error @param {number} index
174
+ */
175
+ export function atIndex (error, index) {
176
+ if (!(error instanceof RestApiValidationError)) return error
177
+ const prefix = path => typeof path === 'string' ? path.replace(/^(data\.)?/, `data.${index}.`) : path
178
+ const details = /** @type {any} */ (error.details || {})
179
+ if (Array.isArray(details.fields)) details.fields = details.fields.map(prefix)
180
+ if (Array.isArray(details.violations)) details.violations = details.violations.map(violation => ({ ...violation, field: prefix(violation.field) }))
181
+ details.index = index
182
+ error.details = details
183
+ return error
184
+ }
185
+
186
+ /**
187
+ * Throws when a requested identifier is missing or outside the caller's scope.
188
+ * @param {{ requested: unknown[], visible: Map<string, unknown> | Set<string>, missing: 'throw' | 'skip', scopeName: string, storageAdapter: any }} request
189
+ * @returns {unknown[]} requested identifiers that are visible
190
+ */
191
+ export function keepVisible ({ requested, visible, missing, scopeName, storageAdapter }) {
192
+ const kept = []
193
+ let absent = 0
194
+ for (const id of requested) {
195
+ if (visible.has(String(storageAdapter.translateFilterValue('id', id)))) kept.push(id)
196
+ else absent++
197
+ }
198
+ if (absent && missing === 'throw') throw new RestApiRecordsNotFoundError({ resourceType: scopeName, missing: absent })
199
+ return kept
200
+ }
201
+
202
+ /** @param {unknown[]} ids */
203
+ export function refuseDuplicateIds (ids, storageAdapter, field = 'data') {
204
+ const seen = new Set()
205
+ for (const id of ids) {
206
+ const key = String(storageAdapter.translateFilterValue('id', id))
207
+ if (seen.has(key)) throw new RestApiValidationError(`Identifier ${id} appears twice in the batch`, { fields: [field] })
208
+ seen.add(key)
209
+ }
210
+ }
211
+
212
+ /** @param {{ transaction?: any, scopeName: string, operation: string }} request */
213
+ export function requireLockTransaction ({ transaction, scopeName, operation }) {
214
+ if (!transaction || typeof transaction !== 'function' || !transaction.isTransaction || transaction.isCompleted?.()) {
215
+ throw new RestApiLockOutsideTransactionError({ resourceType: scopeName, operation })
216
+ }
217
+ }
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
+
235
+ /**
236
+ * Validates filters as `query` would, so many-row operations accept the same
237
+ * search fields and values.
238
+ */
239
+ export function validateFilters ({ filters, scopeName, schemaInfo, vars }) {
240
+ if (filters === undefined) return undefined
241
+ const contracts = getRequestContracts({ scopeName, schemaInfo, includeDepthLimit: vars.includeDepthLimit, sortableFields: vars.sortableFields })
242
+ return validateRequestContractOrThrow(contracts.query, { queryParams: { filters } }, 'Filters are invalid').queryParams?.filters
243
+ }
244
+
245
+ /**
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.
248
+ * @param {{ api: any, scopeName: string, context: any, ids: unknown[], returning: string, format: string, queryParams?: any }} request
249
+ */
250
+ export async function manyRowResponse ({ api, scopeName, context, ids, returning, format, queryParams = {} }) {
251
+ if (returning === 'none') return undefined
252
+ const present = ids.filter(id => id !== undefined && id !== null).map(String)
253
+ if (returning === 'count') return { meta: { count: present.length } }
254
+ if (returning === 'minimal') return { data: present.map(id => ({ type: scopeName, id })) }
255
+
256
+ const byId = new Map()
257
+ /** @type {Map<string, any>} */
258
+ const included = new Map()
259
+ for (let offset = 0; offset < present.length; offset += READ_CHUNK_SIZE) {
260
+ const part = present.slice(offset, offset + READ_CHUNK_SIZE)
261
+ const page = await api.resources[scopeName].query({
262
+ format,
263
+ transaction: context.transaction,
264
+ pageSizeLimit: part.length,
265
+ queryParams: { fields: queryParams.fields, include: queryParams.include, sort: ['id'], page: { size: part.length } },
266
+ [queryConstraint]: { scopeName, idsQuery: part }
267
+ }, { ...context, method: undefined, knexQuery: undefined })
268
+ for (const record of page.data) byId.set(String(record.id), record)
269
+ for (const resource of page.included || []) included.set(`${resource.type}:${resource.id}`, resource)
270
+ }
271
+ // Rows the caller's read scope hides are left out.
272
+ const data = present.filter(id => byId.has(id)).map(id => byId.get(id))
273
+ return format === 'jsonapi' && included.size ? { data, included: [...included.values()] } : { data }
274
+ }