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.
- package/README.md +7 -2
- package/docs/API.md +262 -54
- package/docs/GUIDE/01-getting-started.md +1 -1
- package/docs/GUIDE/07-polymorphic-relationships.md +4 -2
- package/docs/GUIDE/09-pagination-and-sorting.md +4 -2
- package/docs/GUIDE/13-hooks-and-lifecycle.md +34 -6
- package/docs/GUIDE/14-custom-resource-methods.md +3 -2
- package/docs/GUIDE/16-autofiltering.md +54 -2
- package/docs/GUIDE/17-row-policies.md +15 -5
- package/docs/GUIDE/19-managed-transactions.md +8 -9
- package/docs/GUIDE/20-transaction-outcomes.md +3 -4
- package/docs/GUIDE/21-schema-and-migrations.md +7 -1
- package/docs/GUIDE/22-version-field-migrations.md +3 -3
- package/docs/GUIDE/27-many-row-writes.md +458 -0
- package/docs/GUIDE/28-socketio.md +10 -2
- package/docs/GUIDE/30-backend-capabilities.md +42 -2
- package/docs/GUIDE/31-positioning.md +3 -1
- package/docs/GUIDE/33-migrating-to-v2.md +2 -2
- package/docs/GUIDE/34-scoped-reads-and-locks.md +464 -0
- package/docs/GUIDE/35-migrating-to-v3.md +325 -0
- package/docs/GUIDE/index.md +5 -2
- package/docs/QUICKSTART.md +2 -2
- package/docs/contributing.md +1 -1
- package/index.d.ts +8 -1
- package/index.js +4 -0
- package/lib/rest-api-errors.js +79 -0
- package/lib/runtime/json-rest-api.js +18 -3
- package/lib/system-context.js +42 -0
- package/package.json +3 -2
- package/plugins/core/bulk-operations-plugin.js +54 -436
- package/plugins/core/connectors/lib/transport-http-helpers.js +34 -3
- package/plugins/core/file-handling-plugin.js +17 -2
- package/plugins/core/lib/anyapi/anyapi-registry.js +3 -2
- package/plugins/core/lib/anyapi/canonical-link-store.js +5 -2
- package/plugins/core/lib/anyapi/canonical-relationship-reader.js +56 -60
- package/plugins/core/lib/dbIntrospection.js +106 -6
- package/plugins/core/lib/querying/aggregate-query.js +215 -22
- package/plugins/core/lib/querying/include-query-helpers.js +11 -13
- package/plugins/core/lib/querying/include-to-one.js +54 -59
- package/plugins/core/lib/querying/knex-pagination-helpers.js +9 -2
- package/plugins/core/lib/querying/query-constraint.d.ts +11 -0
- package/plugins/core/lib/querying/query-constraint.js +45 -0
- package/plugins/core/lib/querying/read-only-query.js +92 -0
- package/plugins/core/lib/querying-writing/compile-schemas.js +14 -0
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
- package/plugins/core/lib/storage/canonical-data-helpers.js +103 -39
- package/plugins/core/lib/storage/many-row-data-helpers.js +738 -0
- package/plugins/core/lib/storage/ordinary-data-helpers.js +63 -79
- package/plugins/core/lib/storage/single-row-writes.js +71 -0
- package/plugins/core/lib/storage/storage-adapter.js +18 -8
- package/plugins/core/lib/storage/storage-mapping.js +4 -1
- package/plugins/core/lib/storage/storage-types.d.ts +21 -3
- package/plugins/core/lib/writing/many-to-many-manipulations.js +10 -5
- package/plugins/core/lib/writing/relationship-processor.js +33 -37
- package/plugins/core/rest-api-anyapi-knex-plugin.js +3 -0
- package/plugins/core/rest-api-autofilter-plugin.js +137 -10
- package/plugins/core/rest-api-knex-plugin.js +2 -0
- package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
- package/plugins/core/rest-api-plugin-methods/common.js +144 -84
- package/plugins/core/rest-api-plugin-methods/delete-many.js +132 -0
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +22 -0
- package/plugins/core/rest-api-plugin-methods/get.js +48 -42
- package/plugins/core/rest-api-plugin-methods/many-row-common.js +274 -0
- package/plugins/core/rest-api-plugin-methods/patch-many.js +333 -0
- package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
- package/plugins/core/rest-api-plugin-methods/post-many.js +295 -0
- package/plugins/core/rest-api-plugin-methods/put.js +5 -1
- package/plugins/core/rest-api-plugin-methods/scoped-reads.js +221 -0
- package/plugins/core/rest-api-plugin.js +17 -0
- package/plugins/core/rest-api-positioning-plugin.js +22 -4
- package/plugins/core/rest-api-query-projections-plugin.js +9 -1
- package/plugins/core/rest-api-row-policy-plugin.js +4 -0
- package/plugins/core/socketio-plugin.js +80 -2
- package/types/autofilter.d.ts +5 -1
- package/types/data-access-methods.d.ts +141 -0
- package/types/errors.d.ts +45 -0
- package/types/hook-context.d.ts +21 -0
- package/types/plugin-options.d.ts +3 -1
- package/types/resource-methods.d.ts +27 -4
- package/types/row-policy.d.ts +3 -0
- package/types/runtime.d.ts +7 -1
- package/docs/GUIDE/27-bulk-operations.md +0 -277
- 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
|
|
312
|
-
|
|
313
|
-
under `queryPurpose: 'include'`.
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
51
|
-
whole
|
|
52
|
-
individually.
|
|
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.
|
|
89
|
-
|
|
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
|
|
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.
|
|
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,
|
|
10
|
-
outcome of the transaction they represent. HTTP
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
|
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
|
|
151
|
-
|
|
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
|
|
265
|
-
owner completion
|
|
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.
|