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
package/types/errors.d.ts CHANGED
@@ -116,3 +116,48 @@ export class RestApiTemporalDataError extends RestApiError {
116
116
  details: { field?: string; resourceType?: string; fieldType?: string; source: string }
117
117
  toJSON(): SerializedError & Pick<RestApiTemporalDataError, 'code' | 'type' | 'statusCode' | 'details'>
118
118
  }
119
+
120
+ export class RestApiBulkWriteUnsupportedError extends RestApiError {
121
+ constructor(options?: { resourceType?: string; operation?: string; reason?: string; hooks?: string[] })
122
+ code: 'REST_API_BULK_WRITE_UNSUPPORTED'
123
+ type: 'rest_api_bulk_write_unsupported'
124
+ statusCode: 422
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[] }
127
+ toJSON(): SerializedError & Pick<RestApiBulkWriteUnsupportedError, 'code' | 'type' | 'statusCode' | 'details'>
128
+ }
129
+
130
+ // Reports only how many requested records are missing or outside the caller's scope.
131
+ export class RestApiRecordsNotFoundError extends RestApiResourceError {
132
+ constructor(options?: { resourceType?: string; missing?: number })
133
+ subtype: 'not_found'
134
+ details: { resourceType?: string; missing?: number }
135
+ }
136
+
137
+ export class RestApiBulkInsertIdsError extends RestApiError {
138
+ constructor(options?: { resourceType?: string; expected?: number; verified?: number })
139
+ code: 'REST_API_BULK_INSERT_IDS'
140
+ type: 'rest_api_bulk_insert_ids'
141
+ details: { resourceType?: string; expected?: number; verified?: number }
142
+ toJSON(): SerializedError & Pick<RestApiBulkInsertIdsError, 'code' | 'type' | 'details'>
143
+ }
144
+
145
+ export class RestApiLockOutsideTransactionError extends RestApiValidationError {
146
+ constructor(options?: { resourceType?: string; operation?: string })
147
+ }
148
+
149
+ export class RestApiScopedQueryReadOnlyError extends RestApiError {
150
+ constructor(options?: { resourceType?: string; method?: string })
151
+ code: 'REST_API_SCOPED_QUERY_READ_ONLY'
152
+ type: 'rest_api_scoped_query_read_only'
153
+ details: { resourceType?: string; method?: string }
154
+ toJSON(): SerializedError & Pick<RestApiScopedQueryReadOnlyError, 'code' | 'type' | 'details'>
155
+ }
156
+
157
+ export class RestApiRowLimitError extends RestApiError {
158
+ constructor(options?: { resourceType?: string; rowLimit?: number; operation?: 'queryAll' | 'lockWhere' })
159
+ code: 'REST_API_ROW_LIMIT'
160
+ type: 'rest_api_row_limit'
161
+ details: { resourceType?: string; rowLimit?: number; operation: 'queryAll' | 'lockWhere' }
162
+ toJSON(): SerializedError & Pick<RestApiRowLimitError, 'code' | 'type' | 'details'>
163
+ }
@@ -169,10 +169,31 @@ export interface HookContextMap<Context extends object = Record<string, unknown>
169
169
  finishPostRelationship: RelationshipHookContext<Context, 'postRelationship'>
170
170
  finishPatchRelationship: RelationshipHookContext<Context, 'patchRelationship'>
171
171
  finishDeleteRelationship: RelationshipHookContext<Context, 'deleteRelationship'>
172
+ // Many-row operations: once per call. ids are the affected records' ids as strings
173
+ // (for a create, set from afterDataCallPostMany; undefined for rows an ignore skipped).
174
+ beforeProcessingPostMany: ManyRowHookContext<Context, 'postMany'>
175
+ beforeDataCallPostMany: ManyRowHookContext<Context, 'postMany'>
176
+ afterDataCallPostMany: ManyRowHookContext<Context, 'postMany'>
177
+ beforeProcessingPatchMany: ManyRowHookContext<Context, 'patchMany' | 'patchWhereIds' | 'patchWhere'>
178
+ beforeDataCallPatchMany: ManyRowHookContext<Context, 'patchMany' | 'patchWhereIds' | 'patchWhere'>
179
+ afterDataCallPatchMany: ManyRowHookContext<Context, 'patchMany' | 'patchWhereIds' | 'patchWhere'>
180
+ beforeDataCallDeleteMany: ManyRowHookContext<Context, 'deleteMany' | 'deleteWhere'>
181
+ afterDataCallDeleteMany: ManyRowHookContext<Context, 'deleteMany' | 'deleteWhere'>
172
182
  afterCommit: HookContext<Context>
173
183
  afterRollback: HookContext<Context>
174
184
  }
175
185
 
186
+ export type ManyRowHookContext<Context extends object = Record<string, unknown>, Method extends string = string> = HookContext<Context> & {
187
+ readonly method: Method
188
+ readonly scopeName: string
189
+ readonly transaction: ManagedTransaction
190
+ // JSON:API documents of the rows, in input order (creates and patches).
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[]
194
+ readonly ids?: Array<string | undefined>
195
+ }
196
+
176
197
  export type KnownHookName = keyof HookContextMap
177
198
  export type HookArguments<Event extends KnownHookName, Context extends object = Record<string, unknown>> =
178
199
  Omit<RuntimeArguments, 'context'> & { context: HookContextMap<Context>[Event] }
@@ -25,6 +25,8 @@ export interface HttpConnectorOptions {
25
25
  export interface ResourceVersionOptions {
26
26
  // Compilation validates that this names a stored, untransformed string attribute.
27
27
  versionField?: string
28
+ // A stored, non-nullable integer advanced by one on every update.
29
+ lockVersionField?: string
28
30
  }
29
31
 
30
32
  export type CorsOrigin = string | RegExp | ((origin: string) => boolean | Promise<boolean>) | CorsOrigin[]
@@ -57,8 +59,8 @@ export interface RestApiAnyapiKnexPluginOptions extends RestApiKnexPluginOptions
57
59
  schemaCheckInterval?: number
58
60
  }
59
61
  export interface BulkOperationsPluginOptions {
62
+ // Entries one HTTP bulk request may carry (default 1000).
60
63
  maxBulkOperations?: number
61
- defaultAtomic?: boolean
62
64
  }
63
65
  export interface LabelPluginOptions {
64
66
  preferNameFields?: string[]
@@ -1,5 +1,6 @@
1
1
  import type { Knex } from 'knex'
2
2
  import type { ManagedTransactionParam } from './transactions.js'
3
+ import type { DataAccessMethods } from './data-access-methods.js'
3
4
  import type {
4
5
  CollectionResult, DirectResourceId, InputResourceId, InputResourceIdentifier, ResourceFormat,
5
6
  SingleResourceResult, WriteResult, WriteReturning
@@ -24,10 +25,32 @@ export interface QueryParams extends SelectionParams {
24
25
  page?: PageParams
25
26
  }
26
27
  export type AggregateFunction = 'count' | 'countDistinct' | 'sum' | 'avg' | 'min' | 'max'
27
- // One function per measure; only count accepts '*'.
28
- export type AggregateMeasure = { count: string } | { [Function in Exclude<AggregateFunction, 'count'>]: { [Name in Function]: string } }[Exclude<AggregateFunction, 'count'>]
28
+ type AggregateScalar = string | number | boolean | Date
29
+ // A measure's condition: a value (null tests for null), a list (IN), or operators; every entry must hold.
30
+ export type AggregateCondition = AggregateScalar | null | AggregateScalar[] | {
31
+ eq?: AggregateScalar | null
32
+ ne?: AggregateScalar | null
33
+ gt?: AggregateScalar
34
+ gte?: AggregateScalar
35
+ lt?: AggregateScalar
36
+ lte?: AggregateScalar
37
+ in?: AggregateScalar[]
38
+ notIn?: AggregateScalar[]
39
+ }
40
+ export type AggregateWhere = Record<string, AggregateCondition>
41
+ // One function per measure; only count accepts '*'. where makes the measure conditional (CASE inside the function).
42
+ export type AggregateMeasure = ({ count: string } | { [Function in Exclude<AggregateFunction, 'count'>]: { [Name in Function]: string } }[Exclude<AggregateFunction, 'count'>]) & { where?: AggregateWhere }
43
+ // A date or dateTime field grouped by period; the group value is the period's first day, 'YYYY-MM-DD' (weeks start on Monday).
44
+ export interface AggregateBucket {
45
+ field: string
46
+ bucket: 'day' | 'week' | 'month' | 'year'
47
+ // 'UTC' (default), a fixed offset such as '+10:00', or an IANA name (PostgreSQL; MySQL with time zone tables).
48
+ timezone?: string
49
+ // Output name; default the field name.
50
+ as?: string
51
+ }
29
52
  export interface AggregateParams extends TransactionParam {
30
- groupBy?: string[]
53
+ groupBy?: Array<string | AggregateBucket>
31
54
  measures: Record<string, AggregateMeasure>
32
55
  queryParams?: {
33
56
  filters?: Record<string, unknown>
@@ -89,7 +112,7 @@ export interface ResourceCoreMethods<
89
112
  DefaultFormat extends ResourceFormat = 'plain',
90
113
  DefaultReturning extends WriteReturning = 'full',
91
114
  Context extends object = object
92
- > {
115
+ > extends DataAccessMethods<Fields, Input, Type, DefaultFormat, Context> {
93
116
  get<Format extends ResourceFormat = DefaultFormat>(
94
117
  params: ReadOptions<Format> & { id: DirectResourceId }, context?: Context
95
118
  ): Promise<SingleResourceResult<Format, Fields, Type>>
@@ -5,7 +5,10 @@ export interface RowPolicyParams<Context extends object = Record<string, unknown
5
5
  context: Context
6
6
  scopeName: string
7
7
  tableName?: string
8
+ // 'report', 'bulk-write', 'lock' and 'claim' join the read purposes of earlier releases.
8
9
  queryPurpose: string
10
+ // True only for a context made by createSystemContext.
11
+ system: boolean
9
12
  db?: StorageDatabase
10
13
  isAnyApi: boolean
11
14
  storageAdapter?: StorageAdapter | null
@@ -33,10 +33,14 @@ export interface RuntimeArguments<Context extends object = Record<string, unknow
33
33
  runHooks(event: string, context?: Record<string, unknown>): Promise<boolean>
34
34
  /** Whether runHooks(event) for this resource, or for scope when given, would call any handler. */
35
35
  hasHooks(event: string, scope?: RuntimeResource): boolean
36
+ /** Handlers of these events that would run for this resource (or scope) without { batch: true }. */
37
+ perRecordHooks(events: readonly string[], scope?: RuntimeResource): Array<{ event: string; name: string }>
36
38
  }
37
39
  export type RuntimeMethod<Context extends object = Record<string, unknown>> = (args: RuntimeArguments<Context>) => unknown
38
40
  export type HookPlacement = { beforeFunction?: string; afterFunction?: never } | { beforeFunction?: never; afterFunction?: string }
39
- type HookDefinition<Handler> = Handler | (HookPlacement & { functionName?: string; handler: Handler })
41
+ // batch: true marks a per-record handler that many-row operations may skip: its work
42
+ // is done by a batch hook, or does not apply to them. Without it they refuse the resource.
43
+ type HookDefinition<Handler> = Handler | (HookPlacement & { functionName?: string; batch?: boolean; handler: Handler })
40
44
  export type RuntimeHook<Context extends object = Record<string, unknown>> = HookDefinition<RuntimeMethod<Context>>
41
45
  export type RuntimeHooks<Context extends object = Record<string, unknown>> = {
42
46
  [Event in KnownHookName]?: HookDefinition<HookHandler<Event, Context>>
@@ -60,6 +64,8 @@ export interface RuntimeResourceOptions<Context extends object = Record<string,
60
64
  sortableFields?: string[]
61
65
  defaultSort?: string[]
62
66
  versionField?: string
67
+ // A stored integer that every library update of a row advances by one.
68
+ lockVersionField?: string
63
69
  rowPolicy?: ResourceRowPolicy<Context>
64
70
  autofilter?: ResourceAutoFilter<Context>
65
71
  indexes?: Array<{ name?: string; columns: string[]; unique?: boolean }>
@@ -1,277 +0,0 @@
1
- ---
2
- title: "Bulk operations"
3
- chapter: 27
4
- chapter_label: "27"
5
- ---
6
-
7
- # 27. Bulk operations
8
-
9
- `BulkOperationsPlugin` provides `bulkPost`, `bulkPatch` and `bulkDelete`. Entries
10
- run through their normal resource lifecycle, sequentially. A bulk request can
11
- reduce network overhead; it does not turn resource writes into one batched SQL
12
- statement or skip permissions, validation, setters and hooks.
13
-
14
- POST/PATCH use the same output-only `format` and `returning` options as individual writes.
15
- Results contain `meta`, optional indexed `errors`, and `data` for full/minimal
16
- responses. `returning: 'none'` omits data. DELETE returns its summary and deleted
17
- IDs in `meta.deleted`.
18
-
19
- ## Runnable example
20
-
21
- Insert the following seven JavaScript blocks into the
22
- [starting script](03-running-example.md), after installing storage and
23
- before declaring resources or starting the server, on a fresh database. Later
24
- snippets in this guide are fragments illustrating existing application state.
25
-
26
- ```javascript
27
- import { BulkOperationsPlugin } from 'json-rest-api/plugins/core/bulk-operations-plugin.js'
28
-
29
- await api.use(BulkOperationsPlugin, { maxBulkOperations: 100, defaultAtomic: true })
30
- await api.addResource('books', {
31
- schema: { title: { type: 'string', required: true } }
32
- })
33
- await api.resources.books.createKnexTable()
34
- ```
35
-
36
- ```javascript
37
- const created = await api.resources.books.bulkPost({
38
- data: [{ title: 'Alpha' }, { title: 'Beta' }], format: 'plain'
39
- })
40
- console.log(created.data, created.meta)
41
- ```
42
-
43
- The two records are returned in input order. The summary is
44
- `{ total: 2, succeeded: 2, failed: 0, atomic: true }`.
45
- For JSON:API bulk creation, pass `document: { data: [...] }`, where each array
46
- entry is a JSON:API resource object with `type`, attributes and relationships.
47
- Supply either the plain `data` array or `document`, never both. Input selection
48
- does not determine the output format.
49
-
50
- ```javascript
51
- const updated = await api.resources.books.bulkPatch({
52
- operations: created.data.map(book => ({ id: book.id, data: { title: `${book.title} revised` } })),
53
- format: 'plain', returning: 'minimal'
54
- })
55
- console.log(updated.data, updated.meta)
56
- ```
57
-
58
- PATCH entries select input individually: `{ id, data: plainValues }` or
59
- `{ id, document: { data: resourceObject } }`. Each entry accepts exactly one input
60
- key; a batch may contain both kinds. `format` selects output for the batch.
61
- Minimal output contains identifiers.
62
-
63
- ```javascript
64
- const partial = await api.resources.books.bulkPost({
65
- data: [{ title: 'Gamma' }, {}], format: 'plain', atomic: false
66
- })
67
- console.log(partial.data, partial.errors, partial.meta)
68
- ```
69
-
70
- Gamma is stored, while the second entry fails required-title validation. The
71
- summary reports one success and one failure; `errors[0].index` is 1. Successful
72
- data entries are compacted, so their array positions do not represent failed
73
- input slots. Use the indexed errors to identify rejected inputs.
74
-
75
- ```javascript
76
- let atomicError
77
- try {
78
- await api.resources.books.bulkPost({
79
- data: [{ title: 'Not retained' }, {}], format: 'plain', atomic: true
80
- })
81
- } catch (error) { atomicError = error }
82
- const afterAtomicFailure = await api.resources.books.query({ format: 'plain' })
83
- console.log(atomicError?.transactionOutcome, afterAtomicFailure.data.map(book => book.title))
84
- ```
85
-
86
- The owned batch rejects with rolled-back outcome, and Not retained is absent.
87
- The earlier, separately completed calls remain stored.
88
-
89
- ```javascript
90
- const gamma = partial.data[0]
91
- let managedError
92
- try {
93
- await api.transaction(async transaction => {
94
- await api.resources.books.bulkPatch({
95
- operations: [{ id: gamma.id, data: { title: 'Pending title' } }],
96
- format: 'plain', returning: 'none', atomic: true, transaction
97
- })
98
- throw new Error('Cancel this unit')
99
- })
100
- } catch (error) { managedError = error }
101
- const afterManagedRollback = await api.resources.books.get({ id: gamma.id, format: 'plain' })
102
- console.log(managedError?.message, afterManagedRollback.title)
103
- ```
104
-
105
- The outer callback rejection restores Gamma. The batch borrows the managed
106
- transaction and cannot independently commit its changes.
107
-
108
- ```javascript
109
- const deleted = await api.resources.books.bulkDelete({ ids: created.data.map(book => book.id), atomic: true })
110
- const remaining = await api.resources.books.query({ format: 'plain' })
111
- console.log(deleted.meta, remaining.data)
112
- ```
113
-
114
- The delete summary names the two deleted IDs. Gamma remains.
115
-
116
- ## Configuration Options
117
-
118
- Pass these options directly to `api.use(BulkOperationsPlugin, options)`:
119
-
120
- | Option | Type | Default | Description |
121
- |--------|------|---------|-------------|
122
- | `maxBulkOperations` | positive safe integer | 100 | Maximum number of records that can be processed in a single request |
123
- | `defaultAtomic` | boolean | true | Whether operations are atomic (all-or-nothing) by default |
124
-
125
- Invalid values and unknown option names fail during installation. Remove
126
- `batchSize` and `enableOptimizations`: they did not provide SQL batching, and the
127
- disconnected optimization handler has been removed. Do not wrap options inside
128
- a `'bulk-operations'` key. See [the migration steps](33-migrating-to-v2.md#bulk-writes).
129
-
130
- ## Failures and diagnostics
131
-
132
- Atomic failures preserve the original error if rollback also rejects. The
133
- outer batch attempts rollback once for an unfinished transaction it owns and stores
134
- secondary rollback errors in the supplied context's `cleanupErrors` array;
135
- `context.error` retains the original failure. Both diagnostic fields reset on
136
- the next bulk call. A rejected rollback does not confirm that data was restored.
137
- Managed completion follows the [transaction contract](19-managed-transactions.md).
138
-
139
- Child cleanup diagnostics are now available on the batch context, including
140
- warnings from successful entries. Each has a zero-based `bulkIndex` alongside
141
- its `phase`, original `error` and any file information. For example:
142
-
143
- ```js
144
- const context = {};
145
- const result = await api.resources.books.bulkPatch({
146
- operations: [{ id: '42', data: { title: 'Updated title' } }],
147
- format: 'plain',
148
- atomic: false
149
- }, context);
150
-
151
- for (const diagnostic of context.cleanupErrors || []) {
152
- console.error(diagnostic.bulkIndex, diagnostic.phase, diagnostic.error);
153
- }
154
- ```
155
-
156
- The batch response keeps its existing indexed primary errors; secondary cleanup
157
- errors are retained on the context. `meta.failed` counts rejected calls, not
158
- rolled-back writes: an `afterCommit` hook can fail after its data is stored.
159
-
160
- Remaining child uploads are retained in `context.fileHandlingUploads`, also
161
- tagged with `bulkIndex` and retaining their storage and transaction references.
162
- They can include a failed deletion or committed files whose tracking-release
163
- hook did not run. Do not delete or retry them solely because they are tracked.
164
- Reusing the batch context clears old error diagnostics and keeps unresolved
165
- upload tracking without copying it into new children. Write errors expose
166
- `transactionOutcome`; managed completion-chain diagnostics also identify
167
- `operationIndex`, `scopeName` and `method`. Broader integration remains in progress.
168
-
169
- ## Version conditions
170
-
171
- For a resource with an explicit stored `versionField`, bulk PATCH and DELETE
172
- accept `expectedVersions`: one revision token for each entry, in the same order
173
- as `operations` or `ids`. Omit the entire array for unconditional writes. Each
174
- token must be a non-empty string of at most 128 characters; null entries and
175
- partially conditional arrays are not supported. The library validates the
176
- array before starting any child operation, including in non-atomic mode.
177
-
178
- ```js
179
- const current = await api.resources.books.get({ id: '42', format: 'plain' })
180
- await api.resources.books.bulkPatch({
181
- operations: [{ id: current.id, data: { title: 'Updated title' } }],
182
- expectedVersions: [current.revision],
183
- format: 'plain'
184
- })
185
- ```
186
-
187
- Use the revision field selected by your resource configuration. It belongs in
188
- `expectedVersions`, not in the submitted attributes. Successful updates rotate
189
- the revision even when no conditions were supplied. Read full responses or
190
- fetch the record again to obtain its new revision.
191
-
192
- A stale condition raises `REST_API_VERSION_CONFLICT`. An owned atomic batch
193
- rolls back all its changes. A non-atomic batch retains successful children and
194
- reports the failed child's index and transaction outcome in `errors`. Caller
195
- transactions retain their normal ownership rules below. Missing or inaccessible
196
- rows retain the single-write not-found behavior.
197
-
198
- HTTP bulk PATCH/DELETE use the same `expectedVersions` property in the request
199
- body, alongside `operations` or `ids`. Atomic version conflicts map to HTTP 409;
200
- malformed condition arrays map to 422. These are body conditions, not HTTP
201
- `If-Match` validators. Bulk creation rejects revision conditions, and all bulk
202
- methods reject the singular `expectedVersion` option.
203
-
204
- ## Transaction ownership
205
-
206
- Pass `transaction` in the first argument to bulkPost, bulkPatch or bulkDelete,
207
- with `atomic: true`. Each entry uses that same transaction, including its
208
- relationship changes and full-response reads. The bulk call neither commits nor
209
- rolls it back. Other calls and raw SQL can join the same transaction.
210
-
211
- ```js
212
- await api.transaction(async transaction => {
213
- return api.resources.books.bulkPatch({
214
- operations: [{ id: '42', data: { title: 'Updated title' } }],
215
- format: 'plain',
216
- returning: 'minimal',
217
- atomic: true,
218
- transaction
219
- })
220
- })
221
- ```
222
-
223
- The managed helper rolls back the whole unit after callback rejection or a
224
- participating write/observed SQL failure, even when caught. A rejected batch can
225
- leave changes pending until the helper finishes; its individual operations do
226
- not complete the transaction or create savepoints. Raw transactions and child
227
- savepoints cannot be passed to library writes. Raw SQL can join the managed
228
- callback through its actual Knex handle.
229
-
230
- Without a supplied transaction, an atomic batch owns one transaction for all
231
- entries; a non-atomic batch lets each entry own its transaction. Passing a
232
- transaction with `atomic: false` rejects before any child or SQL runs. This also
233
- applies when `defaultAtomic` is false: explicitly select `atomic: true` to join
234
- an outer transaction. A completed transaction rejects through the normal write
235
- path and is never silently replaced with a new one.
236
-
237
- Managed completion defers enlisted completion chains to the owner. See
238
- [managed transactions](19-managed-transactions.md) and
239
- [transaction outcomes](20-transaction-outcomes.md) for file/event integration,
240
- completion failures and uncertain outcomes.
241
-
242
- ## HTTP API Usage
243
-
244
- Express and Fastify expose bulk endpoints. Install the connector and bulk
245
- plugin **before declaring resources**, then mount/start the application.
246
- Authenticated transport context reaches every ordinary resource write and its
247
- permission checks. HTTP always selects JSON:API/full responses: POST returns
248
- 201, PATCH and DELETE return 200 with the batch summary. `atomic` query values
249
- must be exactly `true` or `false`; malformed values produce 422 without writes.
250
- The optional plugin reserves `/RESOURCE/bulk` for these methods, including when
251
- the resource accepts opaque IDs. This bulk protocol is not an implementation of
252
- the JSON:API Atomic Operations extension.
253
-
254
-
255
- The bulk HTTP request bodies are:
256
-
257
- | Method and path | Body fields |
258
- | --- | --- |
259
- | `POST /api/books/bulk` | `data`: array of JSON:API resource objects |
260
- | `PATCH /api/books/bulk` | `operations`: array of `{ id, data }`, where `data` is a resource object |
261
- | `DELETE /api/books/bulk` | `ids`: array of resource IDs |
262
-
263
- For example:
264
-
265
- ```json
266
- {
267
- "operations": [
268
- { "id": "1", "data": { "type": "books", "id": "1", "attributes": { "title": "Revised" } } }
269
- ]
270
- }
271
- ```
272
-
273
- Select `?atomic=false` for independent child transactions, or omit it for the
274
- configured default. Non-atomic error summaries require application handling;
275
- a successful HTTP status does not mean every child call succeeded. In
276
- particular, inspect each error's transaction outcome before retrying a mutation
277
- that may already have committed. See [transaction outcomes](20-transaction-outcomes.md).
@@ -1,63 +0,0 @@
1
- import type { ManagedTransaction } from './transactions.js'
2
- import type { TransactionOutcome } from './errors.js'
3
- import type { DirectResourceId, InputResourceId, JsonApiResource, PlainResource, ResourceFormat, ResourceIdentifier, WriteReturning } from './representations.js'
4
- import type { JsonApiWriteDocument, RemovedResourceOptions, ResourceWriteInput, WriteOptions } from './resource-methods.js'
5
-
6
- export type BulkTransactionOptions<DefaultAtomic extends boolean = true> =
7
- { atomic: true; transaction?: ManagedTransaction } |
8
- { atomic: false; transaction?: never } |
9
- { atomic?: undefined; transaction?: DefaultAtomic extends true ? ManagedTransaction : never }
10
- export interface BulkError {
11
- index: number
12
- id?: DirectResourceId
13
- status: 'error'
14
- error: { code: unknown; message: string; details?: unknown; transactionOutcome: TransactionOutcome }
15
- }
16
- export interface BulkMeta {
17
- total: number
18
- succeeded: number
19
- failed: number
20
- atomic: boolean
21
- }
22
- export type BulkWriteResult<Format extends ResourceFormat, Returning extends WriteReturning, Fields extends object, Type extends string> =
23
- { meta: BulkMeta; errors?: BulkError[] } & (Returning extends 'none'
24
- ? { data?: never }
25
- : { data: Array<Returning extends 'minimal' ? ResourceIdentifier<Type> : Format extends 'plain' ? PlainResource<Fields> : JsonApiResource<Fields, Type>> })
26
- export interface BulkDeleteResult {
27
- meta: BulkMeta & { deleted: DirectResourceId[] }
28
- errors?: BulkError[]
29
- }
30
- export type BulkPostInput<Input extends object, Type extends string> =
31
- { data: Array<Partial<Input> & { id?: InputResourceId }>; document?: never } |
32
- { data?: never; document: Omit<JsonApiWriteDocument<Input, Type>, 'data'> & { data: JsonApiWriteDocument<Input, Type>['data'][] } }
33
- export type BulkPatchInput<Input extends object, Type extends string> = {
34
- id: DirectResourceId
35
- } & ResourceWriteInput<Input, Type>
36
- export interface BulkResourceMethods<
37
- Fields extends object = Record<string, unknown>, Input extends object = Fields,
38
- Type extends string = string, DefaultFormat extends ResourceFormat = 'plain',
39
- DefaultReturning extends WriteReturning = 'full', DefaultAtomic extends boolean = true,
40
- Context extends object = object
41
- > {
42
- bulkPost<Format extends ResourceFormat = DefaultFormat, Returning extends WriteReturning = DefaultReturning>(
43
- params: Omit<WriteOptions<Format, Returning>, 'queryParams'> & BulkTransactionOptions<DefaultAtomic> & BulkPostInput<Input, Type> & {
44
- expectedVersion?: never
45
- expectedVersions?: never
46
- }, context?: Context
47
- ): Promise<BulkWriteResult<Format, Returning, Fields, Type>>
48
- bulkPatch<Format extends ResourceFormat = DefaultFormat, Returning extends WriteReturning = DefaultReturning>(
49
- params: Omit<WriteOptions<Format, Returning>, 'queryParams'> & BulkTransactionOptions<DefaultAtomic> & {
50
- operations: BulkPatchInput<Input, Type>[]
51
- expectedVersion?: never
52
- expectedVersions?: string[]
53
- }, context?: Context
54
- ): Promise<BulkWriteResult<Format, Returning, Fields, Type>>
55
- bulkDelete(
56
- params: RemovedResourceOptions & BulkTransactionOptions<DefaultAtomic> & {
57
- ids: DirectResourceId[]
58
- format?: ResourceFormat
59
- expectedVersion?: never
60
- expectedVersions?: string[]
61
- }, context?: Context
62
- ): Promise<BulkDeleteResult>
63
- }