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
@@ -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,15 @@ 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.
207
- * @param {{ api: any, scopeName: string, context: any, ids: unknown[], returning: string, format: string, queryParams?: any }} request
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
+ * `locked`: the transaction holds row locks on the ids, so they are read as locked.
249
+ * @param {{ api: any, scopeName: string, context: any, ids: unknown[], returning: string, format: string, queryParams?: any, locked?: boolean }} request
208
250
  */
209
- export async function manyRowResponse ({ api, scopeName, context, ids, returning, format, queryParams = {} }) {
251
+ export async function manyRowResponse ({ api, scopeName, context, ids, returning, format, queryParams = {}, locked = false }) {
210
252
  if (returning === 'none') return undefined
211
253
  const present = ids.filter(id => id !== undefined && id !== null).map(String)
254
+ if (returning === 'count') return { meta: { count: present.length } }
212
255
  if (returning === 'minimal') return { data: present.map(id => ({ type: scopeName, id })) }
213
256
 
214
257
  const byId = new Map()
@@ -221,7 +264,7 @@ export async function manyRowResponse ({ api, scopeName, context, ids, returning
221
264
  transaction: context.transaction,
222
265
  pageSizeLimit: part.length,
223
266
  queryParams: { fields: queryParams.fields, include: queryParams.include, sort: ['id'], page: { size: part.length } },
224
- [queryConstraint]: { scopeName, idsQuery: part }
267
+ [queryConstraint]: { scopeName, idsQuery: part, locked }
225
268
  }, { ...context, method: undefined, knexQuery: undefined })
226
269
  for (const record of page.data) byId.set(String(record.id), record)
227
270
  for (const resource of page.included || []) included.set(`${resource.type}:${resource.id}`, resource)
@@ -2,17 +2,18 @@
2
2
  // ids the caller may change (one scoped read per chunk), then run one UPDATE per
3
3
  // chunk with CASE expressions. patchWhereIds and patchWhere reuse the same path.
4
4
  import { lockRelationshipTargets, processRelationships } from '../lib/writing/relationship-processor.js'
5
- import { rejectRemovedOptions, resolveFormat, resolveReturning } from '../lib/querying-writing/response-options.js'
5
+ import { rejectRemovedOptions, resolveFormat } from '../lib/querying-writing/response-options.js'
6
6
  import { requireExistingResourceId } from '../lib/querying-writing/resource-id-normalization.js'
7
7
  import { validateUpdateRequest } from '../lib/querying-writing/request-contracts.js'
8
8
  import { beginWriteTransaction } from '../../../lib/error-context.js'
9
- import { RestApiValidationError } from '../../../lib/rest-api-errors.js'
10
- import { chunk } from '../lib/storage/many-row-data-helpers.js'
9
+ import { RestApiRecordsNotFoundError, RestApiValidationError } from '../../../lib/rest-api-errors.js'
10
+ import { chunk, databaseFamily } from '../lib/storage/many-row-data-helpers.js'
11
11
  import {
12
12
  applyFieldSetters,
13
13
  collectRelationshipTargets,
14
14
  commitOwnedTransaction,
15
15
  handleWriteMethodError,
16
+ keepHiddenLinks,
16
17
  validateRelationshipTargets,
17
18
  validateResourceAttributesBeforeWrite
18
19
  } from './common.js'
@@ -24,7 +25,9 @@ import {
24
25
  refuseDuplicateIds,
25
26
  refuseUnsupportedResource,
26
27
  rejectBulkOptions,
28
+ requireWhereScope,
27
29
  resolveChunkSize,
30
+ resolveManyRowReturning,
28
31
  resolveMissing,
29
32
  selectManyInput,
30
33
  toDocument,
@@ -50,12 +53,16 @@ function protectedFields (schemaInfo) {
50
53
  return { fields, relationships }
51
54
  }
52
55
 
53
- function refuseProtectedInput ({ document, raw, schemaInfo, index }) {
54
- const { fields, relationships } = protectedFields(schemaInfo)
55
- const attributes = document.data.attributes || {}
56
- for (const field of [...Object.keys(attributes), ...Object.keys(raw)]) {
56
+ function refuseProtectedFields (names, schemaInfo, index) {
57
+ const { fields } = protectedFields(schemaInfo)
58
+ for (const field of names) {
57
59
  if (fields.has(field)) throw atIndex(invalid(`data.attributes.${field}`, `'${field}' cannot be changed by a many-row patch`), index)
58
60
  }
61
+ }
62
+
63
+ function refuseProtectedInput ({ document, raw, schemaInfo, index }) {
64
+ const { relationships } = protectedFields(schemaInfo)
65
+ refuseProtectedFields([...Object.keys(document.data.attributes || {}), ...Object.keys(raw)], schemaInfo, index)
59
66
  for (const name of Object.keys(document.data.relationships || {})) {
60
67
  if (relationships.has(name)) throw atIndex(invalid(`data.relationships.${name}`, `'${name}' sets ownership and cannot be changed by a many-row patch`), index)
61
68
  }
@@ -83,16 +90,18 @@ function resolveOnlyIf (onlyIf, { schemaInfo, storageAdapter }) {
83
90
  }
84
91
 
85
92
  /**
86
- * The shared path: `entries` are { id, document, raw } in input order.
93
+ * The shared path: `entries` are { id, document, raw } in input order. A
94
+ * `selection` (patchWhere) gives the filters that found the ids: its rows are
95
+ * never missing, and the write answers with the rows it changed.
87
96
  * @param {any} args
88
- * @param {{ operation: string, entries: Array<{ id: unknown, document: any, raw: Record<string, unknown> }>, started?: boolean }} batch
97
+ * @param {{ operation: string, entries: Array<{ id: unknown, document: any, raw: Record<string, unknown> }>, started?: boolean, selection?: { filters?: Record<string, unknown> } }} batch
89
98
  */
90
- async function runPatchMany (args, { operation, entries, started = false }) {
91
- const { params, context, vars, helpers, scope, runHooks, perRecordHooks, scopeOptions, scopeName, api } = args
99
+ async function runPatchMany (args, { operation, entries, started = false, selection }) {
100
+ const { params, context, vars, helpers, scope, runHooks, hasHooks, perRecordHooks, scopeOptions, scopeName, api } = args
92
101
  const schemaInfo = context.schemaInfo
93
102
  const storageAdapter = helpers.getStorageAdapter(scopeName)
94
103
  const chunkSize = resolveChunkSize(params.chunkSize)
95
- const missing = resolveMissing(params.missing)
104
+ const missing = selection ? 'skip' : resolveMissing(params.missing)
96
105
  refuseUnsupportedResource({ api, helpers, perRecordHooks, scopeName, schemaInfo, operation, kind: 'patch', storageHelper: 'dataUpdateByIds' })
97
106
  const onlyIf = resolveOnlyIf(params.onlyIf, { schemaInfo, storageAdapter })
98
107
  entries.forEach((entry, index) => refuseProtectedInput({ ...entry, schemaInfo, index }))
@@ -100,18 +109,21 @@ async function runPatchMany (args, { operation, entries, started = false }) {
100
109
  if (!started) await beginWriteTransaction(context, params.transaction, helpers.newTransaction, runHooks)
101
110
  context.db = context.transaction
102
111
  context.inputRecords = entries.map(entry => entry.document)
112
+ // Fields set by raw SQL values, which plugins that refuse fields check too.
113
+ context.rawFields = [...new Set(entries.flatMap(entry => Object.keys(entry.raw)))]
103
114
  await runHooks('beforeProcessingPatchMany')
104
115
 
105
116
  const targets = new Map()
106
117
  const rows = entries.map((entry, index) => {
107
118
  const rowContext = { ...context, method: 'patch', inputRecord: entry.document, originalInputAttributes: undefined, bulkIndex: index }
108
- // A row of raw values alone still carries an (empty) attributes object.
109
- if (Object.keys(entry.raw).length) entry.document.data.attributes ||= {}
119
+ // A row of raw values alone, or one that only advances a lock version,
120
+ // still carries an (empty) attributes object.
121
+ if (Object.keys(entry.raw).length || schemaInfo.lockVersionField) entry.document.data.attributes ||= {}
110
122
  try {
111
123
  validateUpdateRequest({ method: 'patch', params: {}, context: rowContext, vars, scopeOptions, scopeName })
112
124
  } catch (error) { throw atIndex(error, index) }
113
125
  collectRelationshipTargets(rowContext.inputRecord, api, targets)
114
- return { rowContext, raw: entry.raw, index }
126
+ return { rowContext, raw: entry.raw, index, clearsLink: false }
115
127
  })
116
128
  refuseDuplicateIds(rows.map(row => row.rowContext.id), storageAdapter)
117
129
  await validateRelationshipTargets(context, [...targets.values()], helpers, api)
@@ -126,26 +138,52 @@ async function runPatchMany (args, { operation, entries, started = false }) {
126
138
  }
127
139
  await validateResourceAttributesBeforeWrite({ context: rowContext, schema: schemaInfo.schemaInstance, belongsToUpdates, runHooks: noHooks, isPartialValidation: true })
128
140
  rowContext.inputRecord.data.attributes = { ...rowContext.inputRecord.data.attributes, ...belongsToUpdates }
129
- if (!Object.keys(rowContext.inputRecord.data.attributes).length && !Object.keys(row.raw).length) {
141
+ row.clearsLink = Object.values(belongsToUpdates).includes(null)
142
+ // A polymorphic relationship writes its own type and id columns, which may be ownership fields.
143
+ refuseProtectedFields(Object.keys(rowContext.inputRecord.data.attributes), schemaInfo, index)
144
+ // A row with no fields still advances a lock version.
145
+ if (!Object.keys(rowContext.inputRecord.data.attributes).length && !Object.keys(row.raw).length && !schemaInfo.lockVersionField) {
130
146
  throw invalid('data', 'A patch row must change at least one field')
131
147
  }
132
148
  parents.push(...belongsToTargets)
133
149
  } catch (error) { throw atIndex(error, index) }
134
150
  }
135
151
 
136
- // Which of the requested rows the caller may change: one scoped read per chunk.
137
- const visible = new Map()
138
- for (const part of chunk(rows.map(row => row.rowContext.id), VISIBLE_READ_CHUNK)) {
139
- const records = await helpers.dataGetMinimal({ scopeName, context, runHooks, ids: part, filters: {}, queryPurpose: 'bulk-write' })
140
- for (const record of records) visible.set(String(storageAdapter.translateFilterValue('id', record.id)), record)
141
- }
142
- const kept = keepVisible({ requested: rows.map(row => row.rowContext.id), visible, missing, scopeName, storageAdapter })
143
- const keptRows = rows.filter(row => kept.includes(row.rowContext.id))
144
- for (const { rowContext, index } of keptRows) {
145
- rowContext.minimalRecord = visible.get(String(storageAdapter.translateFilterValue('id', rowContext.id)))
146
- try {
147
- await scope.checkPermissions({ method: 'patch', originalContext: rowContext })
148
- } catch (error) { throw atIndex(error, index) }
152
+ // Which of the requested rows the caller may change. With missing: 'skip' and
153
+ // no hook that needs the rows first, the UPDATE decides it, carrying the
154
+ // scope, and its changed rows answer. Otherwise one scoped read per chunk
155
+ // gives each row's minimal record to the permission check. On MySQL a
156
+ // locking read locks every listed row before it checks the scope, so ids
157
+ // from the caller always get that read first; a selection's ids come from
158
+ // its own scoped read. A selection's UPDATE carries the scope and its filters
159
+ // either way, so it changes only rows that still match.
160
+ // A row that clears a link reads first too, to keep a link to a hidden target.
161
+ const readFirst = rows.some(row => row.clearsLink) || !(missing === 'skip' && !hasHooks('checkPermissions') && !hasHooks('beforeDataCallPatchMany') &&
162
+ (selection || databaseFamily(context.db) !== 'mysql'))
163
+ const scoped = Boolean(selection) || !readFirst
164
+ let keptRows = rows
165
+ if (readFirst) {
166
+ const visible = new Map()
167
+ for (const part of chunk(rows.map(row => row.rowContext.id), VISIBLE_READ_CHUNK)) {
168
+ const records = await helpers.dataGetMinimal({ scopeName, context, runHooks, ids: part, filters: {}, queryPurpose: 'bulk-write' })
169
+ for (const record of records) visible.set(String(storageAdapter.translateFilterValue('id', record.id)), record)
170
+ }
171
+ const kept = keepVisible({ requested: rows.map(row => row.rowContext.id), visible, missing, scopeName, storageAdapter })
172
+ keptRows = rows.filter(row => kept.includes(row.rowContext.id))
173
+ for (const { rowContext } of keptRows) {
174
+ rowContext.minimalRecord = visible.get(String(storageAdapter.translateFilterValue('id', rowContext.id)))
175
+ }
176
+ await keepHiddenLinks({
177
+ context,
178
+ scopes: api.resources,
179
+ schemaInfo,
180
+ rows: keptRows.filter(row => row.clearsLink).map(({ rowContext }) => ({ updates: rowContext.inputRecord.data.attributes, stored: rowContext.minimalRecord, inputRecord: rowContext.inputRecord }))
181
+ })
182
+ for (const { rowContext, index } of keptRows) {
183
+ try {
184
+ await scope.checkPermissions({ method: 'patch', originalContext: rowContext })
185
+ } catch (error) { throw atIndex(error, index) }
186
+ }
149
187
  }
150
188
 
151
189
  context.ids = keptRows.map(row => String(row.rowContext.id))
@@ -164,8 +202,16 @@ async function runPatchMany (args, { operation, entries, started = false }) {
164
202
  updates.push({ id: rowContext.id, row })
165
203
  }
166
204
 
167
- const changed = updates.length ? await helpers.dataUpdateByIds({ scopeName, context, updates, onlyIf, chunkSize }) : []
168
- context.ids = changed.map(String)
205
+ const changed = new Set((updates.length
206
+ ? await helpers.dataUpdateByIds({ scopeName, context, runHooks, updates, onlyIf, chunkSize, scoped, filters: selection?.filters })
207
+ : []).map(String))
208
+ // A row that left the caller's scope after the visibility read is not written.
209
+ const unchanged = updates.length - changed.size
210
+ if (unchanged && !onlyIf && missing === 'throw') throw new RestApiRecordsNotFoundError({ resourceType: scopeName, missing: unchanged })
211
+ // After-hooks see the changed rows' ids and inputs, paired by position.
212
+ const written = keptRows.filter(row => changed.has(String(storageAdapter.translateFilterValue('id', row.rowContext.id))))
213
+ context.ids = written.map(row => String(storageAdapter.translateFilterValue('id', row.rowContext.id)))
214
+ context.inputRecords = written.map(row => row.rowContext.inputRecord)
169
215
  await runHooks('afterDataCallPatchMany')
170
216
 
171
217
  const response = await manyRowResponse({
@@ -183,7 +229,7 @@ function setupManyWrite ({ params, context, vars, scopes, scopeName }, method) {
183
229
  context.schemaInfo = scopes[scopeName].vars.schemaInfo
184
230
  context.format = resolveFormat(params.format, vars.format)
185
231
  context.simplified = context.format === 'plain'
186
- context.returning = resolveReturning(params.returning, 'none')
232
+ context.returning = resolveManyRowReturning(params.returning)
187
233
  context.params = params
188
234
  }
189
235
 
@@ -269,16 +315,17 @@ export async function patchWhereMethod (args) {
269
315
  throw unsupported(scopeName, 'patchWhere', 'its storage plugin has no many-row support (AnyAPI storage takes single writes)')
270
316
  }
271
317
  const filters = validateFilters({ filters: params.filters, scopeName, schemaInfo: context.schemaInfo, vars })
318
+ requireWhereScope({ filters, all: params.all, missing: params.missing, operation: 'patchWhere' })
272
319
  // The visible ids are read in the write's transaction, so open it first.
273
320
  await beginWriteTransaction(context, params.transaction, helpers.newTransaction, args.runHooks)
274
321
  context.db = context.transaction
275
322
  const ids = await visibleIdsByFilters(args, filters)
276
323
  if (!ids.length) {
277
324
  await commitOwnedTransaction(context)
278
- return context.returning === 'none' ? undefined : { data: [] }
325
+ return context.returning === 'count' ? { meta: { count: 0 } } : context.returning === 'none' ? undefined : { data: [] }
279
326
  }
280
327
  return await runPatchMany(args, {
281
- operation: 'patchWhere', entries: entriesFor(ids, shared, { scopeName, schemaInfo: context.schemaInfo }), started: true
328
+ operation: 'patchWhere', entries: entriesFor(ids, shared, { scopeName, schemaInfo: context.schemaInfo }), started: true, selection: { filters }
282
329
  })
283
330
  } catch (error) {
284
331
  await handleWriteMethodError(error, context, 'PATCH_WHERE', scopeName, log)
@@ -18,7 +18,8 @@ import {
18
18
  validatePivotResource,
19
19
  handleRecordReturnAfterWrite,
20
20
  handleWriteMethodError,
21
- commitOwnedTransaction
21
+ commitOwnedTransaction,
22
+ keepHiddenLinks
22
23
  } from './common.js'
23
24
 
24
25
  /**
@@ -90,6 +91,7 @@ export default async function patchMethod ({
90
91
  }
91
92
 
92
93
  context.minimalRecord = minimalRecord
94
+ await keepHiddenLinks({ context, scopes, schemaInfo: context.schemaInfo, rows: [{ updates: belongsToUpdates, stored: minimalRecord, inputRecord: context.inputRecord }] })
93
95
 
94
96
  await scope.checkPermissions({
95
97
  method: 'patch',