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.
Files changed (55) hide show
  1. package/README.md +2 -2
  2. package/docs/API.md +60 -27
  3. package/docs/GUIDE/07-polymorphic-relationships.md +4 -2
  4. package/docs/GUIDE/09-pagination-and-sorting.md +4 -2
  5. package/docs/GUIDE/13-hooks-and-lifecycle.md +6 -1
  6. package/docs/GUIDE/16-autofiltering.md +1 -1
  7. package/docs/GUIDE/17-row-policies.md +8 -6
  8. package/docs/GUIDE/21-schema-and-migrations.md +7 -1
  9. package/docs/GUIDE/27-many-row-writes.md +201 -28
  10. package/docs/GUIDE/30-backend-capabilities.md +16 -2
  11. package/docs/GUIDE/33-migrating-to-v2.md +2 -2
  12. package/docs/GUIDE/34-scoped-reads-and-locks.md +165 -26
  13. package/docs/GUIDE/35-migrating-to-v3.md +141 -10
  14. package/lib/rest-api-errors.js +6 -5
  15. package/package.json +2 -2
  16. package/plugins/core/connectors/lib/transport-http-helpers.js +31 -3
  17. package/plugins/core/file-handling-plugin.js +2 -1
  18. package/plugins/core/lib/anyapi/anyapi-registry.js +3 -2
  19. package/plugins/core/lib/anyapi/canonical-link-store.js +5 -2
  20. package/plugins/core/lib/anyapi/canonical-relationship-reader.js +56 -60
  21. package/plugins/core/lib/dbIntrospection.js +106 -6
  22. package/plugins/core/lib/querying/aggregate-query.js +12 -6
  23. package/plugins/core/lib/querying/include-query-helpers.js +11 -13
  24. package/plugins/core/lib/querying/include-to-one.js +54 -59
  25. package/plugins/core/lib/querying/knex-pagination-helpers.js +9 -2
  26. package/plugins/core/lib/querying/query-constraint.d.ts +11 -0
  27. package/plugins/core/lib/querying/query-constraint.js +45 -0
  28. package/plugins/core/lib/querying/read-only-query.js +50 -13
  29. package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
  30. package/plugins/core/lib/storage/canonical-data-helpers.js +72 -41
  31. package/plugins/core/lib/storage/many-row-data-helpers.js +409 -124
  32. package/plugins/core/lib/storage/ordinary-data-helpers.js +67 -81
  33. package/plugins/core/lib/storage/single-row-writes.js +71 -0
  34. package/plugins/core/lib/storage/storage-adapter.js +18 -8
  35. package/plugins/core/lib/storage/storage-mapping.js +4 -1
  36. package/plugins/core/lib/storage/storage-types.d.ts +12 -1
  37. package/plugins/core/lib/writing/many-to-many-manipulations.js +5 -2
  38. package/plugins/core/lib/writing/relationship-processor.js +33 -37
  39. package/plugins/core/rest-api-autofilter-plugin.js +30 -8
  40. package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
  41. package/plugins/core/rest-api-plugin-methods/common.js +117 -77
  42. package/plugins/core/rest-api-plugin-methods/delete-many.js +54 -28
  43. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +22 -0
  44. package/plugins/core/rest-api-plugin-methods/get.js +48 -42
  45. package/plugins/core/rest-api-plugin-methods/many-row-common.js +51 -8
  46. package/plugins/core/rest-api-plugin-methods/patch-many.js +81 -34
  47. package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
  48. package/plugins/core/rest-api-plugin-methods/post-many.js +149 -21
  49. package/plugins/core/rest-api-plugin-methods/put.js +5 -1
  50. package/plugins/core/rest-api-plugin-methods/scoped-reads.js +72 -27
  51. package/plugins/core/rest-api-plugin.js +2 -1
  52. package/plugins/core/rest-api-positioning-plugin.js +1 -1
  53. package/types/data-access-methods.d.ts +32 -11
  54. package/types/errors.d.ts +5 -4
  55. 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`; it does not promise totals or
2035
- `hasMore`. Cursor pages use one extra row for `hasMore` and never issue a count
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 whose owner resolves to NULL is `forbidden` |
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 on the
163
- resource's table with the caller's ownership filters, row policy and the given
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 and the methods that clear existing predicates throw
187
- `RestApiScopedQueryReadOnlyError`, on clones too. A table you join is not
188
- scoped by the builder; join only what the caller may see, or scope it with its
189
- own `scopedQuery`. `readOnlyQueryBuilder(builder, { resourceType })` is
190
- exported for builders your own code produces.
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. Both refuse to run
248
- outside a transaction (`RestApiLockOutsideTransactionError`); pass one from
249
- `api.transaction`. They return the records as the locking read saw them.
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. Hidden or missing ids throw
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 are read again under the lock, so they show the latest committed
256
- values even when the transaction began earlier.
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 visible ids | reads candidates, then locks windows of them by primary key with `SKIP LOCKED`, because InnoDB locks every row a locking read examines |
270
- | SQLite | a scoped read | a scoped read |
271
-
272
- SQLite allows one writer at a time, so a write transaction already excludes
273
- every other one; its locks are ordinary scoped reads.
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 transaction.
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`. See
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
- The last five are described in
38
- [scoped reads, locks and system work](34-scoped-reads-and-locks.md).
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 none.
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 a message naming the hooks to give a batch form.
193
- 4. Update HTTP clients of `/RESOURCE/bulk` to the new PATCH and DELETE bodies
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.
@@ -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
- super(`queryAll on '${resourceType}' matched more than ${rowLimit} rows; read them in a background job or narrow the filters`, 'REST_API_ROW_LIMIT')
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.0-rc.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": "^11.10.0",
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 !== 'entity.parse.failed') return error
27
- return { name: 'SyntaxError', message: 'Invalid JSON request body', type: 'entity.parse.failed', status: 400 }
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.inputRecords.some(record => record?.data?.attributes?.[field.field] !== undefined))
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
- const WRITE_CONFLICT_CODES = new Set(['23505', '40001', '40P01', 'ER_DUP_ENTRY', 'ER_LOCK_DEADLOCK', 'SQLITE_BUSY', 'SQLITE_CONSTRAINT_UNIQUE'])
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
- const invalidateDeletedLinkTargets = async (scopeName, context) => {
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 lockRelationshipTargets(api, context.transaction, [{ type: scopeName, id: context.id }], { exclusive: true })
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))