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
@@ -198,6 +198,7 @@ A policy receives one object:
198
198
  | `scopeName` | The resource currently being selected. For includes, this is the child resource. |
199
199
  | `tableName` | The table name or active query alias for this selection. |
200
200
  | `queryPurpose` | Why the selection is running; see the table below. |
201
+ | `system` | `true` for a context made by `createSystemContext`. The policy decides whether system work sees every row. |
201
202
  | `db` | The active Knex connection or transaction. Use it to build subqueries. |
202
203
  | `isAnyApi` | `true` when the AnyAPI storage path is active. |
203
204
  | `column(field, options?)` | Translate and qualify a logical field. |
@@ -293,10 +294,17 @@ Policies are reused for the resource selections that define externally visible d
293
294
  | `relationship-identifiers` | Child identifiers attached to a relationship, including has-many and AnyAPI many-to-many links. |
294
295
  | `relationship-validation` | A batch of up to 100 distinct target IDs referenced by a write payload, or a scoped lookup retaining database ID equality. |
295
296
  | `relationship-parent` | The parent of a relationship route such as `getRelated`. |
297
+ | `report` | A `scopedQuery()` builder with the default purpose. |
298
+ | `bulk-write` | The visible ids a many-row patch or delete may change, and `scopedQuery({ purpose: 'bulk-write' })`. |
299
+ | `lock` | The rows `lockByIds()` and `lockWhere()` lock, in the locking read itself. A policy that joins other tables is refused here, as for `claim`. |
300
+ | `claim` | The candidate rows `claim()` takes. A policy that joins other tables is refused here, because the database would lock the joined rows too. |
296
301
  | `unspecified` | A custom call to the lower-level query-filtering seam did not provide a purpose. |
297
302
 
298
303
  The important invariant is not the label. It is that the predicate is attached before any limit, offset, cursor boundary, per-parent include limit, or count is evaluated.
299
304
 
305
+ A row policy cannot see the row an upsert merges into, so `postMany` refuses
306
+ `onConflict: { action: 'merge' }` on a resource with a row policy.
307
+
300
308
  Treat `queryPurpose` as diagnostic context, not as permission to weaken a policy. A policy should normally add the same visibility predicate for every purpose. If an advanced policy uses a purpose-specific SQL shape, each branch must enforce equivalent visibility and must be tested independently; never skip a predicate for counts, includes, identifiers, or relationship validation.
301
309
 
302
310
  Relationship identifier visibility is selected in batches of at most 100 unique
@@ -308,11 +316,13 @@ and include limits in their operation/resource settings. Visibility hooks add
308
316
  row predicates and retain the query's selected ID and mandatory constraints.
309
317
  Their invocation count is not a per-request or per-record contract.
310
318
 
311
- Regular and polymorphic belongs-to includes also load at most 100 target IDs
312
- per query, with target permission and filtering hooks applied to every batch
313
- under `queryPurpose: 'include'`. SQL projection callbacks run for each query.
314
- Nested collection includes run after all target batches have been collected,
315
- so a global collection limit is not restarted for each batch.
319
+ Regular and polymorphic belongs-to includes load each target type's records
320
+ in one query, however many there are, with target permission and filtering
321
+ hooks applied under `queryPurpose: 'include'`. A long id list is bound as one
322
+ parameter on PostgreSQL and SQLite, so it stays within their parameter limits;
323
+ MySQL and MariaDB have none. SQL projection callbacks run for each query.
324
+ Nested collection includes run after all targets have been collected, so a
325
+ global collection limit applies to the complete set.
316
326
 
317
327
  Relationship validation uses the same batch bound. Its query context has no
318
328
  individual `id` and no parent client filters. Retain the selected ID and scope
@@ -47,9 +47,9 @@ cannot be replaced by a later successful empty COMMIT.
47
47
  ## Participation and failure
48
48
 
49
49
  Ordinary resource and relationship calls without a transaction use the same
50
- completion mechanism for their own transaction. Atomic bulk uses it for the
51
- whole batch. Calls receiving its transaction participate and never complete it
52
- individually. Non-atomic bulk with an outer transaction continues to reject.
50
+ completion mechanism for their own transaction, and so do many-row writes for
51
+ the whole call. Calls receiving its transaction participate and never complete
52
+ it individually.
53
53
 
54
54
  The final contract requires library writes to use a library-owned transaction.
55
55
  Standalone transactions created with `knex.transaction()` remain usable for raw
@@ -85,8 +85,9 @@ retry just the failed statement inside an already failed unit.
85
85
 
86
86
  Each accepted resource/relationship operation enlists its existing completion
87
87
  hook chain once. This includes nested resource calls made by relationship
88
- implementation; helper-internal SQL does not create synthetic operations. Bulk
89
- or transaction owners do not also run a duplicate resource hook chain.
88
+ implementation; helper-internal SQL does not create synthetic operations. A
89
+ many-row write enlists one chain for the whole call. Transaction owners do not
90
+ also run a duplicate resource hook chain.
90
91
 
91
92
  After confirmed commit, invoke enlisted `afterCommit` chains in enlistment
92
93
  order. After confirmed rollback, invoke `afterRollback` chains in reverse order.
@@ -99,8 +100,7 @@ set of outcome-dependent hooks.
99
100
 
100
101
  Enlistment happens when the library accepts an operation into the transaction.
101
102
  Overlapping operations can finish their writes in another order; that does not
102
- change completion-hook order. Internal finalizers, currently bulk diagnostic
103
- collection, run after those chains in registration order even when a chain or
103
+ change completion-hook order. Internal finalizers run after those chains in registration order even when a chain or
104
104
  an earlier finalizer fails. They also collect diagnostics after unknown
105
105
  completion; they must not infer commit or run outcome-dependent side effects.
106
106
  The first post-commit failure remains primary across hooks and finalizers.
@@ -129,8 +129,7 @@ owner's error in its rollback context.
129
129
 
130
130
  File cleanup/tracking and Socket.IO delivery use these existing hooks. Uploaded
131
131
  files are cleaned only after confirmed rollback, and notifications run only
132
- after confirmed outer commit. Bulk diagnostics aggregate unresolved child state
133
- after finalization; the parent must not delete a child's files a second time.
132
+ after confirmed outer commit.
134
133
  Tests cover repeated changes to the same resource, multiple uploads,
135
134
  callback/SQL failures and failing completion hooks. File-specific failures and
136
135
  unresolved upload tracking remain on each operation's context; rejected hook
@@ -6,9 +6,8 @@ chapter_label: "20"
6
6
 
7
7
  # 20. Transaction outcomes
8
8
 
9
- Resource, relationship, bulk and canonical registry write errors carry the
10
- outcome of the transaction they represent. HTTP and non-atomic bulk errors
11
- preserve that outcome. The states below describe the evidence available when
9
+ Resource, relationship, many-row and canonical registry write errors carry the
10
+ outcome of the transaction they represent. HTTP errors preserve that outcome. The states below describe the evidence available when
12
11
  the failure is reported.
13
12
 
14
13
  [Managed transactions](19-managed-transactions.md) explains ownership and
@@ -166,7 +165,7 @@ retained: for example, child pending and outer rolledBack. Existing code/subtype
166
165
  classification can usually remain at the outer error; code that checks original
167
166
  error identity or `instanceof` needs migration. HTTP mapping preserves status
168
167
  classification and adds `meta.transactionOutcome` to each JSON:API error.
169
- Non-atomic bulk entries expose `error.transactionOutcome` individually.
168
+ A many-row write is one operation with one outcome for all its rows.
170
169
 
171
170
  ## Errors, batches and later observations
172
171
 
@@ -596,7 +596,13 @@ Current live introspection support:
596
596
 
597
597
  - SQLite
598
598
  - PostgreSQL via `pg` (verified on PostgreSQL 16)
599
- - MySQL via `mysql2` (verified on MySQL 8; this does not establish MariaDB coverage)
599
+ - MySQL via `mysql2` (verified on MySQL 8)
600
+ - MariaDB via `mysql2` (verified on MariaDB 11.4)
601
+
602
+ MariaDB reports column defaults as SQL (a quoted literal, the word `NULL` for no
603
+ default) and stores a `json` column as `longtext` with a `json_valid` column
604
+ check; the snapshot reads both as MySQL's forms, so the JSON check is part of
605
+ the column type and not a check constraint of its own.
600
606
 
601
607
  PostgreSQL inspection reads the current schema's
602
608
  [columns](https://www.postgresql.org/docs/16/infoschema-columns.html),
@@ -41,7 +41,7 @@ It is not a hash of the complete response and is not an HTTP ETag.
41
41
  | Child creation, reparenting, detachment or deletion | Rotates affected configured has-many/has-one parent revisions, including declared polymorphic inverses. |
42
42
  | Child attribute change with unchanged membership | Does not rotate the parent's revision merely because included child attributes changed. |
43
43
  | Ordinary pivot-resource write | Invalidates affected configured parents when either membership key changes. Canonical through-resource records remain separate from canonical links. |
44
- | Bulk PATCH or DELETE | Applies the same rules per child. Optional `expectedVersions` must contain one token per input entry. |
44
+ | Many-row writes | Refused with `REST_API_BULK_WRITE_UNSUPPORTED` on a versioned resource and on resources whose writes advance a version; use single writes. |
45
45
  | Transaction rollback | Restores revisions together with the associated writes and links. |
46
46
 
47
47
  Submit conditions through `expectedVersion`, not as an attribute update. Omitting
@@ -147,8 +147,8 @@ a current conditional PATCH succeeds, and a stale one returns
147
147
  `REST_API_VERSION_CONFLICT` without changing stored data. Test rollback before
148
148
  allowing writers to resume.
149
149
 
150
- Clients send `expectedVersion` alongside the single-write options, or aligned
151
- `expectedVersions` for bulk PATCH/DELETE. They must not submit the revision among
150
+ Clients send `expectedVersion` alongside the single-write options. They must
151
+ not submit the revision among
152
152
  the record attributes. A conflict requires fetching and reconciling the current
153
153
  record; blindly retrying with a newly fetched token can overwrite another
154
154
  client's edits. These tokens are not HTTP ETags.
@@ -0,0 +1,458 @@
1
+ ---
2
+ title: "Many-row writes"
3
+ chapter: 27
4
+ chapter_label: "27"
5
+ ---
6
+
7
+ # 27. Many-row writes
8
+
9
+ Every table-storage resource has six methods that write many rows at once:
10
+
11
+ | Method | Writes |
12
+ | --- | --- |
13
+ | `postMany({ data })` | one new record per entry |
14
+ | `patchMany({ data: [{ id, ... }] })` | each entry's own changes to the record with that id |
15
+ | `patchWhereIds({ ids, data })` | the same changes to every listed record |
16
+ | `patchWhere({ filters, data })` | the same changes to every record the filters select (or `all: true`) |
17
+ | `deleteMany({ ids })` | the listed records |
18
+ | `deleteWhere({ filters })` | every record the filters select (or `all: true`) |
19
+
20
+ Each call is all-or-nothing. Every row still goes through validation,
21
+ ownership stamping (autofilter), setters, relationship checks and
22
+ `checkPermissions`, but in memory; the database then sees one statement per
23
+ chunk of rows, not one per row. A batch of 10 rows and a batch of 200 run the
24
+ same number of statements. Use these methods for imports, mass updates and
25
+ jobs; use the single-record methods when each write needs its own hooks.
26
+
27
+ ## Runnable example
28
+
29
+ Insert the following four JavaScript blocks into the
30
+ [starting script](03-running-example.md), after installing table storage and
31
+ before declaring other resources, on a fresh database. Many-row writes need
32
+ table storage (`RestApiKnexPlugin`); AnyAPI storage refuses them.
33
+
34
+ ```javascript
35
+ await api.addResource('books', {
36
+ schema: {
37
+ title: { type: 'string', required: true, search: true },
38
+ isbn: { type: 'string', required: true },
39
+ stock: { type: 'integer', defaultTo: 0 }
40
+ },
41
+ indexes: [{ columns: ['isbn'], unique: true }]
42
+ })
43
+ await api.resources.books.createKnexTable()
44
+ ```
45
+
46
+ ```javascript
47
+ const created = await api.resources.books.postMany({
48
+ data: [
49
+ { title: 'Alpha', isbn: '111', stock: 3 },
50
+ { title: 'Beta', isbn: '222', stock: 0 }
51
+ ],
52
+ format: 'plain',
53
+ returning: 'full'
54
+ })
55
+ const restocked = await api.resources.books.postMany({
56
+ data: [{ title: 'Alpha', isbn: '111', stock: 9 }, { title: 'Gamma', isbn: '333' }],
57
+ onConflict: { keys: ['isbn'], action: 'merge', merge: ['stock'] },
58
+ returning: 'minimal'
59
+ })
60
+ console.log(created.data, restocked.data)
61
+ ```
62
+
63
+ Rows come back in input order. Many-row writes return nothing unless you ask:
64
+ `returning: 'minimal'` gives identifiers, `returning: 'full'` the records,
65
+ read back in the write's transaction, and `returning: 'count'` only
66
+ `{ meta: { count } }`, the number of rows written, without reading any back.
67
+ The second call is an upsert: Alpha's ISBN already exists, so its stock
68
+ becomes 9, and Gamma is inserted.
69
+
70
+ ```javascript
71
+ const [alpha, beta] = created.data
72
+ const updated = await api.resources.books.patchMany({
73
+ data: [{ id: alpha.id, title: 'Alpha, 2nd edition' }, { id: beta.id, stock: 4 }],
74
+ returning: 'minimal'
75
+ })
76
+ const emptied = await api.resources.books.patchWhere({
77
+ filters: { title: 'Gamma' }, data: { stock: 0 }, returning: 'minimal'
78
+ })
79
+ console.log(updated.data, emptied.data)
80
+ ```
81
+
82
+ `patchMany` takes one change set per id. `patchWhereIds` and `patchWhere` apply
83
+ one change set to many records. `filters` are the resource's search filters,
84
+ validated exactly as `query` validates them.
85
+
86
+ ```javascript
87
+ let invalidRow
88
+ try {
89
+ await api.resources.books.postMany({ data: [{ title: 'Delta', isbn: '444' }, { isbn: '555' }] })
90
+ } catch (error) { invalidRow = error.cause ?? error }
91
+ const deleted = await api.resources.books.deleteMany({ ids: [beta.id], returning: 'minimal' })
92
+ const remaining = await api.resources.books.query({ format: 'plain' })
93
+ console.log(invalidRow.details.index, deleted.data, remaining.data.map(book => book.title))
94
+ ```
95
+
96
+ The second row has no title, so the batch writes nothing and the error names
97
+ the row: `details.index` is 1 and the field paths start with `data.1.`. Delta
98
+ is not stored. The delete removes Beta; Alpha, 2nd edition and Gamma remain.
99
+
100
+ ## Input
101
+
102
+ `postMany` and `patchMany` take either plain records as `data: [...]` or
103
+ JSON:API resource objects as `document: { data: [...] }`, never both. The
104
+ `format` option selects only the output. `patchWhereIds` and `patchWhere` take
105
+ one record: `data: { ... }` or `document: { data: { type, attributes } }`.
106
+
107
+ ```js
108
+ await api.resources.books.postMany({
109
+ document: { data: [{ type: 'books', attributes: { title: 'Epsilon', isbn: '666' } }] }
110
+ })
111
+ ```
112
+
113
+ Rows may set belongs-to relationships. The library checks every target the
114
+ batch names with one scoped read per related type, and locks them once for the
115
+ batch with one statement per type, however many rows name them. Rows that set
116
+ many-to-many or reverse (has-many) relationships are refused; change those
117
+ links with single writes or the relationship methods.
118
+
119
+ `patchMany`, `patchWhereIds` and `patchWhere` refuse:
120
+
121
+ - the id;
122
+ - autofilter (ownership) fields and the relationships that set them;
123
+ - the resource's `lockVersionField`, which every update advances by itself.
124
+
125
+ A row of a resource with a lock version may change no field at all: its UPDATE
126
+ then only advances the version, which tells other writers' `onlyIf` the
127
+ record has changed.
128
+
129
+ They accept a `knex.raw(...)` value for a stored, non-relationship field, so a
130
+ counter can move inside the statement:
131
+
132
+ ```js
133
+ const knex = api.knex.instance
134
+ await api.resources.books.patchWhereIds({
135
+ ids: ['1', '2'], data: { stock: knex.raw('?? + 1', ['stock']) }
136
+ })
137
+ ```
138
+
139
+ Only a real Knex raw value counts. An object that merely carries an
140
+ `isRawInstance` flag, as JSON input can, is refused with a validation error, so
141
+ the HTTP routes never pass SQL through. Batch hooks find the fields that raw
142
+ values set in `context.rawFields`, and the file-field and positioning refusals
143
+ below apply to them as well.
144
+
145
+ Every row also goes through the resource's setters, and creates through
146
+ `defaultTo` values and autofilter stamping, as single writes do. Rows may omit
147
+ different fields. On SQLite, a field that some rows set and others omit is
148
+ written as NULL for the rows that omit it, so a column default that the schema
149
+ does not declare with `defaultTo` does not apply to them.
150
+
151
+ ## Options
152
+
153
+ | Option | Methods | Default | Meaning |
154
+ | --- | --- | --- | --- |
155
+ | `returning` | all | `'none'` | `'minimal'` returns `{ data: [{ type, id }] }`; `'full'` (not for deletes) returns the records; `'count'` returns `{ meta: { count } }` |
156
+ | `format` | writes | resource default | Output format of `'full'` records |
157
+ | `queryParams` | writes | | `fields` and `include` of `'full'` records |
158
+ | `transaction` | all | | A transaction from `api.transaction` to join |
159
+ | `chunkSize` | all | 500 | Rows per statement, lowered further to the database's parameter limit |
160
+ | `missing` | `patchMany`, `patchWhereIds`, `deleteMany` | `'throw'` | `'skip'` ignores ids the caller cannot see |
161
+ | `onlyIf` | patches | | Compare-and-set: change only rows whose stored values equal these |
162
+ | `all` | `patchWhere`, `deleteWhere` | | `true` acts on every row the caller can see; needed when there are no `filters` |
163
+ | `onConflict` | `postMany` | | Upsert, below |
164
+
165
+ Ids that do not exist or that the caller cannot see (another owner's rows,
166
+ rows a row policy hides) throw `RestApiRecordsNotFoundError` (subtype
167
+ `not_found`, HTTP 404) before anything is written. The error gives only a
168
+ count, so it does not reveal which ids exist. With `missing: 'skip'` they are
169
+ left out. A batch may not name the same id twice.
170
+
171
+ With `missing: 'skip'`, `patchMany`, `patchWhereIds` and `deleteMany` read
172
+ nothing first when the resource has no `checkPermissions` hook and no
173
+ `beforeDataCallPatchMany` or `beforeDataCallDeleteMany` hook (those need the
174
+ visible rows before the write), except on MySQL and MariaDB. The UPDATE or
175
+ DELETE carries the caller's scope, row policy included, as a subquery, and its
176
+ changed rows are the answer, returned with `RETURNING` on PostgreSQL and
177
+ SQLite. MySQL and MariaDB keep the visibility read for ids from the caller:
178
+ InnoDB would lock every listed row before checking the scope, rows the caller
179
+ cannot see included, and hold those locks until commit. After that read, a
180
+ statement of one row there runs the UPDATE with its ownership conditions and
181
+ `onlyIf`, and its row count is the answer. A longer statement locks the rows
182
+ that still match and then updates exactly those, and so does a one-row
183
+ statement on a connection that disables the drivers' default `FOUND_ROWS`
184
+ flag, where MySQL counts the rows an UPDATE changed rather than those it
185
+ matched.
186
+
187
+ `patchWhere` and `deleteWhere` are set operations. They read the ids of the
188
+ rows their selection finds, then write those rows with the caller's scope and
189
+ the filters inside every UPDATE or DELETE (on MySQL and MariaDB inside a locking
190
+ read of the rows, followed by the write of exactly those). A row that another
191
+ transaction deletes, moves out of the caller's scope or changes so that it no
192
+ longer matches in the meantime is left out. The result lists the rows actually
193
+ written, and the methods never throw `RestApiRecordsNotFoundError`; they take no
194
+ `missing` option.
195
+
196
+ The filters are checked on each row as it is once locked, so a selection write
197
+ is a compare-and-set on them: of two transactions whose filters matched the
198
+ same row, the one that waits for the other's lock finds the row the other
199
+ committed, and leaves it out if it no longer matches. PostgreSQL locks the rows
200
+ first, so the statement that writes them starts after the wait; MySQL and
201
+ MariaDB check the scope and the filters in the locking read itself, which
202
+ therefore also locks the rows it joins from other tables. With MariaDB's
203
+ `innodb_snapshot_isolation` on, the waiting transaction fails instead ("Record
204
+ has changed since last read", errno 1020); retry it. SQLite has one writer:
205
+ the second transaction fails with `SQLITE_BUSY` rather than write over the
206
+ first, or, if it starts after the first commits, finds the row the first
207
+ committed.
208
+
209
+ A filter on another table's field (a `searchSchema` field whose `actualField`
210
+ names another table) is read through a derived table. PostgreSQL reads it
211
+ again after the wait, so it too is a compare-and-set; InnoDB would read it from
212
+ the snapshot without locking it, so on MySQL and MariaDB `patchWhere` and
213
+ `deleteWhere` refuse filters whose scope joins a derived table (rule
214
+ `selection_cross_table`), whatever rows they match. Filter on the row's own
215
+ columns instead, for example on the link itself after locking its targets with
216
+ `lockByIds` on their resource. A custom `applyFilter` that reads another table
217
+ through its own subquery is not detected: MySQL and MariaDB read that subquery
218
+ from the snapshot too, so it is no compare-and-set there.
219
+
220
+ The UPDATE, DELETE and lock statements repeat the caller's ownership
221
+ (autofilter) conditions as plain column predicates. A row that another
222
+ transaction moves to a different owner between the visibility read and the
223
+ write is therefore neither written nor locked, and counts as missing:
224
+ `missing: 'throw'` rolls the batch back, `'skip'` leaves the row out of the
225
+ result, and with `onlyIf` it is reported unchanged. Single `patch`, `put` and
226
+ `delete`, on table and AnyAPI storage, do the same with their one scoped read:
227
+ the UPDATE or DELETE carries the owner that read found, and a row that has gone
228
+ or moved to another owner since answers `not_found`. The statement's own row
229
+ count tells. A DELETE counts the rows it removed, and an UPDATE the rows it
230
+ matched on PostgreSQL, SQLite and default MySQL connections. On a MySQL or
231
+ MariaDB connection without `FOUND_ROWS`, an UPDATE that changes nothing counts
232
+ zero, so a zero count there is checked again with a locking read of the row.
233
+ Under READ COMMITTED that re-check leaves a window: a row deleted and created
234
+ again with the same id and owner between the UPDATE and the re-check passes
235
+ it, and the write is reported done although nothing was written. REPEATABLE
236
+ READ, InnoDB's default, closes it: the UPDATE's gap lock holds the new row off
237
+ until commit. A proxy that does not pass the `FOUND_ROWS` flag on to its
238
+ backend connections makes a no-op `patch` of a resource without a lock version
239
+ answer `not_found`.
240
+
241
+ Row policies are checked by the visibility read only, for single and
242
+ many-row writes alike: a row that another transaction moves out of the policy
243
+ (for example into a group the caller cannot see) between the read and the
244
+ write is still written. The ownership conditions close that window for the
245
+ owner. The policy would need a subquery on the written table, which MySQL
246
+ refuses in an UPDATE or DELETE, so no database checks it there. To keep a
247
+ policy-relevant change from slipping between the read and the write, lock the
248
+ row in the writing transaction first (`lockByIds`, which helps only for a
249
+ policy on the row's own columns: [it refuses](17-row-policies.md) a policy
250
+ that joins other tables), or give a many-row write an `onlyIf` on the policy's
251
+ fields. Without a
252
+ visibility read (`missing: 'skip'`, above) the policy is checked by the
253
+ write's subquery, which reads as of the statement's start.
254
+
255
+ `patchWhere` and `deleteWhere` need non-empty `filters`. To change or delete
256
+ every row the caller can see, pass `all: true` instead (not both); in a
257
+ [system context](34-scoped-reads-and-locks.md#system-contexts) that is the whole
258
+ table.
259
+
260
+ `onlyIf` makes a patch conditional. Only the rows whose stored values still
261
+ equal the given ones change, and with `returning: 'minimal'` or `'full'` the
262
+ result lists only those rows. With a
263
+ [`lockVersionField`](34-scoped-reads-and-locks.md#lock-versions) this is
264
+ optimistic locking for many rows:
265
+
266
+ ```js
267
+ const changed = await api.resources.orders.patchMany({
268
+ data: [{ id: order.id, status: 'shipped' }],
269
+ onlyIf: { lock_version: order.lock_version },
270
+ returning: 'minimal'
271
+ })
272
+ if (changed.data.length === 0) {
273
+ // Someone else changed the order first.
274
+ }
275
+ ```
276
+
277
+ ## Upserts
278
+
279
+ `onConflict: { keys, action, merge }` turns `postMany` into an upsert.
280
+ `keys` are the stored fields of a unique index. Each row must give them values,
281
+ no two rows may repeat them, and rows may not carry an `id`: the keys identify
282
+ the record. On a resource with no autofilter (ownership) fields and no row
283
+ policy, `keys: ['id']` makes the id itself the key (the `idProperty` name, such
284
+ as `email` for a natural key, means the same); every row then carries its id,
285
+ and the primary key is the unique index. There is no other owner whose row the
286
+ id could match, so this is the atomic create-if-missing for such tables.
287
+ With `action: 'ignore'`, rows that match an existing record are skipped and
288
+ only the inserted rows are returned. With `action: 'merge'`, the matching
289
+ record is updated: the fields in `merge` that the row supplies, or by
290
+ default every stored field the rows supply except the keys, the id, ownership
291
+ fields and the lock version. A row supplies the fields the caller sent, its
292
+ belongs-to links and the fields a setter adds; schema defaults (`defaultTo`)
293
+ apply only to the rows merge inserts, never to the records it updates.
294
+
295
+ Keys compare exactly. A stored value is first read as its field reads it, so
296
+ `true` matches a boolean stored as 1 and `1.5` a decimal stored as `1.50`, but
297
+ strings compare as they are, without the column's collation. When the database
298
+ finds a record that matches the keys only by its own comparison, for example
299
+ `'Urgent'` against `'urgent'` under MySQL's case-insensitive collations, the
300
+ batch fails with a `conflict` error (409) instead of guessing.
301
+
302
+ Upserts cannot reach another owner's rows:
303
+
304
+ - `keys` must include every autofilter (ownership) field of the resource. The
305
+ unique index should include them too. The id is a key only on a resource
306
+ with no ownership fields and no row policy.
307
+ - `merge` is refused on a resource with a row policy. Insert with `ignore`,
308
+ then `patchMany` the rows the caller may change.
309
+ - Only a match on the conflict keys counts. A row that clashes with an existing
310
+ record on any other unique index, the primary key included, fails the batch.
311
+ - `merge`, and `ignore` on MySQL and MariaDB, need a unique index whose
312
+ columns are all among the keys, and refuse the batch
313
+ (`RestApiBulkWriteUnsupportedError`) without one. Without it the keys could
314
+ match several records, and the locking read would examine, and on MySQL
315
+ lock, every row of the table. (`ignore` on PostgreSQL and SQLite needs a
316
+ unique index on exactly the keys, as `ON CONFLICT` does.) The first upsert
317
+ on a table reads its indexes once.
318
+
319
+ `merge` reads the records that match the keys under a row lock, inserts the
320
+ other rows with a plain INSERT and updates the matched records by id, a fixed
321
+ number of statements per chunk. The records it updates go through a patch's
322
+ checks as well as a create's: `checkPermissions` runs with `method: 'patch'`
323
+ and the matched record as `minimalRecord`, the resource is refused if a
324
+ per-record patch hook without `{ batch: true }` runs for it, and a key that
325
+ matches a record the caller cannot see fails the batch as `forbidden`.
326
+
327
+ With `returning: 'count'` an upsert counts the rows it inserted, and with
328
+ `merge` the records it updated as well.
329
+
330
+ `ignore` runs `INSERT … ON CONFLICT DO NOTHING` on PostgreSQL and SQLite. On
331
+ MySQL and MariaDB, whose `ON DUPLICATE KEY` fires on every unique index, it
332
+ reads the matching keys under a row lock and inserts the rest. With `merge`,
333
+ and with `ignore` on MySQL and MariaDB, two transactions that upsert the same
334
+ new key at the same time can fail with a unique-key error (PostgreSQL 23505)
335
+ or a deadlock (MySQL 1213); retry the batch. InnoDB keeps the locks of every
336
+ row a locking read examined until the transaction ends, so keep upserting
337
+ transactions short.
338
+
339
+ A single `put` creates a record at the id the caller chose, and is the one
340
+ write that tells an id taken by another owner or hidden from the caller
341
+ apart from a free one: it
342
+ answers `not_found` for the first and creates the second. Where ids must not
343
+ leak between owners, let the server choose them: create with `post` or
344
+ `postMany` (or upsert on keys that include the owner) and keep `put` for
345
+ records the caller already has.
346
+
347
+ `merge` on every database, and `ignore` on MySQL and MariaDB, find the rows
348
+ that match the conflict keys with a locking read of the bare table, before the
349
+ caller's scope is checked. A matching row the caller cannot see, such as one a
350
+ `knexQueryFiltering` hook hides, is therefore locked until commit, and waited
351
+ for while another transaction holds it, as `lockByIds` does with a hidden id
352
+ (see [scoped reads and locks](34-scoped-reads-and-locks.md)). `merge` then fails
353
+ the batch as `forbidden`, and `ignore` skips the row. With `keys: ['id']` that
354
+ read locks the listed ids by primary key.
355
+
356
+ ## Transactions
357
+
358
+ Without a `transaction` the call owns one transaction and commits it at the end,
359
+ or rolls everything back on any failure. To combine a many-row write with other
360
+ work, pass a transaction from `api.transaction`; the method joins it and never
361
+ commits or rolls it back.
362
+
363
+ ```js
364
+ await api.transaction(async transaction => {
365
+ const order = await api.resources.orders.post({ data: { number: 'A-1' }, transaction, format: 'plain' })
366
+ await api.resources.order_lines.postMany({
367
+ data: lines.map(line => ({ ...line, order: order.id })), transaction
368
+ })
369
+ })
370
+ ```
371
+
372
+ A raw Knex transaction, a savepoint or a completed transaction is refused before
373
+ anything runs. Errors are `RestApiWriteError`s carrying the
374
+ `transactionOutcome`; the original error is their `cause`.
375
+
376
+ ## Hooks and refusals
377
+
378
+ Many-row methods run batch hooks once per call instead of the per-record write
379
+ hooks:
380
+
381
+ | Method | Batch hooks (in order) | Context |
382
+ | --- | --- | --- |
383
+ | `postMany` | `beforeProcessingPostMany`, `beforeDataCallPostMany`, `afterDataCallPostMany` | `inputRecords`; `ids` after the insert |
384
+ | `patchMany`, `patchWhereIds`, `patchWhere` | `beforeProcessingPatchMany`, `beforeDataCallPatchMany`, `afterDataCallPatchMany` | `inputRecords`, `rawFields`, `ids` |
385
+ | `deleteMany`, `deleteWhere` | `beforeDataCallDeleteMany`, `afterDataCallDeleteMany` | `ids` |
386
+
387
+ In the `afterDataCall…Many` hooks, `ids` lists only the rows the write
388
+ changed, and for patches `inputRecords` lists their inputs in the same order,
389
+ so a row that `missing: 'skip'`, `onlyIf` or a selection left out appears in
390
+ neither. `afterCommit` and `afterRollback` run once, with `context.method` set
391
+ to the many-row method. `checkPermissions` still runs for every row, with
392
+ `method` `'post'`, `'patch'` or `'delete'` and the row's position as
393
+ `originalContext.bulkIndex`.
394
+
395
+ A resource whose per-record write hooks would be skipped is refused rather than
396
+ written around them. Register a per-record handler with `{ batch: true }` when
397
+ a batch hook does its work, or when it does not apply to many-row writes:
398
+
399
+ ```js
400
+ api.addHook('afterDataCallPost', 'audit-create', { batch: true }, auditOne)
401
+ api.addHook('afterDataCallPostMany', 'audit-create-many', {}, auditMany)
402
+ ```
403
+
404
+ The built-in plugins have batch forms. Autofilter stamps every row; Socket.IO
405
+ notifies each changed record that matches a subscription; query projections
406
+ apply to every row. Some features stay single-row. The methods throw
407
+ `RestApiBulkWriteUnsupportedError` (code `REST_API_BULK_WRITE_UNSUPPORTED`,
408
+ HTTP 422) before writing when:
409
+
410
+ - the resource uses AnyAPI storage;
411
+ - the resource has a `versionField`, or its writes advance another resource's
412
+ version (use single writes with `expectedVersion`, or a `lockVersionField`
413
+ with `onlyIf`);
414
+ - a per-record write hook without `{ batch: true }` runs for the resource
415
+ (the error's `details.hooks` names them; HTTP responses leave details out);
416
+ - rows set many-to-many or reverse relationships;
417
+ - rows set file fields (`FileHandlingPlugin`), directly or with raw values;
418
+ - rows create positioned records or move them (`PositioningPlugin`), directly
419
+ or with raw values.
420
+
421
+ ## HTTP routes
422
+
423
+ `BulkOperationsPlugin` exposes the methods over HTTP. Install it after the
424
+ connector and before declaring resources:
425
+
426
+ ```js
427
+ import { BulkOperationsPlugin } from 'json-rest-api/plugins/core/bulk-operations-plugin.js'
428
+
429
+ await api.use(BulkOperationsPlugin, { maxBulkOperations: 1000 })
430
+ ```
431
+
432
+ `maxBulkOperations` (default 1,000) is the only option; requests with more
433
+ entries fail with 422 before anything runs.
434
+
435
+ | Request | Body | Runs | Response |
436
+ | --- | --- | --- | --- |
437
+ | `POST /api/books/bulk` | `{ data: [resource objects] }` | `postMany` | 201, `{ data: [records] }` |
438
+ | `PATCH /api/books/bulk` | `{ data: [resource objects with id] }` | `patchMany` | 200, `{ data: [records] }` |
439
+ | `DELETE /api/books/bulk` | `{ data: [{ type, id }] }` | `deleteMany` | 200, `{ data: [identifiers] }` |
440
+
441
+ ```json
442
+ {
443
+ "data": [
444
+ { "type": "books", "id": "1", "attributes": { "title": "Revised" } },
445
+ { "type": "books", "id": "2", "attributes": { "stock": 5 } }
446
+ ]
447
+ }
448
+ ```
449
+
450
+ Responses are full JSON:API records. The routes take no query parameters and
451
+ no `If-Match`. The request's authenticated context reaches every permission
452
+ check. Ids the caller cannot see give 404, a permission denial 403, invalid
453
+ rows and refused resources 422. Nothing is written in any of these cases. The
454
+ plugin reserves `/RESOURCE/bulk`, including when the resource accepts opaque
455
+ ids. This protocol is not the JSON:API Atomic Operations extension.
456
+
457
+ Coming from the 2.x `bulkPost`, `bulkPatch` and `bulkDelete`? See
458
+ [migrating to v3](35-migrating-to-v3.md#many-row-writes-replace-the-bulk-methods).
@@ -166,6 +166,9 @@ subscribe. It does not replay changes missed during disconnection.
166
166
  | PATCH or PUT replacement | `resource.updated` | `patch` or `put` |
167
167
  | Relationship POST/PATCH/DELETE | `resource.updated` | `patch` |
168
168
  | Resource DELETE | `resource.deleted` | `delete` |
169
+ | `postMany` | `resource.created`, one per row | `post` |
170
+ | `patchMany`, `patchWhereIds`, `patchWhere` | `resource.updated`, one per row | `patch` |
171
+ | `deleteMany`, `deleteWhere` | `resource.deleted`, one per row | `delete` |
169
172
 
170
173
  IDs are strings. Deletion also includes `deletedRecord: { id }`, without deleted
171
174
  attributes. Reverse relationship changes notify the parent and the changed
@@ -186,6 +189,11 @@ failures are logged and count as nonmatches; a successful match before or after
186
189
  is sufficient. Applications should account for these additional queries and
187
190
  ensure query hooks can run inside the write transaction.
188
191
 
192
+ [Many-row writes](27-many-row-writes.md) run the same eligibility check for all
193
+ their rows at once: one query per subscription for each 1,000 changed ids,
194
+ before and after the write. Each subscription receives one notification for
195
+ every changed row that matched it.
196
+
189
197
  ## Authentication and permissions
190
198
 
191
199
  For an application with authenticated users, install the plugin with a
@@ -261,8 +269,8 @@ change. A rejected call after commit can still have emitted notifications. These
261
269
  HTTP polling, including relationship changes and child rollback.
262
270
 
263
271
  Library writes now require the handle from `api.transaction` when joining an
264
- outer unit. Resource and atomic-bulk child completion hooks run after confirmed
265
- owner completion; non-atomic bulk retains one outcome per entry. Rolled-back
272
+ outer unit. Resource and many-row completion hooks run after confirmed
273
+ owner completion. Rolled-back
266
274
  units do not flush queued notifications. Explicit callbacks are tested with
267
275
  repeated changes to the same record, callback/caught-write/caught-SQL failures,
268
276
  and rejected completion hooks on both storage modes and all three databases.