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,221 @@
1
+ // Reads that keep the resource's scope: the read-only scoped builder, every-row
2
+ // reads in cursor pages, and row locks inside a transaction.
3
+ import { RestApiBulkWriteUnsupportedError, RestApiRecordsNotFoundError, RestApiRowLimitError, RestApiValidationError } from '../../../lib/rest-api-errors.js'
4
+ import { readOnlyQueryBuilder } from '../lib/querying/read-only-query.js'
5
+ import { normalizeRecordAttributes } from '../lib/querying-writing/database-value-normalizers.js'
6
+ import { filterResponseFields, getResourceFieldset } from '../lib/querying-writing/field-utils.js'
7
+ import { getRequestedComputedFields, validateRequestedFieldsets } from '../lib/querying-writing/knex-field-helpers.js'
8
+ import { resolveFormat } from '../lib/querying-writing/response-options.js'
9
+ import { transformJsonApiToSimplified } from '../lib/querying-writing/simplified-helpers.js'
10
+ import { requireExistingResourceId } from '../lib/querying-writing/resource-id-normalization.js'
11
+ import { chunk } from '../lib/storage/many-row-data-helpers.js'
12
+ import { filterToOneResponseLinkage, snapshotReadRecord } from './common.js'
13
+ import { refuseDuplicateIds, requireLockTransaction, resolveMissing, validateFilters } from './many-row-common.js'
14
+
15
+ export const SCOPED_QUERY_PURPOSES = new Set(['report', 'bulk-write', 'lock', 'claim'])
16
+ const DEFAULT_QUERY_ALL_PAGE = 1000
17
+ const MAX_CLAIM = 1000
18
+ const MAX_LOCK_WHERE = 1000
19
+
20
+ const invalid = (field, message) => new RestApiValidationError(message, { fields: [field], violations: [{ field, rule: 'scoped_read', message }] })
21
+
22
+ /**
23
+ * The resource's table with ownership and row policy applied, as a read-only
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.
27
+ * @param {any} args
28
+ */
29
+ export async function scopedQueryMethod ({ params, context, vars, helpers, scope, scopes, runHooks, scopeName, api }) {
30
+ const { transaction, alias, purpose = 'report', filters, ...unknown } = params
31
+ if (Object.keys(unknown).length) throw invalid(Object.keys(unknown)[0], `Unknown scopedQuery options: ${Object.keys(unknown).join(', ')}`)
32
+ if (!SCOPED_QUERY_PURPOSES.has(purpose)) throw invalid('purpose', `purpose must be one of ${[...SCOPED_QUERY_PURPOSES].join(', ')}`)
33
+ if (alias !== undefined && (typeof alias !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(alias))) {
34
+ throw invalid('alias', 'alias must be an identifier')
35
+ }
36
+ if (typeof helpers.dataScopedQuery !== 'function') {
37
+ throw new RestApiBulkWriteUnsupportedError({ resourceType: scopeName, operation: 'scopedQuery', reason: 'its storage plugin provides no scoped builder' })
38
+ }
39
+ context.method = 'scopedQuery'
40
+ context.scopeName = scopeName
41
+ context.schemaInfo = scopes[scopeName].vars.schemaInfo
42
+ context.transaction = transaction
43
+ context.db = transaction
44
+ context.queryParams = { filters: validateFilters({ filters, scopeName, schemaInfo: context.schemaInfo, vars }) || {} }
45
+ // A caller who may not query the resource may not build reports on it either.
46
+ await scope.checkPermissions({ method: 'query', originalContext: { ...context, method: 'query', scopedQuery: true } })
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 }) }
50
+ }
51
+
52
+ /**
53
+ * Every row the filters select, read in cursor pages of `pageSize` rows.
54
+ * @param {any} args
55
+ */
56
+ export async function queryAllMethod ({ params, context, vars, scopeName, api }) {
57
+ const { queryParams = {}, transaction, pageSize = DEFAULT_QUERY_ALL_PAGE, rowLimit, format, ...unknown } = params
58
+ if (Object.keys(unknown).length) throw invalid(Object.keys(unknown)[0], `Unknown queryAll options: ${Object.keys(unknown).join(', ')}`)
59
+ if (!Number.isSafeInteger(pageSize) || pageSize < 1) throw invalid('pageSize', 'pageSize must be a positive integer')
60
+ if (rowLimit !== undefined && (!Number.isSafeInteger(rowLimit) || rowLimit < 1)) throw invalid('rowLimit', 'rowLimit must be a positive integer')
61
+ if (queryParams.page !== undefined) throw invalid('queryParams.page', 'queryAll pages by itself; leave out queryParams.page')
62
+ const resolvedFormat = resolveFormat(format, vars.format)
63
+ const sort = queryParams.sort?.length ? queryParams.sort : ['id']
64
+
65
+ const data = []
66
+ const included = new Map()
67
+ let cursor
68
+ do {
69
+ const page = await api.resources[scopeName].query({
70
+ format: resolvedFormat,
71
+ transaction,
72
+ pageSizeLimit: pageSize,
73
+ queryParams: { ...queryParams, sort, page: cursor ? { size: pageSize, after: cursor } : { size: pageSize } }
74
+ }, { ...context })
75
+ data.push(...page.data)
76
+ for (const resource of page.included || []) included.set(`${resource.type}:${resource.id}`, resource)
77
+ if (rowLimit !== undefined && data.length > rowLimit) throw new RestApiRowLimitError({ resourceType: scopeName, rowLimit })
78
+ cursor = page.meta?.pagination?.hasMore ? page.meta.pagination.cursor?.next : undefined
79
+ } while (cursor)
80
+ return resolvedFormat === 'jsonapi' && included.size ? { data, included: [...included.values()] } : { data }
81
+ }
82
+
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')
100
+ const computedFields = scope.vars.schemaInfo?.computed || {}
101
+ const requestedComputedFields = getRequestedComputedFields(scopeName, getResourceFieldset(context.queryParams.fields, scopeName), computedFields)
102
+ for (const entry of context.record.data) {
103
+ entry.attributes = await scope.enrichAttributes({
104
+ id: entry.id,
105
+ attributes: entry.attributes,
106
+ parentContext: context,
107
+ requestedComputedFields,
108
+ isMainResource: true,
109
+ computedDependencies: context.computedDependencies
110
+ })
111
+ }
112
+ await runHooks('finish')
113
+ await runHooks('finishQuery')
114
+ await filterToOneResponseLinkage(context, scopes)
115
+ const record = normalizeRecordAttributes(context.record, scopes, { source: 'response' })
116
+ filterResponseFields(record, context.queryParams.fields)
117
+ if (context.format !== 'plain') return { data: record.data }
118
+ const schemaInfo = scope.vars.schemaInfo
119
+ return transformJsonApiToSimplified({ record }, {
120
+ context: { schemaStructure: schemaInfo.schemaInstance.structure, schemaRelationships: schemaInfo.schemaRelationships, scopes }
121
+ })
122
+ }
123
+
124
+ async function setupLockRead ({ params, context, vars, helpers, scopes, scopeName }, operation, storageHelper) {
125
+ requireLockTransaction({ transaction: params.transaction, scopeName, operation })
126
+ if (typeof helpers[storageHelper] !== 'function') {
127
+ throw new RestApiBulkWriteUnsupportedError({ resourceType: scopeName, operation, reason: 'its storage plugin has no row locks (AnyAPI storage takes single writes)' })
128
+ }
129
+ context.method = operation
130
+ context.scopeName = scopeName
131
+ context.schemaInfo = scopes[scopeName].vars.schemaInfo
132
+ context.transaction = params.transaction
133
+ context.db = params.transaction
134
+ context.format = resolveFormat(params.format, vars.format)
135
+ context.simplified = context.format === 'plain'
136
+ const fields = params.queryParams?.fields
137
+ if (params.queryParams !== undefined && Object.keys(params.queryParams).some(key => key !== 'fields')) {
138
+ throw invalid('queryParams', `${operation} takes only queryParams.fields`)
139
+ }
140
+ context.queryParams = { fields: fields ?? {} }
141
+ await validateRequestedFieldsets(context, scopes)
142
+ }
143
+
144
+ /**
145
+ * Locks the visible rows among `ids` until the transaction ends, in id order,
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).
150
+ * @param {any} args
151
+ */
152
+ export async function lockByIdsMethod (args) {
153
+ const { params, context, helpers, scope, scopes, runHooks, hasHooks, scopeName, scopeOptions, vars } = args
154
+ await setupLockRead(args, 'lockByIds', 'dataLockByIds')
155
+ if (!Array.isArray(params.ids) || !params.ids.length) throw invalid('ids', 'ids must be a non-empty array')
156
+ const ids = params.ids.map(id => requireExistingResourceId(id, { scopeOptions, vars, scopeName }))
157
+ const storageAdapter = helpers.getStorageAdapter(scopeName)
158
+ refuseDuplicateIds(ids, storageAdapter, 'ids')
159
+ const missing = resolveMissing(params.missing)
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)
170
+ const document = { 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 })
176
+ }
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 })
195
+ }
196
+
197
+ /**
198
+ * Takes up to `limit` rows the filters select that no other transaction holds
199
+ * (SELECT … FOR UPDATE SKIP LOCKED), for job queues.
200
+ * @param {any} args
201
+ */
202
+ export async function claimMethod (args) {
203
+ const { params, context, vars, helpers, scope, scopes, runHooks, hasHooks, scopeName } = args
204
+ await setupLockRead(args, 'claim', 'dataClaim')
205
+ const limit = params.limit ?? 1
206
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_CLAIM) throw invalid('limit', `limit must be an integer from 1 to ${MAX_CLAIM}`)
207
+ const filters = validateFilters({ filters: params.filters, scopeName, schemaInfo: context.schemaInfo, vars })
208
+ const sortParam = params.sort ?? ['id']
209
+ if (!Array.isArray(sortParam)) throw invalid('sort', 'sort must be an array of fields')
210
+ const schema = context.schemaInfo.schemaStructure
211
+ const sort = sortParam.map((entry, index) => {
212
+ const field = typeof entry === 'string' ? entry.replace(/^-/, '') : null
213
+ if (!field || (field !== 'id' && (!schema[field] || schema[field].virtual || schema[field].computed))) {
214
+ throw invalid(`sort.${index}`, `'${entry}' is not a stored field`)
215
+ }
216
+ return { field, direction: entry.startsWith('-') ? 'desc' : 'asc' }
217
+ })
218
+ await scope.checkPermissions({ method: 'query', originalContext: { ...context, method: 'query', lock: true } })
219
+ const document = await helpers.dataClaim({ scopeName, context, runHooks, filters, sort, limit })
220
+ return finishLockedRead({ document, context, scope, scopes, scopeName, runHooks, hasHooks })
221
+ }
@@ -16,6 +16,10 @@ import postMethod from './rest-api-plugin-methods/post.js'
16
16
  import putMethod from './rest-api-plugin-methods/put.js'
17
17
  import patchMethod from './rest-api-plugin-methods/patch.js'
18
18
  import deleteMethod from './rest-api-plugin-methods/delete.js'
19
+ import postManyMethod from './rest-api-plugin-methods/post-many.js'
20
+ import { patchManyMethod, patchWhereIdsMethod, patchWhereMethod } from './rest-api-plugin-methods/patch-many.js'
21
+ import { deleteManyMethod, deleteWhereMethod } from './rest-api-plugin-methods/delete-many.js'
22
+ import { claimMethod, lockByIdsMethod, lockWhereMethod, queryAllMethod, scopedQueryMethod } from './rest-api-plugin-methods/scoped-reads.js'
19
23
  import enrichAttributesMethod from './rest-api-plugin-methods/enrich-attributes.js'
20
24
  import checkPermissionsMethod from './rest-api-plugin-methods/check-permissions.js'
21
25
  import applyQueryFiltersMethod from './rest-api-plugin-methods/apply-query-filters.js'
@@ -98,6 +102,19 @@ export const RestApiPlugin = {
98
102
  addResourceMethod('patch', withWriteOutcome(patchMethod))
99
103
  addResourceMethod('delete', withWriteOutcome(deleteMethod))
100
104
 
105
+ // Many-row writes, every-row reads, the scoped builder and row locks
106
+ addResourceMethod('postMany', withWriteOutcome(postManyMethod))
107
+ addResourceMethod('patchMany', withWriteOutcome(patchManyMethod))
108
+ addResourceMethod('patchWhereIds', withWriteOutcome(patchWhereIdsMethod))
109
+ addResourceMethod('patchWhere', withWriteOutcome(patchWhereMethod))
110
+ addResourceMethod('deleteMany', withWriteOutcome(deleteManyMethod))
111
+ addResourceMethod('deleteWhere', withWriteOutcome(deleteWhereMethod))
112
+ addResourceMethod('queryAll', withAvailableContext(queryAllMethod))
113
+ addResourceMethod('scopedQuery', withAvailableContext(scopedQueryMethod))
114
+ addResourceMethod('lockByIds', withAvailableContext(lockByIdsMethod))
115
+ addResourceMethod('lockWhere', withAvailableContext(lockWhereMethod))
116
+ addResourceMethod('claim', withAvailableContext(claimMethod))
117
+
101
118
  // Relationship methods
102
119
  addResourceMethod('getRelationship', withAvailableContext(getRelationshipMethod))
103
120
  addResourceMethod('getRelated', withAvailableContext(getRelatedMethod))
@@ -3,7 +3,7 @@ import { calculatePosition, getInitialPosition, isValidPosition } from './lib/fr
3
3
  import { createStorageAdapter } from './lib/storage/storage-adapter.js'
4
4
  import { hasKnexTableIndex } from './lib/dbIntrospection.js'
5
5
  import { applyDatabaseReadOptions, databaseIdentityExpression } from './lib/querying-writing/database-value-normalizers.js'
6
- import { RestApiValidationError } from '../../lib/rest-api-errors.js'
6
+ import { RestApiBulkWriteUnsupportedError, RestApiValidationError } from '../../lib/rest-api-errors.js'
7
7
  import { errorMessage, getOperationDiagnosticContext, isRestApiError } from '../../lib/error-context.js'
8
8
  import { createEnhancedLogger } from '../../lib/enhanced-logger.js'
9
9
 
@@ -104,7 +104,7 @@ export const PositioningPlugin = {
104
104
  }
105
105
  })
106
106
 
107
- addHook('beforeProcessing', 'lock-position-resource', {}, async ({ context, scopeName }) => {
107
+ addHook('beforeProcessing', 'lock-position-resource', { batch: true }, async ({ context, scopeName }) => {
108
108
  if (!enabled(scopeName)) return
109
109
  const adapter = adapterFor(scopeName)
110
110
  const descriptor = context.schemaInfo.descriptor
@@ -124,7 +124,7 @@ export const PositioningPlugin = {
124
124
  }
125
125
  })
126
126
 
127
- addHook('beforeSchemaValidate', 'process-beforeid', {}, ({ context, scopeName }) => {
127
+ addHook('beforeSchemaValidate', 'process-beforeid', { batch: true }, ({ context, scopeName }) => {
128
128
  if (!enabled(scopeName)) return
129
129
  const attributes = context.inputRecord.data.attributes || {}
130
130
  context.positioningBeforeId = attributes[config.beforeIdField]
@@ -207,8 +207,26 @@ export const PositioningPlugin = {
207
207
  }
208
208
 
209
209
  for (const method of ['Post', 'Put', 'Patch']) {
210
- addHook(`beforeDataCall${method}`, `calculate-position-${method.toLowerCase()}`, {}, calculateAndSetPosition)
210
+ addHook(`beforeDataCall${method}`, `calculate-position-${method.toLowerCase()}`, { batch: true }, calculateAndSetPosition)
211
211
  }
212
+
213
+ // Positions are computed per row against their neighbours: many-row writes
214
+ // create no positioned rows and change no positions.
215
+ const refusePositions = ({ context, scopeName }) => {
216
+ if (!enabled(scopeName)) return
217
+ const moves = [config.field, config.beforeIdField].some(field => context.rawFields?.includes(field)) || context.inputRecords.some(record => {
218
+ const attributes = record?.data?.attributes || {}
219
+ return attributes[config.field] !== undefined || attributes[config.beforeIdField] !== undefined
220
+ })
221
+ if (context.method !== 'postMany' && !moves) return
222
+ throw new RestApiBulkWriteUnsupportedError({
223
+ resourceType: scopeName,
224
+ operation: context.method,
225
+ reason: 'it is positioned; create and move positioned rows with single writes'
226
+ })
227
+ }
228
+ addHook('beforeProcessingPostMany', 'refuse-positions-post-many', {}, refusePositions)
229
+ addHook('beforeProcessingPatchMany', 'refuse-positions-patch-many', {}, refusePositions)
212
230
  addHook('beforeDataQuery', 'apply-position-sort', {}, ({ context, scopeName }) => {
213
231
  if (enabled(scopeName) && !context.queryParams.sort?.length) context.queryParams.sort = [config.field]
214
232
  })
@@ -42,6 +42,14 @@ export const QueryProjectionsPlugin = {
42
42
  context.queryFields = fields && typeof fields === 'object' && !Array.isArray(fields) ? { ...fields } : fields
43
43
  })
44
44
 
45
- addHook('beforeSchemaValidate', 'strip-query-field-input', {}, stripQueryFieldInput)
45
+ addHook('beforeSchemaValidate', 'strip-query-field-input', { batch: true }, stripQueryFieldInput)
46
+ // Batch form: strip query fields from every row of a many-row write.
47
+ const stripEveryRow = async ({ context, scopeName, scopes }) => {
48
+ for (const inputRecord of context.inputRecords) {
49
+ await stripQueryFieldInput({ context: { method: context.method, inputRecord }, scopeName, scopes })
50
+ }
51
+ }
52
+ addHook('beforeProcessingPostMany', 'strip-query-field-input-post-many', {}, stripEveryRow)
53
+ addHook('beforeProcessingPatchMany', 'strip-query-field-input-patch-many', {}, stripEveryRow)
46
54
  }
47
55
  }
@@ -1,4 +1,5 @@
1
1
  import { createStorageAdapterUtilities } from './lib/querying/storage-adapter-utils.js'
2
+ import { assertGenuineSystemFlag, isSystemContext } from '../../lib/system-context.js'
2
3
 
3
4
  const INLINE_POLICY_NAME = '<inline>'
4
5
 
@@ -92,6 +93,7 @@ export const RowPolicyPlugin = {
92
93
 
93
94
  const compiledPolicy = scopes[scopeName]?.vars?.rowPolicy
94
95
  if (!compiledPolicy) return
96
+ assertGenuineSystemFlag(context)
95
97
 
96
98
  const adapterUtils = createStorageAdapterUtilities(hookParams, {
97
99
  getStorageAdapter: helpers.getStorageAdapter
@@ -117,6 +119,8 @@ export const RowPolicyPlugin = {
117
119
  scopeName,
118
120
  tableName,
119
121
  queryPurpose,
122
+ // True only for a context from createSystemContext; policies decide what it may see.
123
+ system: isSystemContext(context),
120
124
  db,
121
125
  isAnyApi,
122
126
  storageAdapter,
@@ -5,6 +5,8 @@ import { errorMessage, getOperationDiagnosticContext } from '../../lib/error-con
5
5
  import { queryConstraint } from './lib/querying/query-constraint.js'
6
6
 
7
7
  const pendingRoomChanges = new WeakMap()
8
+ // Ids a many-row write matches against one subscription per query.
9
+ const MANY_ROW_MATCH_CHUNK = 1000
8
10
 
9
11
  // Preserve room mutation order when an adapter completes joins/leaves asynchronously.
10
12
  async function changeRoom (socket, method, room) {
@@ -567,7 +569,7 @@ export const SocketIOPlugin = {
567
569
  return io
568
570
  }
569
571
 
570
- addHook('beforeDataCall', 'socketio-capture-before', {}, async ({ context }) => {
572
+ addHook('beforeDataCall', 'socketio-capture-before', { batch: true }, async ({ context }) => {
571
573
  pendingChanges.delete(context)
572
574
  if (!io || !['post', 'put', 'patch', 'delete', 'postRelationship', 'deleteRelationship'].includes(context.method)) return
573
575
  const { scopeName, id, transaction } = context
@@ -584,7 +586,7 @@ export const SocketIOPlugin = {
584
586
  pendingChanges.set(context, candidates)
585
587
  })
586
588
 
587
- addHook('finish', 'socketio-broadcast', {}, async ({ context }) => {
589
+ addHook('finish', 'socketio-broadcast', { batch: true }, async ({ context }) => {
588
590
  const candidates = pendingChanges.get(context)
589
591
  if (!candidates) return
590
592
  pendingChanges.delete(context)
@@ -607,6 +609,82 @@ export const SocketIOPlugin = {
607
609
  }
608
610
  })
609
611
 
612
+ // Batch form: one subscription query per subscription and chunk of ids, before
613
+ // and after the write, then one notification per changed record after commit.
614
+ const subscriptionsOf = async scopeName => {
615
+ const subscriptions = []
616
+ for (const socket of await io.in(`${scopeName}:updates`).fetchSockets()) {
617
+ for (const subscription of socket.data.subscriptions || []) {
618
+ if (subscription.resource === scopeName) subscriptions.push({ socketId: socket.id, subscription })
619
+ }
620
+ }
621
+ return subscriptions
622
+ }
623
+ const matchingIds = async ({ subscription, scopeName, ids, transaction }) => {
624
+ const matched = new Set()
625
+ for (let offset = 0; offset < ids.length; offset += MANY_ROW_MATCH_CHUNK) {
626
+ const part = ids.slice(offset, offset + MANY_ROW_MATCH_CHUNK)
627
+ try {
628
+ const result = await api.resources[scopeName].query({
629
+ format: 'jsonapi',
630
+ transaction,
631
+ pageSizeLimit: part.length,
632
+ queryParams: { filters: subscription.filters, include: [], page: { size: part.length } },
633
+ [queryConstraint]: { scopeName, idsQuery: part }
634
+ }, { ...subscription.context, auth: subscription.auth })
635
+ for (const record of result.data) matched.add(String(record.id))
636
+ } catch (error) {
637
+ try {
638
+ await createEnhancedLogger(log, { schemaInfo: scopes[scopeName]?.vars?.schemaInfo }).warn('Socket.IO subscription query failed', {
639
+ ...getOperationDiagnosticContext({ transaction }, { phase: 'subscriptionMatch', method: 'query', scopeName, backend: api.knex?.instance?.client?.config?.client }),
640
+ subscriptionId: subscription.id,
641
+ error
642
+ })
643
+ } catch { /* Warning failure must preserve the existing non-match result. */ }
644
+ }
645
+ }
646
+ return matched
647
+ }
648
+ const captureBeforeMany = async ({ context }) => {
649
+ pendingChanges.delete(context)
650
+ if (!io) return
651
+ const candidates = []
652
+ for (const { socketId, subscription } of await subscriptionsOf(context.scopeName)) {
653
+ const before = context.method === 'postMany'
654
+ ? new Set()
655
+ : await matchingIds({ subscription, scopeName: context.scopeName, ids: context.ids, transaction: context.transaction })
656
+ candidates.push({ socketId, subscription, before })
657
+ }
658
+ pendingChanges.set(context, candidates)
659
+ }
660
+ const broadcastMany = async ({ context }) => {
661
+ const candidates = pendingChanges.get(context)
662
+ if (!candidates) return
663
+ pendingChanges.delete(context)
664
+ const ids = (context.ids || []).filter(id => id !== undefined)
665
+ if (!io || !ids.length) return
666
+ const { scopeName, transaction } = context
667
+ const method = { postMany: 'post', deleteMany: 'delete', deleteWhere: 'delete' }[context.method] || 'patch'
668
+ const recipientsById = new Map(ids.map(id => [id, []]))
669
+ for (const { socketId, subscription, before } of candidates) {
670
+ const after = method === 'delete' ? new Set() : await matchingIds({ subscription, scopeName, ids, transaction })
671
+ for (const id of ids) {
672
+ if (before.has(id) || after.has(id)) recipientsById.get(id).push({ socketId, subscriptionId: subscription.id, generation: subscription.generation })
673
+ }
674
+ }
675
+ const changes = [...recipientsById].filter(([, recipients]) => recipients.length)
676
+ .map(([id, recipients]) => ({ method, scopeName, id, isCreate: method === 'post', recipients }))
677
+ if (!changes.length) return
678
+ if (!pendingBroadcasts.has(transaction)) pendingBroadcasts.set(transaction, [])
679
+ pendingBroadcasts.get(transaction).push(...changes)
680
+ }
681
+ addHook('beforeDataCallPostMany', 'socketio-capture-before-post-many', {}, captureBeforeMany)
682
+ addHook('beforeDataCallPatchMany', 'socketio-capture-before-patch-many', {}, captureBeforeMany)
683
+ addHook('beforeDataCallDeleteMany', 'socketio-capture-before-delete-many', {}, captureBeforeMany)
684
+ addHook('afterDataCallPostMany', 'socketio-broadcast-post-many', {}, broadcastMany)
685
+ addHook('afterDataCallPatchMany', 'socketio-broadcast-patch-many', {}, broadcastMany)
686
+ addHook('afterDataCallDeleteMany', 'socketio-broadcast-delete-many', {}, broadcastMany)
687
+
610
688
  addHook('afterCommit', 'socketio-broadcast-deferred', {}, async ({ context }) => {
611
689
  if (!context?.transaction) return
612
690
  const broadcasts = pendingBroadcasts.get(context.transaction)
@@ -22,6 +22,8 @@ export interface CompiledAutoFilter<Context extends object = Record<string, unkn
22
22
  resolve: AutoFilterResolver<Context, Api>
23
23
  resolverName: string
24
24
  required: boolean
25
+ // NULL marks a row every owner reads; writes, locks and claims see only the owner's rows.
26
+ shared: boolean
25
27
  relationshipName: string | null
26
28
  relationshipType: string | null
27
29
  inputPath: string
@@ -30,6 +32,8 @@ export interface CompiledAutoFilter<Context extends object = Record<string, unkn
30
32
  export type AutoFilterDefinition<Context extends object = Record<string, unknown>, Api extends object = object> = {
31
33
  field: string
32
34
  required?: boolean
35
+ // The field must be nullable: NULL rows are shared reference rows.
36
+ shared?: boolean
33
37
  } & (
34
38
  { resolve: string | AutoFilterResolver<Context, Api>; resolver?: string | AutoFilterResolver<Context, Api> }
35
39
  | { resolve?: undefined | null; resolver: string | AutoFilterResolver<Context, Api> }
@@ -52,6 +56,6 @@ export interface AutoFilterRegistry {
52
56
  getConfig(): { presets: string[]; resolvers: string[] }
53
57
  getScopeConfig(scopeName: string): {
54
58
  preset: string | null
55
- filters: Array<{ field: string; resolver: string; required: boolean }>
59
+ filters: Array<{ field: string; resolver: string; required: boolean; shared: boolean }>
56
60
  } | null
57
61
  }
@@ -0,0 +1,141 @@
1
+ import type { Knex } from 'knex'
2
+ import type { ManagedTransaction } from './transactions.js'
3
+ import type { DirectResourceId, InputResourceId, JsonApiResource, PlainResource, ResourceFormat, ResourceIdentifier } from './representations.js'
4
+ import type { JsonApiWriteDocument, QueryParams, RemovedResourceOptions, SelectionParams } from './resource-methods.js'
5
+
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 } }
10
+ export type ManyRowResult<Format extends ResourceFormat, Returning extends ManyRowReturning, Fields extends object, Type extends string> =
11
+ Returning extends 'none' ? void
12
+ : Returning extends 'count' ? ManyRowCount
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>[] }
16
+ export type RecordsResult<Format extends ResourceFormat, Fields extends object, Type extends string> =
17
+ { data: Array<Format extends 'plain' ? PlainResource<Fields> : JsonApiResource<Fields, Type>>; included?: JsonApiResource[] }
18
+ // knex.raw values: accepted by many-row patches for stored, non-relationship fields.
19
+ export type RawValue = Knex.Raw
20
+ // Ids outside the caller's scope (or missing) throw RestApiRecordsNotFoundError, or are skipped.
21
+ export type MissingRows = 'throw' | 'skip'
22
+
23
+ export interface ManyRowWriteOptions<Format extends ResourceFormat, Returning extends ManyRowReturning> extends RemovedResourceOptions {
24
+ transaction?: ManagedTransaction
25
+ format?: Format
26
+ returning?: Returning
27
+ // Rows per statement (default 500), lowered further to the dialect's parameter limit.
28
+ chunkSize?: number
29
+ queryParams?: SelectionParams
30
+ }
31
+ export type ManyRowCreateInput<Input extends object, Type extends string> =
32
+ { data: Array<Partial<Input> & { id?: InputResourceId }>; document?: never } |
33
+ { data?: never; document: { data: Array<JsonApiWriteDocument<Input, Type>['data']> } }
34
+ export interface UpsertOptions<Input extends object> {
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.
40
+ action: 'ignore' | 'merge'
41
+ // merge only: fields to update; default every stored field the rows supply but the keys.
42
+ merge?: Array<keyof Input & string>
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 }
46
+ type Patchable<Input extends object> = { [Field in keyof Input]?: Input[Field] | RawValue }
47
+ export type ManyRowPatchInput<Input extends object, Type extends string> =
48
+ { data: Array<Patchable<Input> & { id: DirectResourceId }>; document?: never } |
49
+ { data?: never; document: { data: Array<JsonApiWriteDocument<Input, Type>['data'] & { id: DirectResourceId }> } }
50
+ export type SharedPatchInput<Input extends object, Type extends string> =
51
+ { data: Patchable<Input> & { id?: never }; document?: never } |
52
+ { data?: never; document: { data: Omit<JsonApiWriteDocument<Input, Type>['data'], 'id'> } }
53
+ export interface PatchConditionOptions<Input extends object> {
54
+ // Compare-and-set: only rows whose stored values equal these are changed; the result lists them.
55
+ onlyIf?: { [Field in keyof Input]?: Input[Field] | null }
56
+ missing?: MissingRows
57
+ }
58
+
59
+ export interface ScopedQueryParams {
60
+ transaction?: Knex.Transaction
61
+ // Table alias for column references; default the table name.
62
+ alias?: string
63
+ // report (default) sees shared rows; bulk-write, lock and claim see only the owner's.
64
+ purpose?: 'report' | 'bulk-write' | 'lock' | 'claim'
65
+ filters?: Record<string, unknown>
66
+ }
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.
69
+ export type ReadOnlyQueryBuilder = Knex.QueryBuilder
70
+
71
+ export interface DataAccessMethods<
72
+ Fields extends object = Record<string, unknown>,
73
+ Input extends object = Fields,
74
+ Type extends string = string,
75
+ DefaultFormat extends ResourceFormat = 'plain',
76
+ Context extends object = object
77
+ > {
78
+ // Resolves to { query }: a builder is thenable, so it cannot be the promise's value.
79
+ scopedQuery(params?: ScopedQueryParams, context?: Context): Promise<{ query: ReadOnlyQueryBuilder }>
80
+ queryAll<Format extends ResourceFormat = DefaultFormat>(params?: {
81
+ transaction?: Knex.Transaction
82
+ format?: Format
83
+ queryParams?: Omit<QueryParams, 'page'>
84
+ // Rows per page (default 1000).
85
+ pageSize?: number
86
+ // Throws RestApiRowLimitError past this many rows.
87
+ rowLimit?: number
88
+ }, context?: Context): Promise<RecordsResult<Format, Fields, Type>>
89
+ postMany<Format extends ResourceFormat = DefaultFormat, Returning extends ManyRowReturning = 'none'>(
90
+ params: ManyRowWriteOptions<Format, Returning> & ManyRowCreateInput<Input, Type> & { onConflict?: UpsertOptions<Input> },
91
+ context?: Context
92
+ ): Promise<ManyRowResult<Format, Returning, Fields, Type>>
93
+ patchMany<Format extends ResourceFormat = DefaultFormat, Returning extends ManyRowReturning = 'none'>(
94
+ params: ManyRowWriteOptions<Format, Returning> & ManyRowPatchInput<Input, Type> & PatchConditionOptions<Input>,
95
+ context?: Context
96
+ ): Promise<ManyRowResult<Format, Returning, Fields, Type>>
97
+ patchWhereIds<Format extends ResourceFormat = DefaultFormat, Returning extends ManyRowReturning = 'none'>(
98
+ params: ManyRowWriteOptions<Format, Returning> & SharedPatchInput<Input, Type> & PatchConditionOptions<Input> & { ids: DirectResourceId[] },
99
+ context?: Context
100
+ ): Promise<ManyRowResult<Format, Returning, Fields, Type>>
101
+ patchWhere<Format extends ResourceFormat = DefaultFormat, Returning extends ManyRowReturning = 'none'>(
102
+ params: ManyRowWriteOptions<Format, Returning> & SharedPatchInput<Input, Type> & Omit<PatchConditionOptions<Input>, 'missing'> & WhereScope,
103
+ context?: Context
104
+ ): Promise<ManyRowResult<Format, Returning, Fields, Type>>
105
+ deleteMany<Returning extends 'none' | 'minimal' | 'count' = 'none'>(
106
+ params: RemovedResourceOptions & { ids: DirectResourceId[]; transaction?: ManagedTransaction; returning?: Returning; chunkSize?: number; missing?: MissingRows },
107
+ context?: Context
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 },
112
+ context?: Context
113
+ ): Promise<DeleteResult<Returning, Type>>
114
+ // Locks the visible rows among ids (FOR UPDATE, id order) until the transaction ends.
115
+ lockByIds<Format extends ResourceFormat = DefaultFormat>(params: {
116
+ ids: DirectResourceId[]
117
+ transaction: ManagedTransaction | Knex.Transaction
118
+ missing?: MissingRows
119
+ format?: Format
120
+ queryParams?: { fields?: Record<string, string> }
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>>
131
+ // Takes up to limit (default 1, at most 1000) matching rows no other transaction holds.
132
+ claim<Format extends ResourceFormat = DefaultFormat>(params: {
133
+ transaction: ManagedTransaction | Knex.Transaction
134
+ filters?: Record<string, unknown>
135
+ // Stored fields, '-' for descending; default ['id'].
136
+ sort?: string[]
137
+ limit?: number
138
+ format?: Format
139
+ queryParams?: { fields?: Record<string, string> }
140
+ }, context?: Context): Promise<RecordsResult<Format, Fields, Type>>
141
+ }