json-rest-api 3.0.0-rc.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +195 -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 +47 -79
  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 +8 -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 +47 -5
  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 +139 -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
package/README.md CHANGED
@@ -75,8 +75,8 @@ app.listen(3000, () => {
75
75
 
76
76
  Version 3 adds many-row writes (`postMany`, `patchMany`, `patchWhereIds`,
77
77
  `patchWhere`, `deleteMany`, `deleteWhere`), scoped reads (`scopedQuery`,
78
- `queryAll`), row locks (`lockByIds`, `claim`), lock versions, shared rows and
79
- system contexts, and replaces the 2.x bulk methods. The
78
+ `queryAll`), row locks (`lockByIds`, `lockWhere`, `claim`), lock versions, shared
79
+ rows and system contexts, and replaces the 2.x bulk methods. The
80
80
  [v3 migration guide](docs/GUIDE/35-migrating-to-v3.md) lists the breaking changes
81
81
  with old → new tables; the [v2 migration guide](docs/GUIDE/33-migrating-to-v2.md)
82
82
  covers upgrades from 1.x.
package/docs/API.md CHANGED
@@ -86,7 +86,7 @@ a separate context for each enlisted operation. See [transaction support](#trans
86
86
  - [scopedQuery - Scoped Read-Only Builder](#scopedquery---scoped-read-only-builder)
87
87
  - [queryAll - Every Matching Record](#queryall---every-matching-record)
88
88
  - [Many-Row Writes](#many-row-writes)
89
- - [lockByIds and claim - Row Locks](#lockbyids-and-claim---row-locks)
89
+ - [lockByIds, lockWhere and claim - Row Locks](#lockbyids-lockwhere-and-claim---row-locks)
90
90
  - [System Contexts](#system-contexts)
91
91
  4. [Relationship Methods](#relationship-methods)
92
92
  - [getRelated - Retrieve Related Resources](#getrelated---retrieve-related-resources)
@@ -1177,6 +1177,11 @@ foreign keys. Fields that are `hidden`, virtual, computed, or that have a getter
1177
1177
  the ones clients read. The exception is a `date` or `dateTime` field with
1178
1178
  `storage.serialize`: it may be bucketed and take `min` and `max`, whose results
1179
1179
  are read back as that field's values. `normallyHidden` fields are allowed.
1180
+ In a [system context](GUIDE/34-scoped-reads-and-locks.md#system-contexts), a
1181
+ hidden ownership (autofilter) field may also be grouped and take
1182
+ `countDistinct`, so a job can find which owners have rows; other callers are
1183
+ refused it, since one whose optional owner filter does not resolve would see
1184
+ every owner.
1180
1185
 
1181
1186
  #### Conditional measures
1182
1187
 
@@ -1273,13 +1278,16 @@ const { query } = await api.resources.entries.scopedQuery(
1273
1278
  { filters, alias, purpose, transaction }, context)
1274
1279
  ```
1275
1280
 
1276
- Resolves to `{ query }`, a Knex builder on the resource's table with ownership
1277
- filters, row policy and the search `filters` applied. It is wrapped because a
1278
- builder is thenable. `purpose` is `'report'` (default; includes shared rows),
1279
- `'bulk-write'`, `'lock'` or `'claim'`; row policies receive it as
1281
+ Resolves to `{ query }`, a Knex builder over a derived table of the resource's
1282
+ rows with ownership filters, row policy and the search `filters` applied, so
1283
+ conditions the caller adds (`orWhere` included) only narrow them. It is wrapped
1284
+ because a builder is thenable. `purpose` is `'report'` (default; includes
1285
+ shared rows), `'bulk-write'`, `'lock'` or `'claim'`; row policies receive it as
1280
1286
  `queryPurpose`. The caller needs `query` permission. The builder refuses
1281
- writes, locking reads, table changes and clearing predicates, on clones too
1282
- (`RestApiScopedQueryReadOnlyError`); joined tables are not scoped.
1287
+ writes, locking reads, table changes, clearing predicates, set operations,
1288
+ common table expressions and access to its Knex client, on clones too
1289
+ (`RestApiScopedQueryReadOnlyError`); joined tables are not scoped. It guards
1290
+ against mistakes, not hostile code.
1283
1291
 
1284
1292
  ### queryAll - Every Matching Record
1285
1293
 
@@ -1299,20 +1307,20 @@ it throws `RestApiRowLimitError`. Works on both storages.
1299
1307
  await resource.postMany({ data | document, onConflict, returning, format, queryParams, chunkSize, transaction }, context)
1300
1308
  await resource.patchMany({ data | document, onlyIf, missing, returning, format, queryParams, chunkSize, transaction }, context)
1301
1309
  await resource.patchWhereIds({ ids, data | document, onlyIf, missing, returning, format, queryParams, chunkSize, transaction }, context)
1302
- await resource.patchWhere({ filters, data | document, onlyIf, returning, format, queryParams, chunkSize, transaction }, context)
1310
+ await resource.patchWhere({ filters | all: true, data | document, onlyIf, returning, format, queryParams, chunkSize, transaction }, context)
1303
1311
  await resource.deleteMany({ ids, missing, returning, chunkSize, transaction }, context)
1304
- await resource.deleteWhere({ filters, returning, chunkSize, transaction }, context)
1312
+ await resource.deleteWhere({ filters | all: true, returning, chunkSize, transaction }, context)
1305
1313
  ```
1306
1314
 
1307
1315
  | Parameter | Description |
1308
1316
  | --- | --- |
1309
1317
  | `data` / `document` | `postMany`, `patchMany`: an array of plain records, or `{ data: [resource objects] }`; `patchMany` rows carry `id`. `patchWhereIds`, `patchWhere`: one record of changes. |
1310
- | `ids` / `filters` | The records to change: listed ids, or search filters validated as `query` validates them. |
1311
- | `returning` | `'none'` (default), `'minimal'` (`{ data: [{ type, id }] }`) or `'full'` (`{ data: [records] }`, not for deletes). |
1318
+ | `ids` / `filters` / `all` | The records to change: listed ids, or non-empty search filters validated as `query` validates them, or `all: true` for every row the caller can see. |
1319
+ | `returning` | `'none'` (default), `'minimal'` (`{ data: [{ type, id }] }`), `'full'` (`{ data: [records] }`, not for deletes) or `'count'` (`{ meta: { count } }`, the number of rows written, with no row data). |
1312
1320
  | `format`, `queryParams` | Output format, `fields` and `include` of full records. |
1313
- | `onConflict` | Upsert: `{ keys, action: 'ignore' \| 'merge', merge? }`. `keys` must include every ownership field; `merge` is refused under a row policy. |
1321
+ | `onConflict` | Upsert: `{ keys, action: 'ignore' \| 'merge', merge? }`. `keys` must include every ownership field, and a unique index must cover them; rows may not carry an `id` or repeat keys. On a resource with no ownership fields and no row policy, `keys: ['id']` (or the `idProperty`) keys the upsert on the id, and every row carries one; keys compare exactly (a match only by collation fails with 409); only a match on the keys counts, and a clash on another unique index fails the batch. `merge` updates only the fields each row supplies (never `defaultTo` values), runs the patch checks (`checkPermissions` with `method: 'patch'`) on the records it updates and is refused under a row policy. |
1314
1322
  | `onlyIf` | Compare-and-set: only rows whose stored values equal these change and are returned. |
1315
- | `missing` | `'throw'` (default; `RestApiRecordsNotFoundError`, 404) or `'skip'` for ids the caller cannot see. |
1323
+ | `missing` | Id lists only: `'throw'` (default; `RestApiRecordsNotFoundError`, 404) or `'skip'` for ids the caller cannot see. `patchWhere` and `deleteWhere` refuse it: they write the rows their selection finds, with the scope and filters inside each write, and return only the rows written. |
1316
1324
  | `chunkSize` | Rows per statement (default 500), lowered to the database's parameter limit. |
1317
1325
  | `transaction` | A transaction from `api.transaction` to join; otherwise the call owns one. |
1318
1326
 
@@ -1320,9 +1328,12 @@ Each call is all-or-nothing. Every row is validated, stamped, passed through
1320
1328
  setters and authorized with `checkPermissions` (`method` `post`, `patch` or
1321
1329
  `delete`, `originalContext.bulkIndex` the row's position); belongs-to targets
1322
1330
  are checked with one read per related type; then one statement runs per chunk.
1323
- Patches refuse the id, ownership fields and the `lockVersionField`, and accept
1324
- `knex.raw()` values for stored, non-relationship fields. Deletes remove the
1325
- records' pivot rows. Batch hooks run once per call:
1331
+ Patches refuse the id, ownership fields (also through a polymorphic
1332
+ relationship) and the `lockVersionField`, and accept real `knex.raw()` values
1333
+ for stored, non-relationship fields. The UPDATE, DELETE and lock statements
1334
+ repeat the caller's ownership conditions, so a row that moved to another owner
1335
+ after the visibility read is left alone and counts as missing. Deletes remove
1336
+ the records' pivot rows. Batch hooks run once per call:
1326
1337
  `beforeProcessingPostMany`, `beforeDataCallPostMany`, `afterDataCallPostMany`,
1327
1338
  the `PatchMany` trio and `beforeDataCallDeleteMany` / `afterDataCallDeleteMany`.
1328
1339
 
@@ -1333,23 +1344,37 @@ many-to-many or reverse relationships, file fields, or positions.
1333
1344
  `BulkOperationsPlugin` exposes `postMany`, `patchMany` and `deleteMany` as
1334
1345
  `POST`, `PATCH` and `DELETE /RESOURCE/bulk`.
1335
1346
 
1336
- ### lockByIds and claim - Row Locks
1347
+ ### lockByIds, lockWhere and claim - Row Locks
1337
1348
 
1338
1349
  ```javascript
1339
1350
  await api.transaction(async transaction => {
1340
1351
  const { data: locked } = await resource.lockByIds({ ids, transaction, missing, format, queryParams: { fields } }, context)
1352
+ const { data: matching } = await resource.lockWhere({ filters, transaction, format, queryParams: { fields } }, context)
1341
1353
  const { data: taken } = await resource.claim({ filters, sort, limit, transaction, format, queryParams: { fields } }, context)
1342
1354
  })
1343
1355
  ```
1344
1356
 
1345
- Both need a transaction (`RestApiLockOutsideTransactionError` otherwise) and
1346
- return the records as the locking read saw them. `lockByIds` locks the visible
1347
- rows among `ids` in id order (`FOR UPDATE`); hidden or missing ids throw
1348
- `RestApiRecordsNotFoundError` unless `missing: 'skip'`. `claim` takes up to
1349
- `limit` (default 1, at most 1,000) matching rows that no other transaction holds
1350
- (`SKIP LOCKED`), in `sort` order (stored fields, default `['id']`); a row policy
1351
- that joins tables is refused. On SQLite, which has one writer, both are scoped
1352
- reads.
1357
+ All three need a transaction (`RestApiLockOutsideTransactionError` otherwise)
1358
+ and return the records as the locking read saw them, through the read hooks of
1359
+ `query` (`enrichRecord`, `finish`, `finishQuery`) and `checkDataPermissions`
1360
+ for each row. The locking read is the first statement and applies the scope
1361
+ itself, so plain reads later in the transaction see what a previous lock holder
1362
+ committed. `lockByIds` locks the visible rows among `ids` in id order
1363
+ (`FOR UPDATE`), sorting lists longer than its 1,000-id statements first; hidden
1364
+ or missing ids throw `RestApiRecordsNotFoundError` unless `missing: 'skip'`. On
1365
+ MySQL and MariaDB it locks every listed id, hidden ones too, until commit, so
1366
+ pass ids from a scoped read, never a raw request list. `lockWhere` locks the
1367
+ visible rows the filters select in id order, waiting for other transactions and
1368
+ including rows that start to match meanwhile; more than 1,000 throw
1369
+ `RestApiRowLimitError`. On MySQL and MariaDB it has no `ORDER BY` and forces an
1370
+ index that starts with the whole ownership columns (or, without ownership
1371
+ conditions, a filtered column), and throws `RestApiBulkWriteUnsupportedError`
1372
+ when the table has none.
1373
+ `claim` takes up to `limit` (default 1, at most 1,000) matching rows that no
1374
+ other transaction holds (`SKIP LOCKED`), in `sort` order (stored fields, default
1375
+ `['id']`). A row policy that joins tables is refused for all three. On SQLite,
1376
+ which has one writer, they take the database's write lock with a write that
1377
+ changes nothing, then read.
1353
1378
 
1354
1379
  A resource option `lockVersionField: 'lock_version'` names a stored integer
1355
1380
  that every update (single, many-row and upsert merge) sets to `column + 1`.
@@ -1992,8 +2017,9 @@ const result = await api.resources.articles.query({
1992
2017
  ```
1993
2018
 
1994
2019
  Pagination metadata is under `meta.pagination`. With counts disabled, it includes
1995
- `page` and `pageSize`, without
1996
- promising totals or `hasMore`. The library supplies pagination links appropriate
2020
+ `page`, `pageSize` and `hasMore`, without totals: the page reads one extra row
2021
+ instead of running a count query, so it is one statement and links to `first`,
2022
+ `prev` and `next` but not `last`. The library supplies pagination links appropriate
1997
2023
  to the available count/boundary information.
1998
2024
 
1999
2025
  #### Cursor Pagination
@@ -2377,6 +2403,13 @@ For example, a resource not-found error maps to:
2377
2403
  | `REST_API_SYSTEM_CONTEXT_FORGED` | 500 |
2378
2404
  | Unexpected or temporal output failure | 500 |
2379
2405
 
2406
+ A 500 answer carries the error's message only for the library's own
2407
+ `RestApi…Error` classes. Any other error, a database error or a plain `Error`
2408
+ thrown by a hook, answers with the detail `An unexpected error occurred`: a
2409
+ database error's message repeats the SQL and its values. The connector's
2410
+ `HTTP request error` or `Fastify request error` log entry records only the
2411
+ `name` and `code` of a database error.
2412
+
2380
2413
  Validation violations produce individual HTTP errors with their message and
2381
2414
  field location; generic validation errors retain their available details.
2382
2415
  Relationship validation/access failures follow their actual typed classification,
@@ -98,8 +98,10 @@ const authorReviewDocument = await api.resources.reviews.post({
98
98
 
99
99
  Plain polymorphic input uses `{ _type, id }` under the relationship alias.
100
100
  JSON:API uses `{ type, id }` under `data.relationships.reviewable.data`.
101
- Do not write `reviewable_type` or `reviewable_id` as public attributes. Those
102
- logical fields describe storage; the public relationship represents them together.
101
+ Writes refuse `reviewable_type` and `reviewable_id` in attributes (rule
102
+ `foreign_key_in_attributes`), as they refuse a belongs-to foreign key: those
103
+ logical fields describe storage, and the public relationship sets them together,
104
+ after checking that the caller can see the target.
103
105
  Plain output uses `_type` to identify the related resource type as well.
104
106
 
105
107
  ## Include either direction
@@ -65,8 +65,10 @@ enabled, metadata is:
65
65
  ```
66
66
 
67
67
  Page 10 returns an empty array. `enablePaginationCounts: false` suppresses the
68
- count query and count-derived metadata; do not assume every collection response
69
- contains totals. Every mode respects `queryMaxLimit`.
68
+ count query and count-derived metadata (`total`, `pageCount` and the `last`
69
+ link); do not assume every collection response contains totals. The page then
70
+ reads one extra row, so `hasMore` and the `next` link remain, and a numbered
71
+ page costs one statement. Every mode respects `queryMaxLimit`.
70
72
 
71
73
  Server code that reads every row, for example an export or a nightly job, can
72
74
  raise the cap for one call with `pageSizeLimit`, a parameter beside
@@ -393,7 +393,12 @@ a handler to these resource write stages.
393
393
  ## Read order and output enrichment
394
394
 
395
395
  GET authorizes through `checkPermissions`, then runs `beforeData` and
396
- `beforeDataGet`. After fetching, it runs `checkDataPermissions`,
396
+ `beforeDataGet`. These see `context.minimalRecord`, the stored row read before
397
+ the full read. A resource with none of these three hooks and no
398
+ `checkDataPermissions` or `checkDataPermissionsGet` hook (registered for it or
399
+ globally) reads its row once, with its scope and row policy in that read, and
400
+ leaves `minimalRecord` unset; a missing or hidden row is still `not_found`, and
401
+ answers before an invalid fieldset does. After fetching, it runs `checkDataPermissions`,
397
402
  `checkDataPermissionsGet` and `enrichRecord`. Field getters and computed output
398
403
  run during attribute enrichment, followed by `enrichAttributes`.
399
404
  `enrichRecordWithRelationships`, `finish` and `finishGet` follow. Final
@@ -300,7 +300,7 @@ await api.addResource('categories', {
300
300
 
301
301
  - Reads (`query`, `get`, includes, `aggregate`, `scopedQuery`) see the owner's
302
302
  rows and the shared rows.
303
- - Many-row writes, `lockByIds`, `claim` and `scopedQuery` with purpose
303
+ - Many-row writes, `lockByIds`, `lockWhere`, `claim` and `scopedQuery` with purpose
304
304
  `'bulk-write'`, `'lock'` or `'claim'` see only the owner's rows.
305
305
  - Single `patch`, `put` and `delete` of a shared row fail as `forbidden`, and so
306
306
  does a create whose owner value resolves to NULL.
@@ -296,7 +296,7 @@ Policies are reused for the resource selections that define externally visible d
296
296
  | `relationship-parent` | The parent of a relationship route such as `getRelated`. |
297
297
  | `report` | A `scopedQuery()` builder with the default purpose. |
298
298
  | `bulk-write` | The visible ids a many-row patch or delete may change, and `scopedQuery({ purpose: 'bulk-write' })`. |
299
- | `lock` | The visible ids `lockByIds()` locks. |
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
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. |
301
301
  | `unspecified` | A custom call to the lower-level query-filtering seam did not provide a purpose. |
302
302
 
@@ -316,11 +316,13 @@ and include limits in their operation/resource settings. Visibility hooks add
316
316
  row predicates and retain the query's selected ID and mandatory constraints.
317
317
  Their invocation count is not a per-request or per-record contract.
318
318
 
319
- Regular and polymorphic belongs-to includes also load at most 100 target IDs
320
- per query, with target permission and filtering hooks applied to every batch
321
- under `queryPurpose: 'include'`. SQL projection callbacks run for each query.
322
- Nested collection includes run after all target batches have been collected,
323
- so a global collection limit is not restarted for each batch.
319
+ Regular and polymorphic belongs-to includes load each target type's records
320
+ in one query, however many there are, with target permission and filtering
321
+ hooks applied under `queryPurpose: 'include'`. A long id list is bound as one
322
+ parameter on PostgreSQL and SQLite, so it stays within their parameter limits;
323
+ MySQL and MariaDB have none. SQL projection callbacks run for each query.
324
+ Nested collection includes run after all targets have been collected, so a
325
+ global collection limit applies to the complete set.
324
326
 
325
327
  Relationship validation uses the same batch bound. Its query context has no
326
328
  individual `id` and no parent client filters. Retain the selected ID and scope
@@ -596,7 +596,13 @@ Current live introspection support:
596
596
 
597
597
  - SQLite
598
598
  - PostgreSQL via `pg` (verified on PostgreSQL 16)
599
- - MySQL via `mysql2` (verified on MySQL 8; this does not establish MariaDB coverage)
599
+ - MySQL via `mysql2` (verified on MySQL 8)
600
+ - MariaDB via `mysql2` (verified on MariaDB 11.4)
601
+
602
+ MariaDB reports column defaults as SQL (a quoted literal, the word `NULL` for no
603
+ default) and stores a `json` column as `longtext` with a `json_valid` column
604
+ check; the snapshot reads both as MySQL's forms, so the JSON check is part of
605
+ the column type and not a check constraint of its own.
600
606
 
601
607
  PostgreSQL inspection reads the current schema's
602
608
  [columns](https://www.postgresql.org/docs/16/infoschema-columns.html),
@@ -13,9 +13,9 @@ Every table-storage resource has six methods that write many rows at once:
13
13
  | `postMany({ data })` | one new record per entry |
14
14
  | `patchMany({ data: [{ id, ... }] })` | each entry's own changes to the record with that id |
15
15
  | `patchWhereIds({ ids, data })` | the same changes to every listed record |
16
- | `patchWhere({ filters, data })` | the same changes to every record the filters select |
16
+ | `patchWhere({ filters, data })` | the same changes to every record the filters select (or `all: true`) |
17
17
  | `deleteMany({ ids })` | the listed records |
18
- | `deleteWhere({ filters })` | every record the filters select |
18
+ | `deleteWhere({ filters })` | every record the filters select (or `all: true`) |
19
19
 
20
20
  Each call is all-or-nothing. Every row still goes through validation,
21
21
  ownership stamping (autofilter), setters, relationship checks and
@@ -61,9 +61,11 @@ console.log(created.data, restocked.data)
61
61
  ```
62
62
 
63
63
  Rows come back in input order. Many-row writes return nothing unless you ask:
64
- `returning: 'minimal'` gives identifiers and `returning: 'full'` the records,
65
- read back in the write's transaction. The second call is an upsert: Alpha's
66
- ISBN already exists, so its stock becomes 9, and Gamma is inserted.
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.
67
69
 
68
70
  ```javascript
69
71
  const [alpha, beta] = created.data
@@ -109,10 +111,10 @@ await api.resources.books.postMany({
109
111
  ```
110
112
 
111
113
  Rows may set belongs-to relationships. The library checks every target the
112
- batch names with one scoped read per related type and locks the targets once
113
- for the batch. Rows that set many-to-many or reverse (has-many) relationships
114
- are refused; change those links with single writes or the relationship
115
- methods.
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.
116
118
 
117
119
  `patchMany`, `patchWhereIds` and `patchWhere` refuse:
118
120
 
@@ -120,6 +122,10 @@ methods.
120
122
  - autofilter (ownership) fields and the relationships that set them;
121
123
  - the resource's `lockVersionField`, which every update advances by itself.
122
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
+
123
129
  They accept a `knex.raw(...)` value for a stored, non-relationship field, so a
124
130
  counter can move inside the statement:
125
131
 
@@ -130,6 +136,12 @@ await api.resources.books.patchWhereIds({
130
136
  })
131
137
  ```
132
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
+
133
145
  Every row also goes through the resource's setters, and creates through
134
146
  `defaultTo` values and autofilter stamping, as single writes do. Rows may omit
135
147
  different fields. On SQLite, a field that some rows set and others omit is
@@ -140,13 +152,14 @@ does not declare with `defaultTo` does not apply to them.
140
152
 
141
153
  | Option | Methods | Default | Meaning |
142
154
  | --- | --- | --- | --- |
143
- | `returning` | all | `'none'` | `'minimal'` returns `{ data: [{ type, id }] }`; `'full'` (not for deletes) returns the records |
155
+ | `returning` | all | `'none'` | `'minimal'` returns `{ data: [{ type, id }] }`; `'full'` (not for deletes) returns the records; `'count'` returns `{ meta: { count } }` |
144
156
  | `format` | writes | resource default | Output format of `'full'` records |
145
157
  | `queryParams` | writes | | `fields` and `include` of `'full'` records |
146
158
  | `transaction` | all | | A transaction from `api.transaction` to join |
147
159
  | `chunkSize` | all | 500 | Rows per statement, lowered further to the database's parameter limit |
148
160
  | `missing` | `patchMany`, `patchWhereIds`, `deleteMany` | `'throw'` | `'skip'` ignores ids the caller cannot see |
149
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` |
150
163
  | `onConflict` | `postMany` | | Upsert, below |
151
164
 
152
165
  Ids that do not exist or that the caller cannot see (another owner's rows,
@@ -155,6 +168,95 @@ rows a row policy hides) throw `RestApiRecordsNotFoundError` (subtype
155
168
  count, so it does not reveal which ids exist. With `missing: 'skip'` they are
156
169
  left out. A batch may not name the same id twice.
157
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
+
158
260
  `onlyIf` makes a patch conditional. Only the rows whose stored values still
159
261
  equal the given ones change, and with `returning: 'minimal'` or `'full'` the
160
262
  result lists only those rows. With a
@@ -175,21 +277,81 @@ if (changed.data.length === 0) {
175
277
  ## Upserts
176
278
 
177
279
  `onConflict: { keys, action, merge }` turns `postMany` into an upsert.
178
- `keys` are the stored fields of a unique index; each row must give them values.
179
- With `action: 'ignore'`, rows that conflict are skipped and only the inserted
180
- rows are returned. With `action: 'merge'`, the conflicting record is updated:
181
- the fields in `merge`, or by default every stored field the rows supply except
182
- the keys, the id, ownership fields and the lock version.
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.
183
301
 
184
302
  Upserts cannot reach another owner's rows:
185
303
 
186
304
  - `keys` must include every autofilter (ownership) field of the resource. The
187
- unique index should include them too.
188
- - `merge` is refused on a resource with a row policy, because the database
189
- cannot apply the policy to the row it merges into. Insert with `ignore`, then
190
- `patchMany` the rows the caller may change.
191
- - On MySQL and MariaDB, a row that conflicts on a different unique index fails
192
- the batch instead of updating that row.
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.
193
355
 
194
356
  ## Transactions
195
357
 
@@ -219,12 +381,15 @@ hooks:
219
381
  | Method | Batch hooks (in order) | Context |
220
382
  | --- | --- | --- |
221
383
  | `postMany` | `beforeProcessingPostMany`, `beforeDataCallPostMany`, `afterDataCallPostMany` | `inputRecords`; `ids` after the insert |
222
- | `patchMany`, `patchWhereIds`, `patchWhere` | `beforeProcessingPatchMany`, `beforeDataCallPatchMany`, `afterDataCallPatchMany` | `inputRecords`, `ids` |
384
+ | `patchMany`, `patchWhereIds`, `patchWhere` | `beforeProcessingPatchMany`, `beforeDataCallPatchMany`, `afterDataCallPatchMany` | `inputRecords`, `rawFields`, `ids` |
223
385
  | `deleteMany`, `deleteWhere` | `beforeDataCallDeleteMany`, `afterDataCallDeleteMany` | `ids` |
224
386
 
225
- `afterCommit` and `afterRollback` run once, with `context.method` set to the
226
- many-row method. `checkPermissions` still runs for every row, with `method`
227
- `'post'`, `'patch'` or `'delete'` and the row's position as
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
228
393
  `originalContext.bulkIndex`.
229
394
 
230
395
  A resource whose per-record write hooks would be skipped is refused rather than
@@ -246,10 +411,12 @@ HTTP 422) before writing when:
246
411
  - the resource has a `versionField`, or its writes advance another resource's
247
412
  version (use single writes with `expectedVersion`, or a `lockVersionField`
248
413
  with `onlyIf`);
249
- - a per-record write hook without `{ batch: true }` runs for the resource;
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);
250
416
  - rows set many-to-many or reverse relationships;
251
- - rows set file fields (`FileHandlingPlugin`);
252
- - rows create positioned records or move them (`PositioningPlugin`).
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.
253
420
 
254
421
  ## HTTP routes
255
422
 
@@ -37,6 +37,19 @@ See [contributing](../contributing.md#real-databases-and-redis) for native
37
37
  database verification commands. A passing SQLite suite is not native PostgreSQL/MySQL
38
38
  evidence, even when launched with a different storage-mode environment variable.
39
39
 
40
+ ## Column values
41
+
42
+ Only `object` and `array` fields store a structure, as JSON. Every other stored
43
+ field takes a single value: a string, number, boolean, bigint, `Date`, `Buffer`
44
+ or `Uint8Array`, or null. A write, filter or cursor that gives such a field an
45
+ object or array, which a `none` or `blob` field accepts from JSON, fails with
46
+ `RestApiValidationError` (rule `single_value`) naming the field, before any SQL
47
+ runs. MySQL drivers that format queries on the client (mysql2 before 3.17, and
48
+ mysql) would otherwise expand a plain object into `column = value` pairs, so a
49
+ value for one column could set others. Declare structured fields as `object` or
50
+ `array`, or convert the value with a synchronous `storage.serialize` function,
51
+ whose result is stored as it is.
52
+
40
53
  ## Temporal values
41
54
 
42
55
  Public date/dateTime inputs are strings; time inputs remain strings too. Built-in
@@ -88,8 +101,9 @@ storage slots.
88
101
  | --- | --- | --- | --- |
89
102
  | Ids of inserted rows | `RETURNING` | `RETURNING` on MariaDB 10.5+; on MySQL the first `insertId` and `auto_increment_increment`, read back and verified (`RestApiBulkInsertIdsError` on a mismatch) | `RETURNING` |
90
103
  | Upsert | `ON CONFLICT` | `ON DUPLICATE KEY UPDATE`; a conflict on another unique index fails the batch | `ON CONFLICT` |
91
- | `lockByIds` | `FOR UPDATE` | `FOR UPDATE` | scoped read (one writer) |
92
- | `claim` | `FOR UPDATE SKIP LOCKED` | candidates first, then `SKIP LOCKED` windows by primary key (MySQL 8.0.1+, MariaDB 10.6+) | scoped read (one writer) |
104
+ | `lockByIds` | `FOR UPDATE` | `FOR UPDATE` | write lock, then a scoped read |
105
+ | `lockWhere` | `FOR UPDATE`, read again until no new row appears | `FOR UPDATE` | write lock, then a scoped read |
106
+ | `claim` | `FOR UPDATE SKIP LOCKED` | candidates first, then `SKIP LOCKED` windows by primary key (MySQL 8.0.1+, MariaDB 10.6+) | write lock, then a scoped read |
93
107
  | Date buckets | named zones and offsets; dateTime columns must be `timestamptz`, as `createKnexTable` creates them | offsets; named zones need loaded time zone tables | offsets only; dates stored as epoch milliseconds or ISO text |
94
108
 
95
109
  Rows per statement are capped by `chunkSize` (default 500) and by the
@@ -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