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
@@ -1,6 +1,6 @@
1
1
  // Reads that keep the resource's scope: the read-only scoped builder, every-row
2
2
  // reads in cursor pages, and row locks inside a transaction.
3
- import { RestApiBulkWriteUnsupportedError, RestApiRowLimitError, RestApiValidationError } from '../../../lib/rest-api-errors.js'
3
+ import { RestApiBulkWriteUnsupportedError, RestApiRecordsNotFoundError, RestApiRowLimitError, RestApiValidationError } from '../../../lib/rest-api-errors.js'
4
4
  import { readOnlyQueryBuilder } from '../lib/querying/read-only-query.js'
5
5
  import { normalizeRecordAttributes } from '../lib/querying-writing/database-value-normalizers.js'
6
6
  import { filterResponseFields, getResourceFieldset } from '../lib/querying-writing/field-utils.js'
@@ -9,21 +9,24 @@ import { resolveFormat } from '../lib/querying-writing/response-options.js'
9
9
  import { transformJsonApiToSimplified } from '../lib/querying-writing/simplified-helpers.js'
10
10
  import { requireExistingResourceId } from '../lib/querying-writing/resource-id-normalization.js'
11
11
  import { chunk } from '../lib/storage/many-row-data-helpers.js'
12
- import { keepVisible, refuseDuplicateIds, requireLockTransaction, resolveMissing, validateFilters } from './many-row-common.js'
12
+ import { filterToOneResponseLinkage, snapshotReadRecord } from './common.js'
13
+ import { refuseDuplicateIds, requireLockTransaction, resolveMissing, validateFilters } from './many-row-common.js'
13
14
 
14
15
  export const SCOPED_QUERY_PURPOSES = new Set(['report', 'bulk-write', 'lock', 'claim'])
15
16
  const DEFAULT_QUERY_ALL_PAGE = 1000
16
17
  const MAX_CLAIM = 1000
18
+ const MAX_LOCK_WHERE = 1000
17
19
 
18
20
  const invalid = (field, message) => new RestApiValidationError(message, { fields: [field], violations: [{ field, rule: 'scoped_read', message }] })
19
21
 
20
22
  /**
21
23
  * The resource's table with ownership and row policy applied, as a read-only
22
- * Knex builder. Resolves to `{ query }`: a builder is thenable, so returning it
23
- * from an async method would run it.
24
+ * Knex builder over a derived table named after the alias (or the table), so
25
+ * nothing a caller adds, `orWhere` included, can widen the scope. Resolves to
26
+ * `{ query }`: a builder is thenable, so returning it from an async method would run it.
24
27
  * @param {any} args
25
28
  */
26
- export async function scopedQueryMethod ({ params, context, vars, helpers, scope, scopes, runHooks, scopeName }) {
29
+ export async function scopedQueryMethod ({ params, context, vars, helpers, scope, scopes, runHooks, scopeName, api }) {
27
30
  const { transaction, alias, purpose = 'report', filters, ...unknown } = params
28
31
  if (Object.keys(unknown).length) throw invalid(Object.keys(unknown)[0], `Unknown scopedQuery options: ${Object.keys(unknown).join(', ')}`)
29
32
  if (!SCOPED_QUERY_PURPOSES.has(purpose)) throw invalid('purpose', `purpose must be one of ${[...SCOPED_QUERY_PURPOSES].join(', ')}`)
@@ -41,8 +44,9 @@ export async function scopedQueryMethod ({ params, context, vars, helpers, scope
41
44
  context.queryParams = { filters: validateFilters({ filters, scopeName, schemaInfo: context.schemaInfo, vars }) || {} }
42
45
  // A caller who may not query the resource may not build reports on it either.
43
46
  await scope.checkPermissions({ method: 'query', originalContext: { ...context, method: 'query', scopedQuery: true } })
44
- const { query } = await helpers.dataScopedQuery({ scopeName, context, runHooks, alias, purpose, filters: context.queryParams.filters })
45
- return { query: readOnlyQueryBuilder(query, { resourceType: scopeName }) }
47
+ const { query, alias: name } = await helpers.dataScopedQuery({ scopeName, context, runHooks, alias, purpose, filters: context.queryParams.filters })
48
+ const derived = (transaction || api.knex.instance).from(query.select(`${name}.*`).as(name))
49
+ return { query: readOnlyQueryBuilder(derived, { resourceType: scopeName }) }
46
50
  }
47
51
 
48
52
  /**
@@ -76,13 +80,26 @@ export async function queryAllMethod ({ params, context, vars, scopeName, api })
76
80
  return resolvedFormat === 'jsonapi' && included.size ? { data, included: [...included.values()] } : { data }
77
81
  }
78
82
 
79
- // The read pipeline's last steps for rows a lock read: normalization, computed
80
- // fields, sparse fieldsets and the plain format.
81
- async function finishRecords ({ document, context, scope, scopes, scopeName }) {
82
- let record = normalizeRecordAttributes(document, scopes)
83
+ // The rows a lock read leave through the read pipeline `query` runs after its
84
+ // data call, so masking and denials apply to them too: checkDataPermissions for
85
+ // each row as `get` runs it, enrichRecord, computed fields, the finish hooks,
86
+ // sparse fieldsets and the plain format. Hooks see method 'query' and lock: true.
87
+ async function finishLockedRead ({ document, context, scope, scopes, scopeName, runHooks, hasHooks }) {
88
+ context.method = 'query'
89
+ context.lock = true
90
+ context.record = normalizeRecordAttributes(document, scopes)
91
+ if (hasHooks('checkDataPermissions') || hasHooks('checkDataPermissionsGet')) {
92
+ for (const entry of context.record.data) {
93
+ const recordContext = { ...context, method: 'get', id: entry.id, record: { data: entry } }
94
+ await runHooks('checkDataPermissions', recordContext)
95
+ await runHooks('checkDataPermissionsGet', recordContext)
96
+ }
97
+ }
98
+ snapshotReadRecord(context, scopes, hasHooks, 'query')
99
+ await runHooks('enrichRecord')
83
100
  const computedFields = scope.vars.schemaInfo?.computed || {}
84
101
  const requestedComputedFields = getRequestedComputedFields(scopeName, getResourceFieldset(context.queryParams.fields, scopeName), computedFields)
85
- for (const entry of record.data) {
102
+ for (const entry of context.record.data) {
86
103
  entry.attributes = await scope.enrichAttributes({
87
104
  id: entry.id,
88
105
  attributes: entry.attributes,
@@ -92,7 +109,10 @@ async function finishRecords ({ document, context, scope, scopes, scopeName }) {
92
109
  computedDependencies: context.computedDependencies
93
110
  })
94
111
  }
95
- record = normalizeRecordAttributes(record, scopes, { source: 'response' })
112
+ await runHooks('finish')
113
+ await runHooks('finishQuery')
114
+ await filterToOneResponseLinkage(context, scopes)
115
+ const record = normalizeRecordAttributes(context.record, scopes, { source: 'response' })
96
116
  filterResponseFields(record, context.queryParams.fields)
97
117
  if (context.format !== 'plain') return { data: record.data }
98
118
  const schemaInfo = scope.vars.schemaInfo
@@ -123,30 +143,55 @@ async function setupLockRead ({ params, context, vars, helpers, scopes, scopeNam
123
143
 
124
144
  /**
125
145
  * Locks the visible rows among `ids` until the transaction ends, in id order,
126
- * and returns them as the locking read saw them.
146
+ * and returns them as the locking read saw them. That read is the first
147
+ * statement on the rows and decides visibility itself, so the transaction's
148
+ * snapshot starts after any lock wait. On MySQL it locks every listed row,
149
+ * hidden ones too, before it checks the scope (see guide 34).
127
150
  * @param {any} args
128
151
  */
129
152
  export async function lockByIdsMethod (args) {
130
- const { params, context, helpers, scope, scopes, runHooks, scopeName, scopeOptions, vars } = args
153
+ const { params, context, helpers, scope, scopes, runHooks, hasHooks, scopeName, scopeOptions, vars } = args
131
154
  await setupLockRead(args, 'lockByIds', 'dataLockByIds')
132
155
  if (!Array.isArray(params.ids) || !params.ids.length) throw invalid('ids', 'ids must be a non-empty array')
133
156
  const ids = params.ids.map(id => requireExistingResourceId(id, { scopeOptions, vars, scopeName }))
134
157
  const storageAdapter = helpers.getStorageAdapter(scopeName)
135
158
  refuseDuplicateIds(ids, storageAdapter, 'ids')
136
159
  const missing = resolveMissing(params.missing)
137
-
138
- const visible = new Set()
139
- for (const part of chunk(ids, 1000)) {
140
- for (const id of await helpers.dataVisibleIds({ scopeName, context, runHooks, ids: part, queryPurpose: 'lock' })) visible.add(String(id))
141
- }
142
- const kept = keepVisible({ requested: ids, visible, missing, scopeName, storageAdapter })
143
- if (!kept.length) return { data: [] }
144
160
  await scope.checkPermissions({ method: 'query', originalContext: { ...context, method: 'query', lock: true } })
161
+ // Sorted before chunking, so lists longer than a chunk still lock in one
162
+ // order across transactions: a list of integers numerically, any other list
163
+ // by code unit (the database's order for a binary collation).
164
+ const keys = ids.map(id => String(storageAdapter.translateFilterValue('id', id)))
165
+ const numeric = keys.every(key => /^-?\d+$/.test(key))
166
+ const sorted = ids
167
+ .map((id, index) => ({ id, key: numeric ? BigInt(keys[index]) : keys[index] }))
168
+ .sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0)
169
+ .map(entry => entry.id)
145
170
  const document = { data: [] }
146
- for (const part of chunk(kept, 1000)) {
147
- document.data.push(...(await helpers.dataLockByIds({ scopeName, context, ids: part })).data)
171
+ for (const part of chunk(sorted, 1000)) {
172
+ document.data.push(...(await helpers.dataLockByIds({ scopeName, context, runHooks, ids: part })).data)
173
+ }
174
+ if (document.data.length < ids.length && missing === 'throw') {
175
+ throw new RestApiRecordsNotFoundError({ resourceType: scopeName, missing: ids.length - document.data.length })
148
176
  }
149
- return finishRecords({ document, context, scope, scopes, scopeName })
177
+ if (!document.data.length) return { data: [] }
178
+ return finishLockedRead({ document, context, scope, scopes, scopeName, runHooks, hasHooks })
179
+ }
180
+
181
+ /**
182
+ * Locks the visible rows the filters select until the transaction ends, in id
183
+ * order, waiting for transactions that hold them, so a row that starts to match
184
+ * while the lock waits is locked and returned too. At most 1,000 rows.
185
+ * @param {any} args
186
+ */
187
+ export async function lockWhereMethod (args) {
188
+ const { params, context, vars, helpers, scope, scopes, runHooks, hasHooks, scopeName } = args
189
+ await setupLockRead(args, 'lockWhere', 'dataLockWhere')
190
+ const filters = validateFilters({ filters: params.filters, scopeName, schemaInfo: context.schemaInfo, vars })
191
+ await scope.checkPermissions({ method: 'query', originalContext: { ...context, method: 'query', lock: true } })
192
+ const document = await helpers.dataLockWhere({ scopeName, context, runHooks, filters, limit: MAX_LOCK_WHERE + 1 })
193
+ if (document.data.length > MAX_LOCK_WHERE) throw new RestApiRowLimitError({ resourceType: scopeName, rowLimit: MAX_LOCK_WHERE, operation: 'lockWhere' })
194
+ return finishLockedRead({ document, context, scope, scopes, scopeName, runHooks, hasHooks })
150
195
  }
151
196
 
152
197
  /**
@@ -155,7 +200,7 @@ export async function lockByIdsMethod (args) {
155
200
  * @param {any} args
156
201
  */
157
202
  export async function claimMethod (args) {
158
- const { params, context, vars, helpers, scope, scopes, runHooks, scopeName } = args
203
+ const { params, context, vars, helpers, scope, scopes, runHooks, hasHooks, scopeName } = args
159
204
  await setupLockRead(args, 'claim', 'dataClaim')
160
205
  const limit = params.limit ?? 1
161
206
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_CLAIM) throw invalid('limit', `limit must be an integer from 1 to ${MAX_CLAIM}`)
@@ -172,5 +217,5 @@ export async function claimMethod (args) {
172
217
  })
173
218
  await scope.checkPermissions({ method: 'query', originalContext: { ...context, method: 'query', lock: true } })
174
219
  const document = await helpers.dataClaim({ scopeName, context, runHooks, filters, sort, limit })
175
- return finishRecords({ document, context, scope, scopes, scopeName })
220
+ return finishLockedRead({ document, context, scope, scopes, scopeName, runHooks, hasHooks })
176
221
  }
@@ -19,7 +19,7 @@ import deleteMethod from './rest-api-plugin-methods/delete.js'
19
19
  import postManyMethod from './rest-api-plugin-methods/post-many.js'
20
20
  import { patchManyMethod, patchWhereIdsMethod, patchWhereMethod } from './rest-api-plugin-methods/patch-many.js'
21
21
  import { deleteManyMethod, deleteWhereMethod } from './rest-api-plugin-methods/delete-many.js'
22
- import { claimMethod, lockByIdsMethod, queryAllMethod, scopedQueryMethod } from './rest-api-plugin-methods/scoped-reads.js'
22
+ import { claimMethod, lockByIdsMethod, lockWhereMethod, queryAllMethod, scopedQueryMethod } from './rest-api-plugin-methods/scoped-reads.js'
23
23
  import enrichAttributesMethod from './rest-api-plugin-methods/enrich-attributes.js'
24
24
  import checkPermissionsMethod from './rest-api-plugin-methods/check-permissions.js'
25
25
  import applyQueryFiltersMethod from './rest-api-plugin-methods/apply-query-filters.js'
@@ -112,6 +112,7 @@ export const RestApiPlugin = {
112
112
  addResourceMethod('queryAll', withAvailableContext(queryAllMethod))
113
113
  addResourceMethod('scopedQuery', withAvailableContext(scopedQueryMethod))
114
114
  addResourceMethod('lockByIds', withAvailableContext(lockByIdsMethod))
115
+ addResourceMethod('lockWhere', withAvailableContext(lockWhereMethod))
115
116
  addResourceMethod('claim', withAvailableContext(claimMethod))
116
117
 
117
118
  // Relationship methods
@@ -214,7 +214,7 @@ export const PositioningPlugin = {
214
214
  // create no positioned rows and change no positions.
215
215
  const refusePositions = ({ context, scopeName }) => {
216
216
  if (!enabled(scopeName)) return
217
- const moves = context.inputRecords.some(record => {
217
+ const moves = [config.field, config.beforeIdField].some(field => context.rawFields?.includes(field)) || context.inputRecords.some(record => {
218
218
  const attributes = record?.data?.attributes || {}
219
219
  return attributes[config.field] !== undefined || attributes[config.beforeIdField] !== undefined
220
220
  })
@@ -3,11 +3,16 @@ import type { ManagedTransaction } from './transactions.js'
3
3
  import type { DirectResourceId, InputResourceId, JsonApiResource, PlainResource, ResourceFormat, ResourceIdentifier } from './representations.js'
4
4
  import type { JsonApiWriteDocument, QueryParams, RemovedResourceOptions, SelectionParams } from './resource-methods.js'
5
5
 
6
- // Many-row writes return nothing by default; 'minimal' returns identifiers, 'full' the records.
7
- export type ManyRowReturning = 'none' | 'minimal' | 'full'
6
+ // Many-row writes return nothing by default; 'minimal' returns identifiers, 'full' the records,
7
+ // 'count' the number of rows written.
8
+ export type ManyRowReturning = 'none' | 'minimal' | 'full' | 'count'
9
+ export type ManyRowCount = { meta: { count: number } }
8
10
  export type ManyRowResult<Format extends ResourceFormat, Returning extends ManyRowReturning, Fields extends object, Type extends string> =
9
11
  Returning extends 'none' ? void
12
+ : Returning extends 'count' ? ManyRowCount
10
13
  : { data: Array<Returning extends 'minimal' ? ResourceIdentifier<Type> : Format extends 'plain' ? PlainResource<Fields> : JsonApiResource<Fields, Type>> }
14
+ type DeleteResult<Returning extends 'none' | 'minimal' | 'count', Type extends string> =
15
+ Returning extends 'none' ? void : Returning extends 'count' ? ManyRowCount : { data: ResourceIdentifier<Type>[] }
11
16
  export type RecordsResult<Format extends ResourceFormat, Fields extends object, Type extends string> =
12
17
  { data: Array<Format extends 'plain' ? PlainResource<Fields> : JsonApiResource<Fields, Type>>; included?: JsonApiResource[] }
13
18
  // knex.raw values: accepted by many-row patches for stored, non-relationship fields.
@@ -27,12 +32,17 @@ export type ManyRowCreateInput<Input extends object, Type extends string> =
27
32
  { data: Array<Partial<Input> & { id?: InputResourceId }>; document?: never } |
28
33
  { data?: never; document: { data: Array<JsonApiWriteDocument<Input, Type>['data']> } }
29
34
  export interface UpsertOptions<Input extends object> {
30
- // Must include every ownership (autofilter) field.
31
- keys: Array<keyof Input & string>
35
+ // Must include every ownership (autofilter) field. Rows may not carry an id or repeat a key.
36
+ // ['id'] (or the idProperty) alone is allowed on a resource without ownership fields or a
37
+ // row policy; every row then carries its id.
38
+ keys: Array<keyof Input & string> | ['id']
39
+ // merge also runs the patch permission checks on every row it updates.
32
40
  action: 'ignore' | 'merge'
33
41
  // merge only: fields to update; default every stored field the rows supply but the keys.
34
42
  merge?: Array<keyof Input & string>
35
43
  }
44
+ // patchWhere and deleteWhere need filters, or all: true to act on every visible row.
45
+ export type WhereScope = { filters: Record<string, unknown>; all?: never } | { filters?: never; all: true }
36
46
  type Patchable<Input extends object> = { [Field in keyof Input]?: Input[Field] | RawValue }
37
47
  export type ManyRowPatchInput<Input extends object, Type extends string> =
38
48
  { data: Array<Patchable<Input> & { id: DirectResourceId }>; document?: never } |
@@ -54,7 +64,8 @@ export interface ScopedQueryParams {
54
64
  purpose?: 'report' | 'bulk-write' | 'lock' | 'claim'
55
65
  filters?: Record<string, unknown>
56
66
  }
57
- // A Knex builder that refuses writes, locks, table changes and clearing its predicates.
67
+ // A Knex builder over a derived table of the scoped rows. It refuses writes, locks,
68
+ // table changes, set operations, common table expressions and access to the client.
58
69
  export type ReadOnlyQueryBuilder = Knex.QueryBuilder
59
70
 
60
71
  export interface DataAccessMethods<
@@ -88,17 +99,18 @@ export interface DataAccessMethods<
88
99
  context?: Context
89
100
  ): Promise<ManyRowResult<Format, Returning, Fields, Type>>
90
101
  patchWhere<Format extends ResourceFormat = DefaultFormat, Returning extends ManyRowReturning = 'none'>(
91
- params: ManyRowWriteOptions<Format, Returning> & SharedPatchInput<Input, Type> & Omit<PatchConditionOptions<Input>, 'missing'> & { filters?: Record<string, unknown> },
102
+ params: ManyRowWriteOptions<Format, Returning> & SharedPatchInput<Input, Type> & Omit<PatchConditionOptions<Input>, 'missing'> & WhereScope,
92
103
  context?: Context
93
104
  ): Promise<ManyRowResult<Format, Returning, Fields, Type>>
94
- deleteMany<Returning extends 'none' | 'minimal' = 'none'>(
105
+ deleteMany<Returning extends 'none' | 'minimal' | 'count' = 'none'>(
95
106
  params: RemovedResourceOptions & { ids: DirectResourceId[]; transaction?: ManagedTransaction; returning?: Returning; chunkSize?: number; missing?: MissingRows },
96
107
  context?: Context
97
- ): Promise<Returning extends 'none' ? void : { data: ResourceIdentifier<Type>[] }>
98
- deleteWhere<Returning extends 'none' | 'minimal' = 'none'>(
99
- params: RemovedResourceOptions & { filters?: Record<string, unknown>; transaction?: ManagedTransaction; returning?: Returning; chunkSize?: number },
108
+ ): Promise<DeleteResult<Returning, Type>>
109
+ // Deletes the rows its selection finds and answers with those it deleted; it never throws for missing rows.
110
+ deleteWhere<Returning extends 'none' | 'minimal' | 'count' = 'none'>(
111
+ params: RemovedResourceOptions & WhereScope & { transaction?: ManagedTransaction; returning?: Returning; chunkSize?: number },
100
112
  context?: Context
101
- ): Promise<Returning extends 'none' ? void : { data: ResourceIdentifier<Type>[] }>
113
+ ): Promise<DeleteResult<Returning, Type>>
102
114
  // Locks the visible rows among ids (FOR UPDATE, id order) until the transaction ends.
103
115
  lockByIds<Format extends ResourceFormat = DefaultFormat>(params: {
104
116
  ids: DirectResourceId[]
@@ -107,6 +119,15 @@ export interface DataAccessMethods<
107
119
  format?: Format
108
120
  queryParams?: { fields?: Record<string, string> }
109
121
  }, context?: Context): Promise<RecordsResult<Format, Fields, Type>>
122
+ // Locks the visible rows the filters select (FOR UPDATE; id order, on MySQL the
123
+ // order of the forced index, which starts with the ownership or a filtered column),
124
+ // waiting for other transactions; throws RestApiRowLimitError past 1000 rows.
125
+ lockWhere<Format extends ResourceFormat = DefaultFormat>(params: {
126
+ transaction: ManagedTransaction | Knex.Transaction
127
+ filters?: Record<string, unknown>
128
+ format?: Format
129
+ queryParams?: { fields?: Record<string, string> }
130
+ }, context?: Context): Promise<RecordsResult<Format, Fields, Type>>
110
131
  // Takes up to limit (default 1, at most 1000) matching rows no other transaction holds.
111
132
  claim<Format extends ResourceFormat = DefaultFormat>(params: {
112
133
  transaction: ManagedTransaction | Knex.Transaction
package/types/errors.d.ts CHANGED
@@ -118,11 +118,12 @@ export class RestApiTemporalDataError extends RestApiError {
118
118
  }
119
119
 
120
120
  export class RestApiBulkWriteUnsupportedError extends RestApiError {
121
- constructor(options?: { resourceType?: string; operation?: string; reason?: string })
121
+ constructor(options?: { resourceType?: string; operation?: string; reason?: string; hooks?: string[] })
122
122
  code: 'REST_API_BULK_WRITE_UNSUPPORTED'
123
123
  type: 'rest_api_bulk_write_unsupported'
124
124
  statusCode: 422
125
- details: { resourceType?: string; operation?: string; reason?: string }
125
+ /** `hooks` names the per-record hooks that caused the refusal; HTTP responses leave details out. */
126
+ details: { resourceType?: string; operation?: string; reason?: string; hooks?: string[] }
126
127
  toJSON(): SerializedError & Pick<RestApiBulkWriteUnsupportedError, 'code' | 'type' | 'statusCode' | 'details'>
127
128
  }
128
129
 
@@ -154,9 +155,9 @@ export class RestApiScopedQueryReadOnlyError extends RestApiError {
154
155
  }
155
156
 
156
157
  export class RestApiRowLimitError extends RestApiError {
157
- constructor(options?: { resourceType?: string; rowLimit?: number })
158
+ constructor(options?: { resourceType?: string; rowLimit?: number; operation?: 'queryAll' | 'lockWhere' })
158
159
  code: 'REST_API_ROW_LIMIT'
159
160
  type: 'rest_api_row_limit'
160
- details: { resourceType?: string; rowLimit?: number }
161
+ details: { resourceType?: string; rowLimit?: number; operation: 'queryAll' | 'lockWhere' }
161
162
  toJSON(): SerializedError & Pick<RestApiRowLimitError, 'code' | 'type' | 'details'>
162
163
  }
@@ -189,6 +189,8 @@ export type ManyRowHookContext<Context extends object = Record<string, unknown>,
189
189
  readonly transaction: ManagedTransaction
190
190
  // JSON:API documents of the rows, in input order (creates and patches).
191
191
  readonly inputRecords?: Array<{ data: { id?: InputResourceId; attributes?: Record<string, unknown>; relationships?: Record<string, unknown> } }>
192
+ // Patches: fields some row sets with a knex.raw value (those values are not in inputRecords).
193
+ readonly rawFields?: string[]
192
194
  readonly ids?: Array<string | undefined>
193
195
  }
194
196