json-rest-api 3.0.0-rc.0 → 3.0.1
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 +2 -2
- package/docs/API.md +60 -27
- 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 +6 -1
- package/docs/GUIDE/16-autofiltering.md +1 -1
- package/docs/GUIDE/17-row-policies.md +8 -6
- package/docs/GUIDE/21-schema-and-migrations.md +7 -1
- package/docs/GUIDE/27-many-row-writes.md +201 -28
- package/docs/GUIDE/30-backend-capabilities.md +16 -2
- package/docs/GUIDE/33-migrating-to-v2.md +2 -2
- package/docs/GUIDE/34-scoped-reads-and-locks.md +165 -26
- package/docs/GUIDE/35-migrating-to-v3.md +141 -10
- package/lib/rest-api-errors.js +6 -5
- package/package.json +2 -2
- package/plugins/core/connectors/lib/transport-http-helpers.js +31 -3
- package/plugins/core/file-handling-plugin.js +2 -1
- 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 +12 -6
- 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 +50 -13
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
- package/plugins/core/lib/storage/canonical-data-helpers.js +72 -41
- package/plugins/core/lib/storage/many-row-data-helpers.js +409 -124
- package/plugins/core/lib/storage/ordinary-data-helpers.js +67 -81
- 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 +12 -1
- package/plugins/core/lib/writing/many-to-many-manipulations.js +5 -2
- package/plugins/core/lib/writing/relationship-processor.js +33 -37
- package/plugins/core/rest-api-autofilter-plugin.js +30 -8
- package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
- package/plugins/core/rest-api-plugin-methods/common.js +117 -77
- package/plugins/core/rest-api-plugin-methods/delete-many.js +54 -28
- 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 +51 -8
- package/plugins/core/rest-api-plugin-methods/patch-many.js +81 -34
- package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
- package/plugins/core/rest-api-plugin-methods/post-many.js +149 -21
- package/plugins/core/rest-api-plugin-methods/put.js +5 -1
- package/plugins/core/rest-api-plugin-methods/scoped-reads.js +72 -27
- package/plugins/core/rest-api-plugin.js +2 -1
- package/plugins/core/rest-api-positioning-plugin.js +1 -1
- package/types/data-access-methods.d.ts +32 -11
- package/types/errors.d.ts +5 -4
- package/types/hook-context.d.ts +2 -0
|
@@ -2031,8 +2031,8 @@ empty cursor rejects before SQL. Follow generated links to retain filters,
|
|
|
2031
2031
|
fieldsets and the effective size.
|
|
2032
2032
|
|
|
2033
2033
|
`enablePaginationCounts` controls totals for offset pages. With counts disabled,
|
|
2034
|
-
offset metadata contains `page` and `pageSize
|
|
2035
|
-
|
|
2034
|
+
offset metadata contains `page` and `pageSize` (and, since 3.0, `hasMore`, from
|
|
2035
|
+
one extra row); it does not promise totals. Cursor pages use one extra row for `hasMore` and never issue a count
|
|
2036
2036
|
query, regardless of this setting. Ordinary storage previously issued a count
|
|
2037
2037
|
on the first cursor page and discarded its result; that extra query and its
|
|
2038
2038
|
count-filter hook invocation have been removed. Collection predicates still
|
|
@@ -15,7 +15,7 @@ it does not need hand-written Knex that skips them:
|
|
|
15
15
|
- `scopedQuery`, a read-only Knex builder already limited to the caller's rows;
|
|
16
16
|
- `queryAll`, every matching row in cursor pages;
|
|
17
17
|
- window includes, the newest few related rows per parent;
|
|
18
|
-
- `lockByIds` and `claim`, row locks inside a transaction;
|
|
18
|
+
- `lockByIds`, `lockWhere` and `claim`, row locks inside a transaction;
|
|
19
19
|
- `lockVersionField`, a counter every update advances;
|
|
20
20
|
- system contexts, for jobs that work across every owner.
|
|
21
21
|
|
|
@@ -149,9 +149,33 @@ shared: every owner reads them. The field must be `nullable`.
|
|
|
149
149
|
| Operation | Sees |
|
|
150
150
|
| --- | --- |
|
|
151
151
|
| `query`, `get`, includes, `aggregate`, `scopedQuery` (`purpose: 'report'`) | the owner's rows and shared rows |
|
|
152
|
-
| many-row writes, `lockByIds`, `claim`, `scopedQuery` with `purpose` `'bulk-write'`, `'lock'` or `'claim'` | only the owner's rows |
|
|
153
|
-
| single `patch`, `put`, `delete` of a shared row | refused as `forbidden` outside a system context |
|
|
154
|
-
| `post` | stamps the owner; a create
|
|
152
|
+
| many-row writes, `lockByIds`, `lockWhere`, `claim`, `scopedQuery` with `purpose` `'bulk-write'`, `'lock'` or `'claim'` | only the owner's rows |
|
|
153
|
+
| single `patch`, `put`, `delete` of a shared row, and `postRelationship` or `deleteRelationship` on it | refused as `forbidden` outside a system context |
|
|
154
|
+
| `post` | stamps the owner; a create that would leave the field NULL is `forbidden` |
|
|
155
|
+
|
|
156
|
+
A filter with `required: false` may not resolve, for example for a caller
|
|
157
|
+
without a workspace. Reads are then not narrowed by it, but writes, locks and
|
|
158
|
+
claims still skip the shared rows, and a create outside a system context must
|
|
159
|
+
name an owner.
|
|
160
|
+
|
|
161
|
+
`deleteRelationship` on a many-to-many relationship leaves links to targets
|
|
162
|
+
the caller cannot see, as it leaves links to missing targets: when the target
|
|
163
|
+
type has ownership filters or a row policy, it reads which targets are visible
|
|
164
|
+
first (one read per type).
|
|
165
|
+
|
|
166
|
+
A read shows a belongs-to link whose target the caller cannot see as
|
|
167
|
+
unassigned (`data: null`), so a caller that writes the record back, as a form
|
|
168
|
+
sends what it read, sends null for a link it never saw. `patch`, `put` and
|
|
169
|
+
the many-row patches therefore keep such a link: a belongs-to (or polymorphic
|
|
170
|
+
belongs-to) the write sets to null, whose stored target exists but is hidden
|
|
171
|
+
from the writer, stays as stored, and the rest of the write applies. Linking
|
|
172
|
+
a visible target, and clearing a link to a visible or missing target, apply as
|
|
173
|
+
asked. A write that names a target is checked as before, even when it names
|
|
174
|
+
the stored one: accepting a hidden target because it is unchanged would tell
|
|
175
|
+
the writer which hidden record the link points to, since any other hidden id
|
|
176
|
+
is refused. Only a write that clears a link pays for the check: one read of the
|
|
177
|
+
targets' visibility, and one more of the hidden targets' existence. A
|
|
178
|
+
many-row patch with such a row reads its rows first.
|
|
155
179
|
|
|
156
180
|
Create and change shared rows with a [system context](#system-contexts).
|
|
157
181
|
`api.autofilter.getScopeConfig(...)` reports `shared` for each filter, and
|
|
@@ -159,12 +183,17 @@ Create and change shared rows with a [system context](#system-contexts).
|
|
|
159
183
|
|
|
160
184
|
## scopedQuery
|
|
161
185
|
|
|
162
|
-
`scopedQuery(params, context)` resolves to `{ query }`: a Knex builder
|
|
163
|
-
resource's
|
|
186
|
+
`scopedQuery(params, context)` resolves to `{ query }`: a Knex builder over the
|
|
187
|
+
resource's rows with the caller's ownership filters, row policy and the given
|
|
164
188
|
search `filters` already applied. (It is wrapped in an object because a Knex
|
|
165
189
|
builder is thenable and would run if an async method returned it.) Use it for
|
|
166
190
|
reports that `aggregate` cannot express, and as a subquery.
|
|
167
191
|
|
|
192
|
+
The builder selects from a derived table, `(SELECT alias.* FROM table AS alias
|
|
193
|
+
WHERE …) AS alias`, so conditions you add, `orWhere` and `whereRaw('a OR b')`
|
|
194
|
+
included, can only narrow the scoped rows. Refer to columns as `alias.column`
|
|
195
|
+
or by bare name.
|
|
196
|
+
|
|
168
197
|
| Parameter | Default | Meaning |
|
|
169
198
|
| --- | --- | --- |
|
|
170
199
|
| `filters` | | Search filters, validated as `query` validates them |
|
|
@@ -183,11 +212,20 @@ const totals = await query
|
|
|
183
212
|
|
|
184
213
|
The caller needs `query` permission on the resource. The builder is
|
|
185
214
|
SELECT-only: `insert`, `update`, `delete`, `forUpdate` and the other locking
|
|
186
|
-
reads, table changes
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
215
|
+
reads, table changes (`from`, `fromRaw`, `table`), the methods that clear
|
|
216
|
+
existing predicates, set operations (`union`, `unionAll`, `intersect`,
|
|
217
|
+
`except`), common table expressions (`with`, `withRecursive`,
|
|
218
|
+
`withMaterialized`), access to its Knex `client`, replacing or deleting its
|
|
219
|
+
statement lists (`_statements`, `_single`) or its statement kind, and defining
|
|
220
|
+
properties throw `RestApiScopedQueryReadOnlyError`, on clones too. Read from
|
|
221
|
+
outside Knex's own methods, `_statements` is a frozen copy, so it cannot be
|
|
222
|
+
changed in place either, and `_single` is a copy, so changing it changes no
|
|
223
|
+
query. The builder
|
|
224
|
+
guards against mistakes, not hostile code: code that means to bypass it can use
|
|
225
|
+
Knex directly. A table you join is not scoped by the builder; join only what
|
|
226
|
+
the caller may see, or scope it with its own `scopedQuery`.
|
|
227
|
+
`readOnlyQueryBuilder(builder, { resourceType })` is exported for builders your
|
|
228
|
+
own code produces.
|
|
191
229
|
|
|
192
230
|
## queryAll
|
|
193
231
|
|
|
@@ -244,16 +282,90 @@ and MariaDB 10.2+ do. AnyAPI storage applies the same rule.
|
|
|
244
282
|
|
|
245
283
|
## Row locks
|
|
246
284
|
|
|
247
|
-
`lockByIds` and `claim` lock rows until the transaction ends.
|
|
248
|
-
outside a transaction (`RestApiLockOutsideTransactionError`);
|
|
249
|
-
`api.transaction`. They return the records as the locking read saw
|
|
285
|
+
`lockByIds`, `lockWhere` and `claim` lock rows until the transaction ends. All
|
|
286
|
+
three refuse to run outside a transaction (`RestApiLockOutsideTransactionError`);
|
|
287
|
+
pass one from `api.transaction`. They return the records as the locking read saw
|
|
288
|
+
them, through the read pipeline of `query`: `checkDataPermissions` runs for each
|
|
289
|
+
row as for `get`, then `enrichRecord`, computed fields and the `finish` and
|
|
290
|
+
`finishQuery` hooks, which see `method: 'query'` and `lock: true`.
|
|
291
|
+
|
|
292
|
+
The locking read is the lock's first statement on the resource's rows and
|
|
293
|
+
carries the caller's ownership and row-policy predicates itself; the library
|
|
294
|
+
makes no plain read of those rows before it. On MySQL and MariaDB this matters:
|
|
295
|
+
under REPEATABLE READ the first plain read of a transaction fixes its
|
|
296
|
+
snapshot, so a plain read before a lock wait would hide from every later read
|
|
297
|
+
in the transaction what the lock holder committed while it waited (and with
|
|
298
|
+
`innodb_snapshot_isolation=ON` the lock itself would fail with
|
|
299
|
+
`ER_CHECKREAD`). After a lock, plain reads in the same transaction see what the
|
|
300
|
+
previous holder committed, as long as the transaction made no plain read
|
|
301
|
+
before the lock. Some plain reads can still come first or run inside the
|
|
302
|
+
statement, and start the snapshot: subqueries of a row policy (InnoDB reads a
|
|
303
|
+
locking read's subqueries without locks, from the snapshot), the scope
|
|
304
|
+
subquery of a selection write's locking read on MySQL (`patchWhere`,
|
|
305
|
+
`deleteWhere`), a `checkPermissions` or `knexQueryFiltering` hook that queries
|
|
306
|
+
the database, and the first `lockWhere` for a MySQL table and set of filtered
|
|
307
|
+
columns, which reads the table's indexes (below). Where the snapshot matters, keep those reads out of the lock's
|
|
308
|
+
path, or make them before the transaction. For the same reason the query must
|
|
309
|
+
stay a plain `SELECT` on the resource's table: a row policy or
|
|
310
|
+
`knexQueryFiltering` hook that adds joins, a common table expression or a set
|
|
311
|
+
operation, or replaces the source table, is refused for all three (rule
|
|
312
|
+
`lock_single_table` or `claim_single_table`), because the database would lock
|
|
313
|
+
the joined rows too or lock nothing at all.
|
|
250
314
|
|
|
251
315
|
`lockByIds({ ids, transaction, missing, format, queryParams: { fields } })`
|
|
252
316
|
locks the listed rows the caller can see, in id order, so two transactions that
|
|
253
|
-
lock overlapping sets cannot deadlock on each other.
|
|
317
|
+
lock overlapping sets cannot deadlock on each other. It locks 1,000 ids per
|
|
318
|
+
statement; longer lists are sorted first (integer ids numerically, other ids
|
|
319
|
+
by code unit, the database's order for integer keys and binary collations), so
|
|
320
|
+
they too lock in one order. Hidden or missing ids throw
|
|
254
321
|
`RestApiRecordsNotFoundError`, or are left out with `missing: 'skip'`. The
|
|
255
|
-
locked rows
|
|
256
|
-
|
|
322
|
+
locked rows show the latest committed values even when the transaction began
|
|
323
|
+
earlier.
|
|
324
|
+
|
|
325
|
+
On MySQL and MariaDB, InnoDB locks each listed primary-key row before it
|
|
326
|
+
evaluates the ownership and row-policy predicates, and under REPEATABLE READ
|
|
327
|
+
holds that lock until commit. A listed id the caller cannot see is therefore
|
|
328
|
+
still locked, and waited for when another transaction holds it, although it
|
|
329
|
+
is not returned: its lock is a short denial of service against the row's owner
|
|
330
|
+
and its wait a timing signal that the row exists. This is how
|
|
331
|
+
`WHERE id = ? AND owner = ? FOR UPDATE` behaves on InnoDB. Pass `lockByIds` ids
|
|
332
|
+
from a scoped read or from a route that has already checked the caller may use
|
|
333
|
+
them, never a raw list from a request.
|
|
334
|
+
|
|
335
|
+
`lockWhere({ filters, transaction, format, queryParams: { fields } })` locks
|
|
336
|
+
every row the filters select that the caller can see, in id order, waiting for
|
|
337
|
+
transactions that hold them, and returns them. A row that starts to match while
|
|
338
|
+
it waits is locked and returned too: InnoDB's locking reads see the latest rows,
|
|
339
|
+
and on PostgreSQL, whose statements only find rows committed when they began,
|
|
340
|
+
`lockWhere` reads again under the locks it holds until no new row appears. It
|
|
341
|
+
locks at most 1,000 rows and throws `RestApiRowLimitError` when more match. Use
|
|
342
|
+
it, not `scopedQuery`, to lock by filter: the scoped builder is a read-only
|
|
343
|
+
derived table that cannot lock.
|
|
344
|
+
|
|
345
|
+
On MySQL and MariaDB, InnoDB locks every row a locking read examines, and every
|
|
346
|
+
gap between them, until commit. Ordering by id could make it walk the primary
|
|
347
|
+
key across every owner's rows, so there `lockWhere` has no `ORDER BY` and
|
|
348
|
+
forces (`FORCE INDEX`) an index that keeps the read inside the caller's rows;
|
|
349
|
+
rows come in that index's order. The index's leading columns must be the whole
|
|
350
|
+
ownership (autofilter) columns the read is limited to (a prefix index on them
|
|
351
|
+
does not count), or, when no ownership condition applies, it must start with a
|
|
352
|
+
column the filters compare. Among those, an index whose next column the filters
|
|
353
|
+
compare is preferred. Invisible (MySQL) and ignored (MariaDB) indexes cannot be
|
|
354
|
+
forced and do not count. Without such an index `lockWhere` throws
|
|
355
|
+
`RestApiBulkWriteUnsupportedError` before locking anything. Each call reads the
|
|
356
|
+
connection's schema with `SELECT DATABASE()`, which reads no table, so one pool
|
|
357
|
+
can serve several databases. The first call for a schema, table and set of
|
|
358
|
+
columns also reads the table's indexes, in the transaction, and the index it
|
|
359
|
+
picks is remembered while the process runs: restart after changing the
|
|
360
|
+
indexes of a table that `lockWhere` uses.
|
|
361
|
+
|
|
362
|
+
No ownership condition applies to a resource without ownership fields, to a
|
|
363
|
+
system context, or to a resource that has only a row policy, which is not an
|
|
364
|
+
ownership condition. There the forced index starts with a filtered column, so
|
|
365
|
+
the read examines and locks every row the filters match, the rows of other
|
|
366
|
+
policy owners included, although it returns only the rows the policy shows.
|
|
367
|
+
Give such a resource ownership fields, or lock its rows with `lockByIds` on ids
|
|
368
|
+
from a scoped read.
|
|
257
369
|
|
|
258
370
|
`claim({ filters, sort, limit, transaction, format, queryParams: { fields } })`
|
|
259
371
|
takes up to `limit` matching rows (default 1, at most 1,000) that no other
|
|
@@ -263,14 +375,31 @@ queue. Change the claimed rows in the same transaction, as the example does.
|
|
|
263
375
|
A row policy that joins other tables is refused for `claim`, because the
|
|
264
376
|
database would lock the joined rows too.
|
|
265
377
|
|
|
266
|
-
| Database | `lockByIds` | `claim` |
|
|
267
|
-
| --- | --- | --- |
|
|
268
|
-
| PostgreSQL | `FOR UPDATE` on the visible ids | `FOR UPDATE SKIP LOCKED` |
|
|
269
|
-
| MySQL 8, MariaDB 10.6+ | `FOR UPDATE` on the
|
|
270
|
-
| SQLite | a scoped read | a scoped read |
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
378
|
+
| Database | `lockByIds` | `lockWhere` | `claim` |
|
|
379
|
+
| --- | --- | --- | --- |
|
|
380
|
+
| PostgreSQL | `FOR UPDATE` on the visible ids | `FOR UPDATE`, read again until no new row appears | `FOR UPDATE SKIP LOCKED` |
|
|
381
|
+
| MySQL 8, MariaDB 10.6+ | `FOR UPDATE` on the listed ids, hidden ones included (above) | `FOR UPDATE` without `ORDER BY`, forced onto an index that starts with the ownership or a filtered column | reads candidates, then locks windows of them by primary key on the bare table with `SKIP LOCKED` (InnoDB locks every row a locking read examines), and reads the held rows again under the lock to drop any that stopped matching |
|
|
382
|
+
| SQLite | the write lock, then a scoped read | the write lock, then a scoped read | the write lock, then a scoped read |
|
|
383
|
+
|
|
384
|
+
On MySQL and MariaDB the candidate read of `claim` is a plain read, the only way
|
|
385
|
+
to choose rows in sort order without locking every row the sort examines, so it
|
|
386
|
+
starts the transaction's snapshot. `claim` never waits for a lock, so that
|
|
387
|
+
snapshot misses only what commits during the claim itself; with
|
|
388
|
+
`innodb_snapshot_isolation=ON` a candidate that another transaction changes
|
|
389
|
+
between the two reads makes the claim fail with `ER_CHECKREAD`, and the caller
|
|
390
|
+
retries.
|
|
391
|
+
|
|
392
|
+
SQLite allows one writer at a time, but a transaction that has only read holds a
|
|
393
|
+
shared lock, which does not keep another connection from writing, and its own
|
|
394
|
+
later write could then fail with `SQLITE_BUSY` halfway through the work. So on
|
|
395
|
+
SQLite each lock first runs a write that changes nothing
|
|
396
|
+
(`UPDATE … SET id = id WHERE 0 = 1`), which takes the database's write lock for
|
|
397
|
+
the rest of the transaction, once per transaction, and then reads the rows. The
|
|
398
|
+
caller's transaction can stay a deferred `BEGIN`. Because the lock is a write,
|
|
399
|
+
it fails with `SQLITE_READONLY` on a read-only connection. In WAL mode, a
|
|
400
|
+
transaction that has already read keeps that snapshot, and if another
|
|
401
|
+
connection has written since, taking the write lock fails with
|
|
402
|
+
`SQLITE_BUSY_SNAPSHOT`; roll back and run the transaction again.
|
|
274
403
|
|
|
275
404
|
## Lock versions
|
|
276
405
|
|
|
@@ -317,7 +446,17 @@ With a system context:
|
|
|
317
446
|
- autofilter applies no ownership filters, and creates must set every ownership
|
|
318
447
|
field explicitly (NULL creates a shared row);
|
|
319
448
|
- row policies receive `system: true` and decide what it means for them;
|
|
320
|
-
- permission checks run as usual; `isSystemContext(context)` lets them tell
|
|
449
|
+
- permission checks run as usual; `isSystemContext(context)` lets them tell;
|
|
450
|
+
- `aggregate` may group by, and count distinct, a hidden ownership field, so a
|
|
451
|
+
job can find its units of work, then run each under a context for that owner:
|
|
452
|
+
|
|
453
|
+
```js
|
|
454
|
+
const { data } = await api.resources.entries.aggregate({
|
|
455
|
+
groupBy: ['workspace_id'],
|
|
456
|
+
measures: { rows: { count: '*' } },
|
|
457
|
+
queryParams: { filters: { status: 'open' } }
|
|
458
|
+
}, createSystemContext('find workspaces with open entries'))
|
|
459
|
+
```
|
|
321
460
|
|
|
322
461
|
A context with `system: true` but no marker throws
|
|
323
462
|
`REST_API_SYSTEM_CONTEXT_FORGED`. Create system contexts only in server code
|
|
@@ -20,7 +20,8 @@ bulk options are rejected with a message that names their replacement.
|
|
|
20
20
|
and compare-and-set (`onlyIf`). See [many-row writes](27-many-row-writes.md).
|
|
21
21
|
- **Scoped reads:** `scopedQuery`, a read-only Knex builder limited to the
|
|
22
22
|
caller's rows, and `queryAll`, every matching row in cursor pages.
|
|
23
|
-
- **Row locks:** `lockByIds` and `claim` (`SKIP LOCKED`), inside a
|
|
23
|
+
- **Row locks:** `lockByIds`, `lockWhere` and `claim` (`SKIP LOCKED`), inside a
|
|
24
|
+
transaction.
|
|
24
25
|
- **Lock versions:** `lockVersionField`, a counter every update advances.
|
|
25
26
|
- **Shared rows:** an autofilter filter with `shared: true` lets every owner
|
|
26
27
|
read rows whose owner field is NULL.
|
|
@@ -31,11 +32,64 @@ bulk options are rejected with a message that names their replacement.
|
|
|
31
32
|
registrations take `{ batch: true }`.
|
|
32
33
|
- **Aggregates:** measures take a `where` condition; `groupBy` takes date
|
|
33
34
|
buckets (`day`, `week`, `month`, `year`) with a time zone; date fields with a
|
|
34
|
-
storage serializer can be bucketed and take `min` and `max`.
|
|
35
|
+
storage serializer can be bucketed and take `min` and `max`. In a system
|
|
36
|
+
context, a hidden ownership field may be grouped and counted distinct. See
|
|
35
37
|
[AGGREGATE](../API.md#aggregate---grouped-totals).
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
- **One read for `get`:** a resource with no `checkPermissions`,
|
|
39
|
+
`checkDataPermissions`, `checkDataPermissionsGet`, `beforeData` or
|
|
40
|
+
`beforeDataGet` hook reads its record in one statement; such a get no
|
|
41
|
+
longer sets `context.minimalRecord` (read `context.record` in later hooks).
|
|
42
|
+
- **Skipping writes in one statement:** `patchMany`, `patchWhereIds` and
|
|
43
|
+
`deleteMany` with `missing: 'skip'` put the scope in the UPDATE or DELETE
|
|
44
|
+
instead of reading the visible ids first, when no permission or
|
|
45
|
+
before-data-call batch hook needs those rows (not on MySQL and MariaDB,
|
|
46
|
+
where InnoDB would lock every listed row before checking the scope; there a
|
|
47
|
+
patch of one visible row runs its UPDATE without a locking read first).
|
|
48
|
+
- **Fewer reads before single writes:** on table and AnyAPI storage, `patch`
|
|
49
|
+
and `delete` read their row once, scoped, before the UPDATE or DELETE, and a
|
|
50
|
+
`put` that replaces a row reads it twice (whether it exists, then scoped)
|
|
51
|
+
instead of three times. The unscoped read that checked the row still existed
|
|
52
|
+
is gone; the write's own row count answers a row that vanished in between
|
|
53
|
+
(`not_found`, as before).
|
|
54
|
+
- **Single writes keep to their owner:** on table and AnyAPI storage, the
|
|
55
|
+
UPDATE or DELETE of a single `patch`, `put` or `delete` carries the
|
|
56
|
+
ownership (autofilter) conditions its scoped read found, as many-row writes
|
|
57
|
+
already did. A row that another writer moved to a different owner after the
|
|
58
|
+
read answers `not_found` instead of being written. On AnyAPI storage a row
|
|
59
|
+
that vanished after the read now answers `not_found` too, where the write
|
|
60
|
+
used to change nothing and report success.
|
|
61
|
+
- **Links to hidden targets survive a write-back:** a `patch`, `put`,
|
|
62
|
+
many-row patch or merge upsert that sets a belongs-to to null keeps the
|
|
63
|
+
stored link when its target exists but is hidden from the writer, as the read
|
|
64
|
+
showed the link as unassigned. It used to clear it. Clear such links in a
|
|
65
|
+
system context.
|
|
66
|
+
- **Polymorphic links are set through the relationship only:** the type and id
|
|
67
|
+
fields behind a polymorphic belongs-to (`owner_type`, `owner_id`) are refused
|
|
68
|
+
in attributes, as foreign keys already were (rule
|
|
69
|
+
`foreign_key_in_attributes`), so a write cannot skip the target check or half
|
|
70
|
+
clear a link. Send `owner: { _type, id }` (plain) or `relationships.owner`
|
|
71
|
+
(JSON:API) instead.
|
|
72
|
+
- **Selection writes:** `patchWhere` and `deleteWhere` carry the scope and the
|
|
73
|
+
filters in every UPDATE or DELETE and return the rows they wrote; a row that
|
|
74
|
+
vanishes or stops matching in the meantime is left out, never an error.
|
|
75
|
+
- **Counts:** `returning: 'count'` on every many-row write returns
|
|
76
|
+
`{ meta: { count } }`, the number of rows written, without reading rows back.
|
|
77
|
+
- **Upserts on the id:** on a resource with no ownership fields and no row
|
|
78
|
+
policy, `onConflict: { keys: ['id'], action }` (or the `idProperty`, such as
|
|
79
|
+
an email natural key) creates the rows that do not exist yet, atomically.
|
|
80
|
+
- **Integer fields over BIGINT columns read as numbers:** an `integer` or
|
|
81
|
+
`number` field whose driver returns a string (PostgreSQL's BIGINT, MySQL's
|
|
82
|
+
BIGINT past 2^53) reads as a number when a JavaScript number holds it exactly,
|
|
83
|
+
on every read and write response, as aggregates already did. It used to stay
|
|
84
|
+
a string, such as `"4400"`. A longer integer, or a fraction a number cannot
|
|
85
|
+
hold exactly, keeps its digits as a string, where an aggregate used to round
|
|
86
|
+
it; decimal fields (`precision` and `scale`) keep their strings.
|
|
87
|
+
- **Numbered pages without counts:** with `enablePaginationCounts: false` an
|
|
88
|
+
offset page reads one extra row and reports `hasMore` and a `next` link,
|
|
89
|
+
still without a count query.
|
|
90
|
+
|
|
91
|
+
Scoped reads, row locks, lock versions, shared rows and system contexts are
|
|
92
|
+
described in [scoped reads, locks and system work](34-scoped-reads-and-locks.md).
|
|
39
93
|
|
|
40
94
|
## Many-row writes replace the bulk methods
|
|
41
95
|
|
|
@@ -50,10 +104,11 @@ the per-row checks in memory and one INSERT, UPDATE or DELETE per chunk.
|
|
|
50
104
|
| `bulkPatch({ operations: [{ id, document: { data } }] })` | `patchMany({ document: { data: [{ type, id, attributes }] } })` |
|
|
51
105
|
| the same change for many ids | `patchWhereIds({ ids, data })` or `patchWhere({ filters, data })` |
|
|
52
106
|
| `bulkDelete({ ids })` | `deleteMany({ ids })` or `deleteWhere({ filters })` |
|
|
107
|
+
| the same change for, or deletion of, every row | `patchWhere({ all: true, data })`, `deleteWhere({ all: true })`: without `filters`, `all: true` is required |
|
|
53
108
|
| `atomic: true` | remove: every call is all-or-nothing |
|
|
54
109
|
| `atomic: false` | remove; for rows that must succeed independently, call single writes in a loop |
|
|
55
110
|
| `expectedVersion`, `expectedVersions` | removed: resources with a `versionField` refuse many-row writes. Use single writes with `expectedVersion`, or a `lockVersionField` with `onlyIf` |
|
|
56
|
-
| result `{ meta: { total, succeeded, failed, atomic }, errors, data }` | nothing by default; `returning: 'minimal'` gives `{ data: [{ type, id }] }`, `'full'` gives `{ data: [records] }` |
|
|
111
|
+
| result `{ meta: { total, succeeded, failed, atomic }, errors, data }` | nothing by default; `returning: 'minimal'` gives `{ data: [{ type, id }] }`, `'full'` gives `{ data: [records] }`, `'count'` gives `{ meta: { count } }` |
|
|
57
112
|
| `errors[i].index` for a failed entry | the call throws; a validation error names the row in `details.index`, and its field paths start with `data.<index>.` |
|
|
58
113
|
| `meta.deleted` | `deleteMany({ ids, returning: 'minimal' }).data` |
|
|
59
114
|
| default `returning` of the resource | `'none'` unless the call says otherwise |
|
|
@@ -148,6 +203,67 @@ new methods; their parameter types are exported from the package root
|
|
|
148
203
|
`RecordsResult` and others). `BulkOperationsPluginOptions` has only
|
|
149
204
|
`maxBulkOperations`.
|
|
150
205
|
|
|
206
|
+
## Objects in single-value fields
|
|
207
|
+
|
|
208
|
+
Only `object` and `array` fields store a structure. A write, filter or cursor
|
|
209
|
+
that gives any other field an object or array, as a `none` or `blob` field
|
|
210
|
+
accepted from JSON, now fails with `RestApiValidationError` (rule
|
|
211
|
+
`single_value`) before any SQL runs. In 2.x, MySQL drivers that format queries
|
|
212
|
+
on the client (mysql2 before 3.17, mysql) expanded such an object into
|
|
213
|
+
`column = value` pairs, so a value for one column could set others; PostgreSQL
|
|
214
|
+
stored the JSON text and SQLite refused it. This also ships in 2.4.1.
|
|
215
|
+
|
|
216
|
+
| 2.x | 3.0 |
|
|
217
|
+
| --- | --- |
|
|
218
|
+
| `{ type: 'none' }` holding an object | `{ type: 'object' }`, or a `storage.serialize` function |
|
|
219
|
+
| `{ type: 'blob' }` given JSON | a `Buffer` or `Uint8Array` from server code |
|
|
220
|
+
|
|
221
|
+
## `get` reads once
|
|
222
|
+
|
|
223
|
+
A `get` on a resource with no `checkPermissions`, `checkDataPermissions`,
|
|
224
|
+
`checkDataPermissionsGet`, `beforeData` or `beforeDataGet` hook (for the
|
|
225
|
+
resource or global) reads its record in one statement, scope and row policy
|
|
226
|
+
included, instead of a minimal read followed by a full read. Not found stays
|
|
227
|
+
`not_found` and still answers before an invalid fieldset. Such a get no longer
|
|
228
|
+
sets `context.minimalRecord`.
|
|
229
|
+
|
|
230
|
+
| 2.x | 3.0 |
|
|
231
|
+
| --- | --- |
|
|
232
|
+
| `enrichRecord`, `finish` or `finishGet` reading `context.minimalRecord` | read `context.record`, or register a `checkPermissions` or `beforeDataGet` hook to keep the minimal read |
|
|
233
|
+
|
|
234
|
+
## Write failure logs
|
|
235
|
+
|
|
236
|
+
The `Error in METHOD method` entry that a failed write logs at error level now
|
|
237
|
+
carries only the resource, the operation and error codes. In 2.x it also held
|
|
238
|
+
the request's `inputRecord` (hidden fields redacted, every other value kept)
|
|
239
|
+
and the formatted error, whose message and SQL can repeat the values being
|
|
240
|
+
written, so logs collected payment state, ciphertext and other stored secrets.
|
|
241
|
+
The error the call throws is unchanged and still carries its `cause`.
|
|
242
|
+
|
|
243
|
+
| 2.x log field | 3.0 |
|
|
244
|
+
| --- | --- |
|
|
245
|
+
| `inputRecord` | removed; log what you need from the input yourself, in your own code |
|
|
246
|
+
| `error`, `errorSummary` (message, stack, details) | `code`: the error's `code`, or its `name` when it has none |
|
|
247
|
+
| `cleanupErrors: [{ phase, error }]` | `cleanupErrors: [{ phase, code }]` |
|
|
248
|
+
| `method`, `scopeName`, `phase`, `backend`, `transactionOutcome` | unchanged |
|
|
249
|
+
|
|
250
|
+
## HTTP error answers and logs
|
|
251
|
+
|
|
252
|
+
A 500 answer for an error that is not one of the library's own `RestApi…Error`
|
|
253
|
+
classes, such as a database error or a plain `Error` thrown by a hook, now has
|
|
254
|
+
the detail `An unexpected error occurred`. In 2.x it carried the error's
|
|
255
|
+
message, and a database error's message repeats the SQL with its values (Knex
|
|
256
|
+
puts the statement and its bindings in it), so a duplicate key answered with
|
|
257
|
+
the value that clashed. For a database error, the connector's `HTTP request
|
|
258
|
+
error` or `Fastify request error` log entry now records only its `name` and
|
|
259
|
+
`code`; PostgreSQL's `detail` and MySQL's `sql` repeat the values too.
|
|
260
|
+
|
|
261
|
+
| 2.x | 3.0 |
|
|
262
|
+
| --- | --- |
|
|
263
|
+
| 500 `detail` is the error's message | the message for a `RestApi…Error`; otherwise `An unexpected error occurred` |
|
|
264
|
+
| a hook throws `new Error(message)` to tell the client why | throw a `RestApiResourceError` or `RestApiValidationError`, or call `context.reject` in a transport hook |
|
|
265
|
+
| the HTTP error log entry holds the database error (message, SQL, values) | `{ name, code }` of the database error |
|
|
266
|
+
|
|
151
267
|
## System contexts and `context.system`
|
|
152
268
|
|
|
153
269
|
The autofilter and row-policy plugins now reserve `context.system`. A context
|
|
@@ -173,15 +289,27 @@ shared row on a `shared` filter. Row policies receive `system` alongside
|
|
|
173
289
|
Filter definitions take `shared: true | false` (default false). A value other
|
|
174
290
|
than a boolean, or `shared: true` on a field that is not `nullable`, fails when
|
|
175
291
|
the resource is added. `getScopeConfig` reports `shared` for each filter.
|
|
292
|
+
Outside a system context, shared rows refuse `patch`, `put`, `delete`,
|
|
293
|
+
`postRelationship` and `deleteRelationship`, and a create may not leave the
|
|
294
|
+
field NULL, also when an optional (`required: false`) filter does not resolve.
|
|
295
|
+
|
|
296
|
+
## Values and links that read or write differently
|
|
297
|
+
|
|
298
|
+
| 2.x | 3.0 |
|
|
299
|
+
| --- | --- |
|
|
300
|
+
| an `integer` field over a PostgreSQL BIGINT column reads `"4400"` | `4400`; a value past 2^53 stays a string |
|
|
301
|
+
| an aggregate group, `min` or `max` of a BIGINT past 2^53 is a rounded number | the exact digits, as a string |
|
|
302
|
+
| a `patch` or `put` of `accountId: null` by a writer who cannot see the account clears the link | keeps it; clear it in a system context |
|
|
303
|
+
| `owner_type` and `owner_id` in attributes set a polymorphic link | refused; send `owner: { _type, id }` or `relationships.owner` |
|
|
176
304
|
|
|
177
305
|
## Custom storage plugins
|
|
178
306
|
|
|
179
|
-
The storage helpers gain `dataScopedQuery`, `dataVisibleIds`,
|
|
307
|
+
The storage helpers gain `dataScopedQuery`, `dataVisibleIds`, `dataIdsByKeys`,
|
|
180
308
|
`dataInsertMany`, `dataUpdateByIds`, `dataDeleteByIds`, `dataLockByIds` and
|
|
181
309
|
`dataClaim`. They are optional: a storage plugin without them keeps working,
|
|
182
310
|
and the new methods refuse its resources with
|
|
183
311
|
`RestApiBulkWriteUnsupportedError`. `RestApiKnexPlugin` implements all of them;
|
|
184
|
-
`RestApiAnyapiKnexPlugin` implements
|
|
312
|
+
`RestApiAnyapiKnexPlugin` implements only `dataScopedQuery`.
|
|
185
313
|
|
|
186
314
|
## Verify the port
|
|
187
315
|
|
|
@@ -189,6 +317,9 @@ and the new methods refuse its resources with
|
|
|
189
317
|
`expectedVersions`, `defaultAtomic` and `operations:`, and port each call.
|
|
190
318
|
2. Search for `system: true` in contexts.
|
|
191
319
|
3. Run the application's tests. A many-row write on a resource with per-record
|
|
192
|
-
hooks fails with
|
|
193
|
-
|
|
320
|
+
hooks fails with `RestApiBulkWriteUnsupportedError`; its `details.hooks`
|
|
321
|
+
names the hooks to give a batch form.
|
|
322
|
+
4. Search for `patchWhere` and `deleteWhere` calls without `filters`, and add
|
|
323
|
+
`all: true` where every visible row is meant.
|
|
324
|
+
5. Update HTTP clients of `/RESOURCE/bulk` to the new PATCH and DELETE bodies
|
|
194
325
|
and to error responses for failed batches.
|
package/lib/rest-api-errors.js
CHANGED
|
@@ -261,11 +261,11 @@ export class RestApiTemporalDataError extends RestApiError {
|
|
|
261
261
|
* It never falls back to a per-row loop; the reason names what to change.
|
|
262
262
|
*/
|
|
263
263
|
export class RestApiBulkWriteUnsupportedError extends RestApiError {
|
|
264
|
-
constructor ({ resourceType, operation, reason } = {}) {
|
|
264
|
+
constructor ({ resourceType, operation, reason, hooks } = {}) {
|
|
265
265
|
super(`${operation} on '${resourceType}' is not supported: ${reason}`, 'REST_API_BULK_WRITE_UNSUPPORTED')
|
|
266
266
|
this.type = 'rest_api_bulk_write_unsupported'
|
|
267
267
|
this.statusCode = 422
|
|
268
|
-
this.details = { resourceType, operation, reason }
|
|
268
|
+
this.details = { resourceType, operation, reason, ...(hooks ? { hooks } : {}) }
|
|
269
269
|
}
|
|
270
270
|
|
|
271
271
|
toJSON () {
|
|
@@ -323,10 +323,11 @@ export class RestApiScopedQueryReadOnlyError extends RestApiError {
|
|
|
323
323
|
|
|
324
324
|
/** queryAll stopped at the caller's rowLimit instead of reading an unbounded set. */
|
|
325
325
|
export class RestApiRowLimitError extends RestApiError {
|
|
326
|
-
constructor ({ resourceType, rowLimit } = {}) {
|
|
327
|
-
|
|
326
|
+
constructor ({ resourceType, rowLimit, operation = 'queryAll' } = {}) {
|
|
327
|
+
const advice = operation === 'queryAll' ? 'read them in a background job or narrow the filters' : 'narrow the filters'
|
|
328
|
+
super(`${operation} on '${resourceType}' matched more than ${rowLimit} rows; ${advice}`, 'REST_API_ROW_LIMIT')
|
|
328
329
|
this.type = 'rest_api_row_limit'
|
|
329
|
-
this.details = { resourceType, rowLimit }
|
|
330
|
+
this.details = { resourceType, rowLimit, operation }
|
|
330
331
|
}
|
|
331
332
|
|
|
332
333
|
toJSON () {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "json-rest-api",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"types": "index.d.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -142,7 +142,7 @@
|
|
|
142
142
|
"devDependencies": {
|
|
143
143
|
"@socket.io/redis-adapter": "^8.3.0",
|
|
144
144
|
"@types/node": "24.0.14",
|
|
145
|
-
"better-sqlite3": "^
|
|
145
|
+
"better-sqlite3": "^13.0.3",
|
|
146
146
|
"busboy": "^1.6.0",
|
|
147
147
|
"eslint": "^9.37.0",
|
|
148
148
|
"express": "^5.1.0",
|
|
@@ -23,8 +23,32 @@ export function mergeResponseHeaders (...sources) {
|
|
|
23
23
|
export function getHttpDiagnosticError (error) {
|
|
24
24
|
// Parser messages and stacks can quote raw input before a resource is known.
|
|
25
25
|
// Keep the original error for hooks/responses, but log only this safe preview.
|
|
26
|
-
if (error?.type
|
|
27
|
-
|
|
26
|
+
if (error?.type === 'entity.parse.failed') {
|
|
27
|
+
return { name: 'SyntaxError', message: 'Invalid JSON request body', type: 'entity.parse.failed', status: 400 }
|
|
28
|
+
}
|
|
29
|
+
// A database error repeats the stored values: in the message Knex builds
|
|
30
|
+
// from the SQL and its bindings, in MySQL's sql and sqlMessage, and in
|
|
31
|
+
// PostgreSQL's detail. Log only which failure it was.
|
|
32
|
+
const failure = databaseFailure(error)
|
|
33
|
+
return failure ? { name: failure.name, code: failure.code } : error
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The database driver error behind `error`, which may wrap it as its cause
|
|
38
|
+
* (a RestApiWriteError does).
|
|
39
|
+
* @param {unknown} error
|
|
40
|
+
*/
|
|
41
|
+
function databaseFailure (error) {
|
|
42
|
+
let current = error
|
|
43
|
+
for (let depth = 0; current && typeof current === 'object' && depth < 10; depth++) {
|
|
44
|
+
if (!isRestApiError(current)) {
|
|
45
|
+
const { sqlState, sqlMessage, errno, severity, code } = /** @type {any} */ (current)
|
|
46
|
+
// mysql2 (and socket) errors, pg errors, and better-sqlite3 or sqlite3 errors.
|
|
47
|
+
if (typeof sqlState === 'string' || typeof sqlMessage === 'string' || typeof errno === 'number' ||
|
|
48
|
+
typeof severity === 'string' || /^SQLITE_/.test(String(code ?? ''))) return current
|
|
49
|
+
}
|
|
50
|
+
current = /** @type {any} */ (current).cause
|
|
51
|
+
}
|
|
28
52
|
}
|
|
29
53
|
|
|
30
54
|
const JSON_API_WRITE_CONTENT_TYPES = [
|
|
@@ -115,11 +139,15 @@ export function mapRestApiErrorToHttp (error) {
|
|
|
115
139
|
// Read hooks can reject with null or undefined.
|
|
116
140
|
error ??= {}
|
|
117
141
|
const statusHint = error.statusCode ?? error.status
|
|
142
|
+
// Only the library's own errors carry messages meant for clients; a write
|
|
143
|
+
// error wraps what failed. Other messages, a database's above all, can
|
|
144
|
+
// repeat SQL and stored values.
|
|
145
|
+
const origin = isRestApiError(error) && error instanceof RestApiWriteError ? error.cause : error
|
|
118
146
|
let status = 500
|
|
119
147
|
let errors = [{
|
|
120
148
|
status: '500',
|
|
121
149
|
title: 'Internal Server Error',
|
|
122
|
-
detail: error.message
|
|
150
|
+
detail: isRestApiError(origin) ? error.message : 'An unexpected error occurred'
|
|
123
151
|
}]
|
|
124
152
|
|
|
125
153
|
if (error?.validation) {
|
|
@@ -226,7 +226,8 @@ export const FileHandlingPlugin = {
|
|
|
226
226
|
// Uploads belong to one record each; many-row writes refuse rows that set file fields.
|
|
227
227
|
const refuseFileFields = ({ context, scopeName }) => {
|
|
228
228
|
const fileFields = getFileFields(scopeName)
|
|
229
|
-
const set = fileFields.filter(field => context.
|
|
229
|
+
const set = fileFields.filter(field => context.rawFields?.includes(field.field) ||
|
|
230
|
+
context.inputRecords.some(record => record?.data?.attributes?.[field.field] !== undefined))
|
|
230
231
|
if (!set.length) return
|
|
231
232
|
throw new RestApiBulkWriteUnsupportedError({
|
|
232
233
|
resourceType: scopeName,
|
|
@@ -20,7 +20,8 @@ const DEFAULT_CACHE_SIZE = 100
|
|
|
20
20
|
const EMPTY_RUNTIME = Object.freeze({ schema: {}, searchSchema: {}, callbacks: [] })
|
|
21
21
|
|
|
22
22
|
// Errors from two servers inserting the same new resource at once.
|
|
23
|
-
|
|
23
|
+
// ER_CHECKREAD (1020): MariaDB with innodb_snapshot_isolation refuses a row changed since the snapshot.
|
|
24
|
+
const WRITE_CONFLICT_CODES = new Set(['23505', '40001', '40P01', 'ER_DUP_ENTRY', 'ER_LOCK_DEADLOCK', 'ER_CHECKREAD', 'SQLITE_BUSY', 'SQLITE_CONSTRAINT_UNIQUE'])
|
|
24
25
|
const creationRaces = new WeakSet()
|
|
25
26
|
|
|
26
27
|
const resolveIdProperty = (schema = {}, explicitIdProperty = null) => {
|
|
@@ -113,7 +114,7 @@ const rowsSignature = (rows, columns) => JSON.stringify(rows.map(row => columns.
|
|
|
113
114
|
return value ?? null
|
|
114
115
|
})).sort((left, right) => String(left[0]).localeCompare(String(right[0]))))
|
|
115
116
|
|
|
116
|
-
const isWriteConflict = error => WRITE_CONFLICT_CODES.has(error?.code) || [1062, 1213].includes(error?.errno)
|
|
117
|
+
const isWriteConflict = error => WRITE_CONFLICT_CODES.has(error?.code) || [1020, 1062, 1213].includes(error?.errno)
|
|
117
118
|
|
|
118
119
|
/**
|
|
119
120
|
* Canonical resource metadata. One registry can serve every tenant API that
|
|
@@ -432,7 +432,10 @@ export function createCanonicalLinkStore ({ api, getDescriptor, getScopeStorageA
|
|
|
432
432
|
resolveId: row => row.relationship === options.relationshipKey && row.left_resource === options.descriptor.resource && normalizeId(row.left_id) === options.leftId ? row.right_id : row.left_id
|
|
433
433
|
})
|
|
434
434
|
|
|
435
|
-
|
|
435
|
+
// Advances the versions of parents linked to a record about to be deleted.
|
|
436
|
+
// When there are such parents, `lockRow` first locks the record's row against
|
|
437
|
+
// link writers, and answers not found when it has gone.
|
|
438
|
+
const invalidateDeletedLinkTargets = async (scopeName, context, lockRow) => {
|
|
436
439
|
const relationships = []
|
|
437
440
|
for (const [parentType, parent] of Object.entries(api.resources)) {
|
|
438
441
|
if (!parent.vars.schemaInfo?.versionField) continue
|
|
@@ -444,7 +447,7 @@ export function createCanonicalLinkStore ({ api, getDescriptor, getScopeStorageA
|
|
|
444
447
|
}
|
|
445
448
|
if (!relationships.length) return
|
|
446
449
|
assertVersionedRelationshipTransaction(api, relationships[0].inverse, context.transaction)
|
|
447
|
-
await
|
|
450
|
+
await lockRow()
|
|
448
451
|
const id = normalizeId(context.id)
|
|
449
452
|
for (const { inverse, info } of relationships) {
|
|
450
453
|
const query = buildLinkMutationQuery({ ...info, db: context.transaction, allParents: true }, (query, column) => query.where(column, id))
|