json-rest-api 2.4.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +7 -2
  2. package/docs/API.md +262 -54
  3. package/docs/GUIDE/01-getting-started.md +1 -1
  4. package/docs/GUIDE/07-polymorphic-relationships.md +4 -2
  5. package/docs/GUIDE/09-pagination-and-sorting.md +4 -2
  6. package/docs/GUIDE/13-hooks-and-lifecycle.md +34 -6
  7. package/docs/GUIDE/14-custom-resource-methods.md +3 -2
  8. package/docs/GUIDE/16-autofiltering.md +54 -2
  9. package/docs/GUIDE/17-row-policies.md +15 -5
  10. package/docs/GUIDE/19-managed-transactions.md +8 -9
  11. package/docs/GUIDE/20-transaction-outcomes.md +3 -4
  12. package/docs/GUIDE/21-schema-and-migrations.md +7 -1
  13. package/docs/GUIDE/22-version-field-migrations.md +3 -3
  14. package/docs/GUIDE/27-many-row-writes.md +458 -0
  15. package/docs/GUIDE/28-socketio.md +10 -2
  16. package/docs/GUIDE/30-backend-capabilities.md +42 -2
  17. package/docs/GUIDE/31-positioning.md +3 -1
  18. package/docs/GUIDE/33-migrating-to-v2.md +2 -2
  19. package/docs/GUIDE/34-scoped-reads-and-locks.md +464 -0
  20. package/docs/GUIDE/35-migrating-to-v3.md +325 -0
  21. package/docs/GUIDE/index.md +5 -2
  22. package/docs/QUICKSTART.md +2 -2
  23. package/docs/contributing.md +1 -1
  24. package/index.d.ts +8 -1
  25. package/index.js +4 -0
  26. package/lib/rest-api-errors.js +79 -0
  27. package/lib/runtime/json-rest-api.js +18 -3
  28. package/lib/system-context.js +42 -0
  29. package/package.json +3 -2
  30. package/plugins/core/bulk-operations-plugin.js +54 -436
  31. package/plugins/core/connectors/lib/transport-http-helpers.js +34 -3
  32. package/plugins/core/file-handling-plugin.js +17 -2
  33. package/plugins/core/lib/anyapi/anyapi-registry.js +3 -2
  34. package/plugins/core/lib/anyapi/canonical-link-store.js +5 -2
  35. package/plugins/core/lib/anyapi/canonical-relationship-reader.js +56 -60
  36. package/plugins/core/lib/dbIntrospection.js +106 -6
  37. package/plugins/core/lib/querying/aggregate-query.js +215 -22
  38. package/plugins/core/lib/querying/include-query-helpers.js +11 -13
  39. package/plugins/core/lib/querying/include-to-one.js +54 -59
  40. package/plugins/core/lib/querying/knex-pagination-helpers.js +9 -2
  41. package/plugins/core/lib/querying/query-constraint.d.ts +11 -0
  42. package/plugins/core/lib/querying/query-constraint.js +45 -0
  43. package/plugins/core/lib/querying/read-only-query.js +92 -0
  44. package/plugins/core/lib/querying-writing/compile-schemas.js +14 -0
  45. package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
  46. package/plugins/core/lib/storage/canonical-data-helpers.js +103 -39
  47. package/plugins/core/lib/storage/many-row-data-helpers.js +738 -0
  48. package/plugins/core/lib/storage/ordinary-data-helpers.js +63 -79
  49. package/plugins/core/lib/storage/single-row-writes.js +71 -0
  50. package/plugins/core/lib/storage/storage-adapter.js +18 -8
  51. package/plugins/core/lib/storage/storage-mapping.js +4 -1
  52. package/plugins/core/lib/storage/storage-types.d.ts +21 -3
  53. package/plugins/core/lib/writing/many-to-many-manipulations.js +10 -5
  54. package/plugins/core/lib/writing/relationship-processor.js +33 -37
  55. package/plugins/core/rest-api-anyapi-knex-plugin.js +3 -0
  56. package/plugins/core/rest-api-autofilter-plugin.js +137 -10
  57. package/plugins/core/rest-api-knex-plugin.js +2 -0
  58. package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
  59. package/plugins/core/rest-api-plugin-methods/common.js +144 -84
  60. package/plugins/core/rest-api-plugin-methods/delete-many.js +132 -0
  61. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +22 -0
  62. package/plugins/core/rest-api-plugin-methods/get.js +48 -42
  63. package/plugins/core/rest-api-plugin-methods/many-row-common.js +274 -0
  64. package/plugins/core/rest-api-plugin-methods/patch-many.js +333 -0
  65. package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
  66. package/plugins/core/rest-api-plugin-methods/post-many.js +295 -0
  67. package/plugins/core/rest-api-plugin-methods/put.js +5 -1
  68. package/plugins/core/rest-api-plugin-methods/scoped-reads.js +221 -0
  69. package/plugins/core/rest-api-plugin.js +17 -0
  70. package/plugins/core/rest-api-positioning-plugin.js +22 -4
  71. package/plugins/core/rest-api-query-projections-plugin.js +9 -1
  72. package/plugins/core/rest-api-row-policy-plugin.js +4 -0
  73. package/plugins/core/socketio-plugin.js +80 -2
  74. package/types/autofilter.d.ts +5 -1
  75. package/types/data-access-methods.d.ts +141 -0
  76. package/types/errors.d.ts +45 -0
  77. package/types/hook-context.d.ts +21 -0
  78. package/types/plugin-options.d.ts +3 -1
  79. package/types/resource-methods.d.ts +27 -4
  80. package/types/row-policy.d.ts +3 -0
  81. package/types/runtime.d.ts +7 -1
  82. package/docs/GUIDE/27-bulk-operations.md +0 -277
  83. package/types/bulk-methods.d.ts +0 -63
package/README.md CHANGED
@@ -73,8 +73,13 @@ app.listen(3000, () => {
73
73
 
74
74
  ## Documentation
75
75
 
76
- The [API migration guide](docs/GUIDE/33-migrating-to-v2.md) describes the breaking
77
- v2 API changes and the steps for upgrading applications.
76
+ Version 3 adds many-row writes (`postMany`, `patchMany`, `patchWhereIds`,
77
+ `patchWhere`, `deleteMany`, `deleteWhere`), scoped reads (`scopedQuery`,
78
+ `queryAll`), row locks (`lockByIds`, `lockWhere`, `claim`), lock versions, shared
79
+ rows and system contexts, and replaces the 2.x bulk methods. The
80
+ [v3 migration guide](docs/GUIDE/35-migrating-to-v3.md) lists the breaking changes
81
+ with old → new tables; the [v2 migration guide](docs/GUIDE/33-migrating-to-v2.md)
82
+ covers upgrades from 1.x.
78
83
 
79
84
 
80
85
  - [Quick Start](docs/QUICKSTART.md) - Quick start
package/docs/API.md CHANGED
@@ -8,7 +8,8 @@ This reference provides comprehensive documentation for all methods, parameters,
8
8
 
9
9
  ## Current contract
10
10
 
11
- This reference describes the v2 API. See the [migration guide](GUIDE/33-migrating-to-v2.md) and
11
+ This reference describes the v3 API. See the migration guides
12
+ ([to v2](GUIDE/33-migrating-to-v2.md), [to v3](GUIDE/35-migrating-to-v3.md)) and
12
13
  [backend limits](GUIDE/30-backend-capabilities.md) before upgrading.
13
14
 
14
15
  Pass method controls in the first `params` object and application information in
@@ -60,8 +61,10 @@ compound writes and JSON:API extensions are not enabled by accepting a document.
60
61
  On a resource configured with `versionField`, PUT/PATCH/DELETE and relationship
61
62
  writes accept an `expectedVersion` string; unconditional writes may omit it.
62
63
  POST does not accept that condition. See [optimistic concurrency](GUIDE/33-migrating-to-v2.md)
63
- for version-field configuration, bulk version arrays and the separate opt-in
64
- HTTP validator contract.
64
+ for version-field configuration and the separate opt-in HTTP validator
65
+ contract. Many-row writes refuse versioned resources; a
66
+ [`lockVersionField`](GUIDE/34-scoped-reads-and-locks.md#lock-versions) with
67
+ `onlyIf` gives them compare-and-set instead.
65
68
 
66
69
  Read calls can borrow raw Knex transactions. Library writes must use the handle
67
70
  from `api.transaction` when grouping operations; they reject unmanaged raw
@@ -79,25 +82,31 @@ a separate context for each enlisted operation. See [transaction support](#trans
79
82
  - [PATCH - Update Resource](#patch---update-resource)
80
83
  - [DELETE - Remove Resource](#delete---remove-resource)
81
84
  - [AGGREGATE - Grouped Totals](#aggregate---grouped-totals)
82
- 3. [Relationship Methods](#relationship-methods)
85
+ 3. [Data-Access Methods](#data-access-methods)
86
+ - [scopedQuery - Scoped Read-Only Builder](#scopedquery---scoped-read-only-builder)
87
+ - [queryAll - Every Matching Record](#queryall---every-matching-record)
88
+ - [Many-Row Writes](#many-row-writes)
89
+ - [lockByIds, lockWhere and claim - Row Locks](#lockbyids-lockwhere-and-claim---row-locks)
90
+ - [System Contexts](#system-contexts)
91
+ 4. [Relationship Methods](#relationship-methods)
83
92
  - [getRelated - Retrieve Related Resources](#getrelated---retrieve-related-resources)
84
93
  - [getRelationship - Retrieve Relationship Identifiers](#getrelationship---retrieve-relationship-identifiers)
85
94
  - [postRelationship - Add to Relationship](#postrelationship---add-to-relationship)
86
95
  - [patchRelationship - Replace Relationship](#patchrelationship---replace-relationship)
87
96
  - [deleteRelationship - Remove from Relationship](#deleterelationship---remove-from-relationships)
88
- 4. [Hook System](#hook-system)
97
+ 5. [Hook System](#hook-system)
89
98
  - [Complete Hook Execution Order](#complete-hook-execution-order)
90
99
  - [Hook Context Objects](#hook-context-objects)
91
- 5. [Query Features](#query-features)
100
+ 6. [Query Features](#query-features)
92
101
  - [Filtering](#filtering)
93
102
  - [Sorting](#sorting)
94
103
  - [Pagination](#pagination)
95
104
  - [Sparse Fieldsets](#sparse-fieldsets)
96
105
  - [Including Related Resources](#including-related-resources)
97
- 6. [Configuration Options](#configuration-options)
98
- 7. [Schema Configuration](#schema-configuration)
99
- 8. [Error Handling](#error-handling)
100
- 9. [Advanced Features](#advanced-features)
106
+ 7. [Configuration Options](#configuration-options)
107
+ 8. [Schema Configuration](#schema-configuration)
108
+ 9. [Error Handling](#error-handling)
109
+ 10. [Advanced Features](#advanced-features)
101
110
 
102
111
  ---
103
112
 
@@ -1154,8 +1163,8 @@ await api.resources.sales.aggregate(params, context)
1154
1163
 
1155
1164
  | Parameter | Type | Description |
1156
1165
  |-----------|------|-------------|
1157
- | `groupBy` | string[] | Fields to group by. Omit for one row over all selected records. |
1158
- | `measures` | object | Output names mapped to `{ count \| countDistinct \| sum \| avg \| min \| max: field }`. Only `count` accepts `'*'`. |
1166
+ | `groupBy` | (string \| object)[] | Fields to group by, or date buckets `{ field, bucket, timezone, as }`. Omit for one row over all selected records. |
1167
+ | `measures` | object | Output names mapped to `{ count \| countDistinct \| sum \| avg \| min \| max: field, where? }`. Only `count` accepts `'*'`. |
1159
1168
  | `queryParams.filters` | object | Search schema filters, validated as for `query`. |
1160
1169
  | `queryParams.sort` | string[] | Grouped field or measure names, `-` for descending. Grouped fields break ties. |
1161
1170
  | `queryParams.page` | object | `size` and `number`, for grouped results only. `size` defaults to `queryDefaultLimit` and cannot exceed `queryMaxLimit`. |
@@ -1165,7 +1174,56 @@ await api.resources.sales.aggregate(params, context)
1165
1174
  times. Grouping and counting accept any scalar stored field, including belongs-to
1166
1175
  foreign keys. Fields that are `hidden`, virtual, computed, or that have a getter or
1167
1176
  `storage.serialize` are rejected, because SQL would group or sum values other than
1168
- the ones clients read. `normallyHidden` fields are allowed.
1177
+ the ones clients read. The exception is a `date` or `dateTime` field with
1178
+ `storage.serialize`: it may be bucketed and take `min` and `max`, whose results
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.
1185
+
1186
+ #### Conditional measures
1187
+
1188
+ A measure's `where` limits the rows it counts or adds up, so one call can
1189
+ return "this week" next to "this month". Every entry must hold:
1190
+
1191
+ ```javascript
1192
+ measures: {
1193
+ open: { count: '*', where: { status: 'open' } },
1194
+ bigOpen: { sum: 'amount', where: { status: 'open', amount: { gte: 1000 } } },
1195
+ late: { count: '*', where: { status: ['overdue', 'disputed'] } },
1196
+ unassigned: { count: '*', where: { owner_id: null } }
1197
+ }
1198
+ ```
1199
+
1200
+ A condition is a value, an array (IN), `null`, or an object of `eq`, `ne`,
1201
+ `gt`, `gte`, `lt`, `lte`, `in` and `notIn`. Fields may be any stored scalar field
1202
+ aggregates accept; values are translated as filter values are. The SQL is
1203
+ `fn(CASE WHEN … THEN value END)`, so conditional and plain measures share one
1204
+ statement.
1205
+
1206
+ #### Date buckets
1207
+
1208
+ A `groupBy` entry `{ field, bucket, timezone, as }` groups a `date` or
1209
+ `dateTime` field by `'day'`, `'week'`, `'month'` or `'year'`. The group value
1210
+ is the `'YYYY-MM-DD'` of the bucket's first day; weeks start on Monday. The
1211
+ output name is `as`, default the field name.
1212
+
1213
+ ```javascript
1214
+ await api.resources.sales.aggregate({
1215
+ groupBy: [{ field: 'closedAt', bucket: 'month', timezone: 'Europe/Rome', as: 'month' }],
1216
+ measures: { total: { sum: 'amount' } },
1217
+ queryParams: { sort: ['month'] }
1218
+ }, context)
1219
+ // { data: [{ month: '2026-01-01', total: 1250 }, ...] }
1220
+ ```
1221
+
1222
+ `timezone` is `'UTC'` (default), a fixed offset such as `'-09:00'`, or an IANA
1223
+ name. PostgreSQL has zone data; MySQL and MariaDB need their time zone tables
1224
+ loaded (checked once per name); SQLite takes offsets only. `date` fields have
1225
+ no time of day and take no time zone. Buckets need table storage; AnyAPI
1226
+ resources refuse them.
1169
1227
 
1170
1228
  #### Return Value
1171
1229
 
@@ -1201,6 +1259,145 @@ const totals = await api.resources.sales.aggregate({
1201
1259
 
1202
1260
  ---
1203
1261
 
1262
+ ## Data-Access Methods
1263
+
1264
+ Methods for work that does not fit one page or one record: reports, imports and
1265
+ jobs. They keep the caller's ownership filters (autofilter) and row policies.
1266
+ The guides have the details:
1267
+ [many-row writes](GUIDE/27-many-row-writes.md) and
1268
+ [scoped reads, locks and system work](GUIDE/34-scoped-reads-and-locks.md).
1269
+ Many-row writes and locks need table storage (`RestApiKnexPlugin`); AnyAPI
1270
+ resources refuse them with `RestApiBulkWriteUnsupportedError`. `queryAll` and
1271
+ `scopedQuery` work on both storages; on AnyAPI the scoped builder reads the
1272
+ canonical rows, whose fields live in storage slots.
1273
+
1274
+ ### scopedQuery - Scoped Read-Only Builder
1275
+
1276
+ ```javascript
1277
+ const { query } = await api.resources.entries.scopedQuery(
1278
+ { filters, alias, purpose, transaction }, context)
1279
+ ```
1280
+
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
1286
+ `queryPurpose`. The caller needs `query` permission. The builder refuses
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.
1291
+
1292
+ ### queryAll - Every Matching Record
1293
+
1294
+ ```javascript
1295
+ const { data, included } = await api.resources.entries.queryAll(
1296
+ { queryParams, pageSize, rowLimit, format, transaction }, context)
1297
+ ```
1298
+
1299
+ Reads every record `query` would select, in cursor pages of `pageSize` rows
1300
+ (default 1,000) in id order or `queryParams.sort`. `queryParams` takes
1301
+ `filters`, `sort`, `fields` and `include`, but not `page`. Past `rowLimit` rows
1302
+ it throws `RestApiRowLimitError`. Works on both storages.
1303
+
1304
+ ### Many-Row Writes
1305
+
1306
+ ```javascript
1307
+ await resource.postMany({ data | document, onConflict, returning, format, queryParams, chunkSize, transaction }, context)
1308
+ await resource.patchMany({ data | document, onlyIf, missing, returning, format, queryParams, chunkSize, transaction }, context)
1309
+ await resource.patchWhereIds({ ids, data | document, onlyIf, missing, returning, format, queryParams, chunkSize, transaction }, context)
1310
+ await resource.patchWhere({ filters | all: true, data | document, onlyIf, returning, format, queryParams, chunkSize, transaction }, context)
1311
+ await resource.deleteMany({ ids, missing, returning, chunkSize, transaction }, context)
1312
+ await resource.deleteWhere({ filters | all: true, returning, chunkSize, transaction }, context)
1313
+ ```
1314
+
1315
+ | Parameter | Description |
1316
+ | --- | --- |
1317
+ | `data` / `document` | `postMany`, `patchMany`: an array of plain records, or `{ data: [resource objects] }`; `patchMany` rows carry `id`. `patchWhereIds`, `patchWhere`: one record of changes. |
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). |
1320
+ | `format`, `queryParams` | Output format, `fields` and `include` of full records. |
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. |
1322
+ | `onlyIf` | Compare-and-set: only rows whose stored values equal these change and are returned. |
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. |
1324
+ | `chunkSize` | Rows per statement (default 500), lowered to the database's parameter limit. |
1325
+ | `transaction` | A transaction from `api.transaction` to join; otherwise the call owns one. |
1326
+
1327
+ Each call is all-or-nothing. Every row is validated, stamped, passed through
1328
+ setters and authorized with `checkPermissions` (`method` `post`, `patch` or
1329
+ `delete`, `originalContext.bulkIndex` the row's position); belongs-to targets
1330
+ are checked with one read per related type; then one statement runs per chunk.
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:
1337
+ `beforeProcessingPostMany`, `beforeDataCallPostMany`, `afterDataCallPostMany`,
1338
+ the `PatchMany` trio and `beforeDataCallDeleteMany` / `afterDataCallDeleteMany`.
1339
+
1340
+ `RestApiBulkWriteUnsupportedError` (422) is thrown before writing for AnyAPI
1341
+ storage, versioned resources (and those whose writes advance a version),
1342
+ per-record write hooks not registered with `{ batch: true }`, rows that set
1343
+ many-to-many or reverse relationships, file fields, or positions.
1344
+ `BulkOperationsPlugin` exposes `postMany`, `patchMany` and `deleteMany` as
1345
+ `POST`, `PATCH` and `DELETE /RESOURCE/bulk`.
1346
+
1347
+ ### lockByIds, lockWhere and claim - Row Locks
1348
+
1349
+ ```javascript
1350
+ await api.transaction(async transaction => {
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)
1353
+ const { data: taken } = await resource.claim({ filters, sort, limit, transaction, format, queryParams: { fields } }, context)
1354
+ })
1355
+ ```
1356
+
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.
1378
+
1379
+ A resource option `lockVersionField: 'lock_version'` names a stored integer
1380
+ that every update (single, many-row and upsert merge) sets to `column + 1`.
1381
+ Callers cannot set it; use it with `onlyIf`.
1382
+
1383
+ ### System Contexts
1384
+
1385
+ ```javascript
1386
+ import { createSystemContext, isSystemContext, readOnlyQueryBuilder } from 'json-rest-api'
1387
+
1388
+ const context = createSystemContext('nightly report', { jobId })
1389
+ ```
1390
+
1391
+ A system context skips autofilter ownership predicates; creates must set every
1392
+ ownership field explicitly. Row policies receive `system: true`. The marker is
1393
+ a private symbol that object spread keeps and serialization drops; a context
1394
+ with `system: true` but no marker throws `REST_API_SYSTEM_CONTEXT_FORGED`.
1395
+ Autofilter filters may be `shared: true` (on a nullable field): reads include
1396
+ rows whose field is NULL, while many-row writes, locks and single writes stay
1397
+ on the owner's rows.
1398
+
1399
+ ---
1400
+
1204
1401
  ## Relationship Methods
1205
1402
 
1206
1403
  ### getRelated - Retrieve Related Resources
@@ -1820,8 +2017,9 @@ const result = await api.resources.articles.query({
1820
2017
  ```
1821
2018
 
1822
2019
  Pagination metadata is under `meta.pagination`. With counts disabled, it includes
1823
- `page` and `pageSize`, without
1824
- 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
1825
2023
  to the available count/boundary information.
1826
2024
 
1827
2025
  #### Cursor Pagination
@@ -1939,6 +2137,27 @@ purpose. See [row policies](GUIDE/17-row-policies.md),
1939
2137
  [include configuration](GUIDE/33-migrating-to-v2.md#include-configuration-is-validated-before-publication)
1940
2138
  and the query hook contract above.
1941
2139
 
2140
+ #### Per-Parent Limits (Window Includes)
2141
+
2142
+ A to-many relationship can include only the first few related records of each
2143
+ parent, such as the newest snapshot per booking. Configure it on the
2144
+ relationship, not in the query:
2145
+
2146
+ ```javascript
2147
+ relationships: {
2148
+ snapshots: {
2149
+ type: 'hasMany', target: 'snapshots', foreignKey: 'booking_id',
2150
+ include: { strategy: 'window', limit: 1, orderBy: ['-revision'] }
2151
+ }
2152
+ }
2153
+ ```
2154
+
2155
+ `include: ['snapshots']` then returns at most `limit` visible snapshots per
2156
+ booking, ordered by `orderBy`, in one query. The default (`standard`) strategy
2157
+ applies one limit to the whole included set instead. Window includes need
2158
+ database window functions (`api.knex.capabilities.windowFunctions`). See
2159
+ [window includes](GUIDE/34-scoped-reads-and-locks.md#window-includes).
2160
+
1942
2161
  ## Configuration Options
1943
2162
 
1944
2163
  ### Plugin Configuration
@@ -2175,8 +2394,22 @@ For example, a resource not-found error maps to:
2175
2394
  | `REST_API_FIELDSET_INVALID`, `REST_API_INCLUDE_INVALID` | 400 |
2176
2395
  | `REST_API_VERSION_CONFLICT` | 409 |
2177
2396
  | `REST_API_PRECONDITION_FAILED` | 412 |
2397
+ | `RestApiRecordsNotFoundError` (subtype `not_found`) | 404 |
2398
+ | `RestApiBulkWriteUnsupportedError` / `REST_API_BULK_WRITE_UNSUPPORTED` | 422 |
2399
+ | `RestApiLockOutsideTransactionError` (a validation error) | 422 |
2400
+ | `RestApiBulkInsertIdsError` / `REST_API_BULK_INSERT_IDS` | 500 |
2401
+ | `RestApiScopedQueryReadOnlyError` / `REST_API_SCOPED_QUERY_READ_ONLY` | programmatic only |
2402
+ | `RestApiRowLimitError` / `REST_API_ROW_LIMIT` | 500 |
2403
+ | `REST_API_SYSTEM_CONTEXT_FORGED` | 500 |
2178
2404
  | Unexpected or temporal output failure | 500 |
2179
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
+
2180
2413
  Validation violations produce individual HTTP errors with their message and
2181
2414
  field location; generic validation errors retain their available details.
2182
2415
  Relationship validation/access failures follow their actual typed classification,
@@ -2197,15 +2430,14 @@ before owned commit; borrowed transactions remain the owner's responsibility.
2197
2430
  See the [callback migration guide](GUIDE/33-migrating-to-v2.md#setter-getter-and-computed-field-failures)
2198
2431
  for examples and intentional fallback handling.
2199
2432
 
2200
- Resource, relationship, bulk and canonical registry write failures use the root
2433
+ Resource, relationship, many-row and canonical registry write failures use the root
2201
2434
  export `RestApiWriteError`. Its immutable `transactionOutcome` is `none`,
2202
2435
  `pending`, `committed`, `rolledBack` or `unknown`. Its `cause` preserves the
2203
2436
  original thrown value, including frozen errors, while classification fields
2204
2437
  such as `code`, `subtype` and `details` remain directly readable. Read errors
2205
2438
  retain their existing contract.
2206
2439
 
2207
- HTTP JSON:API write errors include `meta.transactionOutcome`; non-atomic bulk
2208
- entries include `error.transactionOutcome`. A failed call can represent a
2440
+ HTTP JSON:API write errors include `meta.transactionOutcome`. A failed call can represent a
2209
2441
  committed write when a later hook rejects. Retrying that call can duplicate its
2210
2442
  effects; neither an HTTP status nor an unknown outcome authorizes automatic
2211
2443
  replay. See the [transaction migration guide](GUIDE/33-migrating-to-v2.md#transactions-and-errors)
@@ -2346,44 +2578,20 @@ For full examples and dialect notes, see [Knex Schema and Migrations](GUIDE/21-s
2346
2578
 
2347
2579
  ### Batch Operations
2348
2580
 
2349
- Process multiple operations efficiently:
2350
-
2351
- ```javascript
2352
- // Batch create with transaction
2353
- const createArticles = async (articlesData) => {
2354
- return api.transaction(async transaction => {
2355
- const results = [];
2356
- for (const data of articlesData) {
2357
- const result = await api.resources.articles.post({
2358
- data,
2359
- transaction,
2360
- format: 'plain',
2361
- returning: 'minimal'
2362
- });
2363
- results.push(result);
2364
- }
2365
-
2366
- return results;
2367
- });
2368
- };
2369
-
2370
- // Batch update
2371
- const updateArticles = async (updates) => {
2372
- return api.transaction(async transaction => {
2373
- for (const { id, data } of updates) {
2374
- await api.resources.articles.patch({
2375
- id,
2376
- data,
2377
- transaction,
2378
- format: 'plain',
2379
- returning: 'none'
2380
- });
2381
- }
2382
-
2383
- });
2384
- };
2581
+ Write many records with the [many-row methods](#many-row-writes): one statement
2582
+ per chunk, all-or-nothing, with the same validation and permissions as single
2583
+ writes. Group them with other calls in `api.transaction`:
2584
+
2585
+ ```javascript
2586
+ await api.transaction(async transaction => {
2587
+ await api.resources.articles.postMany({ data: articlesData, transaction })
2588
+ await api.resources.articles.patchWhereIds({ ids: staleIds, data: { status: 'archived' }, transaction })
2589
+ })
2385
2590
  ```
2386
2591
 
2592
+ When each record needs its own hooks (or the resource refuses many-row writes),
2593
+ call the single-record methods in a loop inside one `api.transaction`.
2594
+
2387
2595
  ### Computed Fields
2388
2596
 
2389
2597
  Declare computed fields in `schema`, with `computed: true`, `dependencies` and
@@ -12,7 +12,7 @@ related resources. The [quickstart](../QUICKSTART.md) provides a complete
12
12
  Express server with related publishers and authors.
13
13
 
14
14
  Use Node 24 or newer; development verification runs on Node 24. These examples
15
- use the revised contract in the [migration guide](33-migrating-to-v2.md). Until
15
+ use the v3 contract; see the [migration guide](35-migrating-to-v3.md). Until
16
16
  its release is published, install the intended library tarball instead of the
17
17
  registry package.
18
18
 
@@ -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
@@ -342,7 +342,7 @@ Built-in nested operations use separate context objects with shallow inheritance
342
342
  | --- | --- | --- |
343
343
  | Full write-response GET | Application properties and referenced cache/auth objects; the active transaction is passed explicitly. Write input is available for virtual-field response enrichment. | GET sets its own method, ID, schema, query selection and read result. Replacing a child top-level property does not replace the parent's property. |
344
344
  | Relationship PATCH and reverse relationship child writes | Application properties and referenced objects; the active transaction is passed explicitly. | Child writes set their own method, input, schema and stored records. The parent retains its own operation identity and finish context. |
345
- | Bulk children | Application properties and referenced objects; atomic children share the batch transaction. | Each child has its own context with `bulkOperation` and `bulkIndex`. Child cleanup diagnostics are collected by the batch. |
345
+ | Many-row writes | One context for the whole call, with `inputRecords` and `ids`. | Each row's permission check gets its own wrapper, with the row's position in `originalContext.bulkIndex`. |
346
346
  | Ordinary CRUD permission checks and attribute enrichment | A reference to the relevant operation through `originalContext` or `parentContext`. | A fresh wrapper for that permission/enrichment call. Adding a wrapper property does not add it to the originating context. Relationship permission hooks instead receive the relationship operation context directly. |
347
347
  | Included-resource and relationship visibility queries | Application context and the active connection/transaction. | The library sets the target resource/schema and query selection; parent filters are not blindly applied as target filters. |
348
348
 
@@ -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
@@ -412,7 +417,7 @@ undeclared output key does not establish a supported field or bypass final
412
417
  field filtering. Finish hooks are late observers; they are not a substitute
413
418
  for schema validation or query policy.
414
419
 
415
- ## Relationship and bulk composition
420
+ ## Relationship and many-row composition
416
421
 
417
422
  Relationship POST and DELETE run `beforeDataCall`, followed by
418
423
  `beforeDataCallPostRelationship` or `beforeDataCallDeleteRelationship`, after
@@ -448,9 +453,32 @@ hooks. Reverse relationship mutations can run child PATCH lifecycles inside
448
453
  the parent's operation. Direct pivot/link changes do not synthesize
449
454
  pivot-resource hook calls.
450
455
 
451
- Bulk operations invoke child resource lifecycles. Atomic children share a
452
- transaction; non-atomic children complete separate transactions. Do not count
453
- hook invocations as independent user requests or assume one context per batch.
456
+ Many-row writes (`postMany`, `patchMany`, `patchWhereIds`, `patchWhere`,
457
+ `deleteMany`, `deleteWhere`) do not run the per-record write hooks. They run
458
+ batch hooks once per call, with every row in `context.inputRecords` and the
459
+ written ids in `context.ids`:
460
+
461
+ | Method | Batch hooks |
462
+ | --- | --- |
463
+ | `postMany` | `beforeProcessingPostMany`, `beforeDataCallPostMany`, `afterDataCallPostMany` |
464
+ | `patchMany`, `patchWhereIds`, `patchWhere` | `beforeProcessingPatchMany`, `beforeDataCallPatchMany`, `afterDataCallPatchMany` |
465
+ | `deleteMany`, `deleteWhere` | `beforeDataCallDeleteMany`, `afterDataCallDeleteMany` |
466
+
467
+ `checkPermissions`, `afterCommit` and `afterRollback` still run. A resource with
468
+ a per-record write hook (`beforeProcessing*`, `beforeSchemaValidate*`,
469
+ `afterSchemaValidate*`, `beforeDataCall*`, `afterDataCall*`, `finish*` for post,
470
+ patch or delete) has its many-row writes refused, unless the hook was
471
+ registered with `{ batch: true }`: a promise that a batch hook does its work, or
472
+ that it does not apply to many-row writes.
473
+
474
+ ```js
475
+ api.addHook('finishPost', 'notify-owner', { batch: true }, notifyOne)
476
+ api.addHook('afterDataCallPostMany', 'notify-owners', {}, ({ context }) => notifyMany(context.ids))
477
+ ```
478
+
479
+ Method and hook handlers receive `perRecordHooks(events, scope)`, which lists
480
+ the `{ event, name }` of the handlers that would block a resource. See
481
+ [many-row writes](27-many-row-writes.md#hooks-and-refusals).
454
482
 
455
483
  ## Completion and failure
456
484
 
@@ -108,8 +108,9 @@ The current implementation depends on more than individual CRUD helpers:
108
108
  - Writes need an actual transaction factory and the managed ownership contract,
109
109
  including acknowledged commit/rollback, uncertain completion, completion
110
110
  hooks and failure diagnostics. Remote side effects need their own semantics.
111
- - Bulk operations and schema changes must preserve their documented atomicity
112
- and publication boundaries. Supporting a single-record happy path is not
111
+ - Many-row writes and schema changes must preserve their documented atomicity
112
+ and publication boundaries. A backend without the many-row storage helpers
113
+ (`dataInsertMany` and the others) has its many-row writes refused. Supporting a single-record happy path is not
113
114
  sufficient evidence for the backend.
114
115
 
115
116
  The source checkout's `plugins/core/lib/storage/storage-types.d.ts` describes
@@ -276,6 +276,58 @@ await api.addResource('system_settings', {
276
276
 
277
277
  If a resource has no `autofilter` setting, the plugin does nothing for that resource.
278
278
 
279
+ ## Shared Rows
280
+
281
+ Reference data that every owner reads, such as default categories, can live in
282
+ the same table as owned rows. Mark the filter `shared: true`; a NULL value in
283
+ its field then marks a shared row. The field must be `nullable`.
284
+
285
+ ```js
286
+ await api.use(AutoFilterPlugin, {
287
+ resolvers: { workspace: ({ context }) => context.session?.workspaceId },
288
+ presets: {
289
+ workspaceOrShared: { filters: [{ field: 'workspace_id', resolver: 'workspace', shared: true }] }
290
+ }
291
+ })
292
+ await api.addResource('categories', {
293
+ schema: {
294
+ name: { type: 'string', required: true },
295
+ workspace_id: { type: 'string', nullable: true }
296
+ },
297
+ autofilter: 'workspaceOrShared'
298
+ })
299
+ ```
300
+
301
+ - Reads (`query`, `get`, includes, `aggregate`, `scopedQuery`) see the owner's
302
+ rows and the shared rows.
303
+ - Many-row writes, `lockByIds`, `lockWhere`, `claim` and `scopedQuery` with purpose
304
+ `'bulk-write'`, `'lock'` or `'claim'` see only the owner's rows.
305
+ - Single `patch`, `put` and `delete` of a shared row fail as `forbidden`, and so
306
+ does a create whose owner value resolves to NULL.
307
+ - Shared rows are created and changed with a system context (below).
308
+
309
+ `shared` must be `true` or `false` (the default).
310
+
311
+ ## System Contexts
312
+
313
+ Work that legitimately spans every owner, such as nightly reports or imports,
314
+ uses a context from `createSystemContext(reason, values)`:
315
+
316
+ ```js
317
+ import { createSystemContext } from 'json-rest-api'
318
+
319
+ const context = createSystemContext('rebuild search index')
320
+ const everyProject = await api.resources.projects.queryAll({}, context)
321
+ await api.resources.categories.post({ data: { name: 'General', workspace_id: null } }, context)
322
+ ```
323
+
324
+ For a system context the plugin applies no ownership filters, and creates
325
+ must set every autofilter field explicitly (NULL creates a shared row on a
326
+ `shared` filter; a missing value fails with `system_ownership_required`). The
327
+ context carries a private marker that request input cannot produce. A context
328
+ that sets `system: true` without it throws `REST_API_SYSTEM_CONTEXT_FORGED`.
329
+ See [system contexts](34-scoped-reads-and-locks.md#system-contexts).
330
+
279
331
  ## Runtime Behavior
280
332
 
281
333
  ### Query Scoping
@@ -454,8 +506,8 @@ api.autofilter.getScopeConfig('projects')
454
506
  // {
455
507
  // preset: 'workspace_user',
456
508
  // filters: [
457
- // { field: 'workspace_id', resolver: 'workspace', required: true },
458
- // { field: 'user_id', resolver: 'user', required: true }
509
+ // { field: 'workspace_id', resolver: 'workspace', required: true, shared: false },
510
+ // { field: 'user_id', resolver: 'user', required: true, shared: false }
459
511
  // ]
460
512
  // }
461
513
  ```