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
@@ -6,8 +6,9 @@ chapter_label: "30"
6
6
 
7
7
  # 30. Backend capabilities and limits
8
8
 
9
- This reference describes the v2 storage contracts. Follow the
10
- [migration guide](33-migrating-to-v2.md) before upgrading an application.
9
+ This reference describes the v3 storage contracts. Follow the migration guides
10
+ ([to v2](33-migrating-to-v2.md), [to v3](35-migrating-to-v3.md)) before
11
+ upgrading an application.
11
12
  Node 24 or later is required; verification runs on Node 24 only.
12
13
 
13
14
  ## Database and storage choices
@@ -36,6 +37,19 @@ See [contributing](../contributing.md#real-databases-and-redis) for native
36
37
  database verification commands. A passing SQLite suite is not native PostgreSQL/MySQL
37
38
  evidence, even when launched with a different storage-mode environment variable.
38
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
+
39
53
  ## Temporal values
40
54
 
41
55
  Public date/dateTime inputs are strings; time inputs remain strings too. Built-in
@@ -73,6 +87,32 @@ and belongs-to slots are always indexed. Old canonical temporal storage needs
73
87
  the documented data migration, not just a package update. See
74
88
  [table helpers and migration limits](21-schema-and-migrations.md#anyapi-across-several-servers).
75
89
 
90
+ ## Many-row writes, locks and aggregates
91
+
92
+ [Many-row writes](27-many-row-writes.md), row locks and `lockVersionField`
93
+ ([chapter 34](34-scoped-reads-and-locks.md)) need table storage
94
+ (`RestApiKnexPlugin`). Resources on AnyAPI storage refuse them with
95
+ `RestApiBulkWriteUnsupportedError`. `queryAll`, `aggregate` without date
96
+ buckets, shared rows and system contexts work on both storages; so does
97
+ `scopedQuery`, though on AnyAPI it reads canonical rows whose fields live in
98
+ storage slots.
99
+
100
+ | | PostgreSQL | MySQL 8 / MariaDB | SQLite |
101
+ | --- | --- | --- | --- |
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` |
103
+ | Upsert | `ON CONFLICT` | `ON DUPLICATE KEY UPDATE`; a conflict on another unique index fails the batch | `ON CONFLICT` |
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 |
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 |
108
+
109
+ Rows per statement are capped by `chunkSize` (default 500) and by the
110
+ database's bound-parameter limit, so statement counts grow with the number of
111
+ chunks, not rows. The 3.0 features are verified on SQLite (`better-sqlite3`
112
+ 13), PostgreSQL 18 and MariaDB 11.4. No MySQL server was available for 3.0;
113
+ its `insertId` path was exercised against MariaDB, and the other MySQL paths
114
+ share MariaDB's code.
115
+
76
116
  ## Transaction ownership and failures
77
117
 
78
118
  Use `api.transaction` to group library writes and raw SQL. Its callback receives
@@ -143,7 +143,9 @@ that enabling `autoIndex` created an index.
143
143
  `api.positioning.getConfig()` returns the configuration, and
144
144
  `api.positioning.isEnabled(resourceName)` reports whether a registered resource
145
145
  participates. The old no-op `api.positioning.reorder()` helper has been removed;
146
- use resource PATCH operations or [atomic bulk PATCH](27-bulk-operations.md).
146
+ use resource PATCH operations inside one `api.transaction`. Positions are
147
+ computed per row against their neighbours, so [many-row writes](27-many-row-writes.md)
148
+ refuse to create positioned records or to change positions.
147
149
  The formerly inert `rebalanceThreshold` option now rejects.
148
150
 
149
151
  There is no automatic rebalancing. Keys that exceed the tighter of the schema's
@@ -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
@@ -0,0 +1,464 @@
1
+ ---
2
+ title: "Scoped reads, locks and system work"
3
+ chapter: 34
4
+ chapter_label: "34"
5
+ ---
6
+
7
+ # 34. Scoped reads, locks and system work
8
+
9
+ Ownership filters ([autofiltering](16-autofiltering.md)) and
10
+ [row policies](17-row-policies.md) apply to every resource method. This chapter
11
+ covers the tools for work that does not fit a page of records, so that
12
+ it does not need hand-written Knex that skips them:
13
+
14
+ - shared rows that every owner reads;
15
+ - `scopedQuery`, a read-only Knex builder already limited to the caller's rows;
16
+ - `queryAll`, every matching row in cursor pages;
17
+ - window includes, the newest few related rows per parent;
18
+ - `lockByIds`, `lockWhere` and `claim`, row locks inside a transaction;
19
+ - `lockVersionField`, a counter every update advances;
20
+ - system contexts, for jobs that work across every owner.
21
+
22
+ Together with [many-row writes](27-many-row-writes.md) and
23
+ [aggregates](../API.md#aggregate---grouped-totals) they cover reports, imports and
24
+ background jobs. Locks and lock versions need table storage
25
+ (`RestApiKnexPlugin`). On AnyAPI storage `scopedQuery` builds on the canonical
26
+ rows, whose fields live in storage slots, so there it suits counts and id
27
+ subqueries rather than reports by field name.
28
+
29
+ ## Runnable example
30
+
31
+ Insert the following five JavaScript blocks into the
32
+ [starting script](03-running-example.md), after installing table storage and
33
+ before starting the server, on a fresh database.
34
+
35
+ ```javascript
36
+ import { AutoFilterPlugin, createSystemContext } from 'json-rest-api'
37
+
38
+ await api.use(AutoFilterPlugin, {
39
+ resolvers: { workspace: ({ context }) => context.session?.workspaceId },
40
+ presets: {
41
+ workspace: { filters: [{ field: 'workspace_id', resolver: 'workspace' }] },
42
+ workspaceOrShared: { filters: [{ field: 'workspace_id', resolver: 'workspace', shared: true }] }
43
+ }
44
+ })
45
+ await api.addResource('categories', {
46
+ schema: {
47
+ name: { type: 'string', required: true },
48
+ workspace_id: { type: 'string', nullable: true }
49
+ },
50
+ autofilter: 'workspaceOrShared'
51
+ })
52
+ await api.addResource('jobs', {
53
+ schema: {
54
+ title: { type: 'string', required: true, search: true },
55
+ state: { type: 'string', defaultTo: 'queued', search: true },
56
+ priority: { type: 'integer', defaultTo: 0 },
57
+ workspace_id: { type: 'string', required: true },
58
+ lock_version: { type: 'integer', defaultTo: 0 }
59
+ },
60
+ autofilter: 'workspace',
61
+ lockVersionField: 'lock_version'
62
+ })
63
+ await api.resources.categories.createKnexTable()
64
+ await api.resources.jobs.createKnexTable()
65
+ const workspace = workspaceId => ({ session: { workspaceId } })
66
+ ```
67
+
68
+ ```javascript
69
+ const seeding = createSystemContext('seed shared categories')
70
+ await api.resources.categories.post({ data: { name: 'General', workspace_id: null } }, seeding)
71
+ await api.resources.categories.post({ data: { name: 'Acme only' } }, workspace('acme'))
72
+ const acmeCategories = await api.resources.categories.query({ format: 'plain' }, workspace('acme'))
73
+ const otherCategories = await api.resources.categories.query({ format: 'plain' }, workspace('other'))
74
+ const general = acmeCategories.data.find(category => category.name === 'General')
75
+ let sharedEdit
76
+ try {
77
+ await api.resources.categories.patch({ id: general.id, data: { name: 'Renamed' } }, workspace('acme'))
78
+ } catch (error) { sharedEdit = error.cause ?? error }
79
+ console.log(acmeCategories.data.map(category => category.name), otherCategories.data.map(category => category.name), sharedEdit.subtype)
80
+ ```
81
+
82
+ Acme reads General and Acme only; the other workspace reads only General. An
83
+ ordinary context cannot change the shared row, so the patch fails as
84
+ `forbidden`.
85
+
86
+ ```javascript
87
+ await api.resources.jobs.postMany({
88
+ data: [
89
+ { title: 'Resize images', priority: 5 },
90
+ { title: 'Send digest', priority: 1 },
91
+ { title: 'Rebuild index', priority: 9, state: 'done' }
92
+ ]
93
+ }, workspace('acme'))
94
+ await api.resources.jobs.post({ data: { title: 'Other workspace job' } }, workspace('other'))
95
+
96
+ const { query } = await api.resources.jobs.scopedQuery({}, workspace('acme'))
97
+ const byState = await query.select('state').count({ total: '*' }).groupBy('state').orderBy('state')
98
+ const everyJob = await api.resources.jobs.queryAll({ format: 'plain', pageSize: 2 }, workspace('acme'))
99
+ console.log(byState, everyJob.data.map(job => job.title))
100
+ ```
101
+
102
+ The scoped query counts only Acme's jobs: one `done` and two `queued`.
103
+ `queryAll` reads Acme's three jobs in two pages.
104
+
105
+ ```javascript
106
+ const claimed = await api.transaction(async transaction => {
107
+ const taken = await api.resources.jobs.claim({
108
+ filters: { state: 'queued' }, sort: ['-priority'], limit: 1, format: 'plain', transaction
109
+ }, workspace('acme'))
110
+ await api.resources.jobs.patchWhereIds({
111
+ ids: taken.data.map(job => job.id), data: { state: 'running' }, transaction
112
+ }, workspace('acme'))
113
+ return taken.data
114
+ })
115
+ const locked = await api.transaction(async transaction => {
116
+ const { data } = await api.resources.jobs.lockByIds({ ids: [claimed[0].id], format: 'plain', transaction }, workspace('acme'))
117
+ return data[0]
118
+ })
119
+ let outsideTransaction
120
+ try {
121
+ await api.resources.jobs.lockByIds({ ids: [claimed[0].id] }, workspace('acme'))
122
+ } catch (error) { outsideTransaction = error }
123
+ console.log(claimed.map(job => job.title), locked.state, locked.lock_version, outsideTransaction.name)
124
+ ```
125
+
126
+ The claim takes the queued job with the highest priority, Resize images, and
127
+ marks it running in the same transaction. The update advanced its lock version
128
+ to 1. Locks need a transaction, so the last call fails with
129
+ `RestApiLockOutsideTransactionError`.
130
+
131
+ ```javascript
132
+ const nightly = createSystemContext('nightly job report')
133
+ const everyOwner = await api.resources.jobs.queryAll({ format: 'plain' }, nightly)
134
+ let forged
135
+ try {
136
+ await api.resources.jobs.query({ format: 'plain' }, { system: true })
137
+ } catch (error) { forged = error.cause ?? error }
138
+ console.log(everyOwner.data.length, forged.code)
139
+ ```
140
+
141
+ The system context reads all four jobs. A context that only claims
142
+ `system: true` fails with `REST_API_SYSTEM_CONTEXT_FORGED`.
143
+
144
+ ## Shared rows
145
+
146
+ An autofilter filter with `shared: true` marks rows whose field is NULL as
147
+ shared: every owner reads them. The field must be `nullable`.
148
+
149
+ | Operation | Sees |
150
+ | --- | --- |
151
+ | `query`, `get`, includes, `aggregate`, `scopedQuery` (`purpose: 'report'`) | the owner's rows and shared rows |
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.
179
+
180
+ Create and change shared rows with a [system context](#system-contexts).
181
+ `api.autofilter.getScopeConfig(...)` reports `shared` for each filter, and
182
+ `schemaInfo.ownershipFields` lists a resource's autofilter fields.
183
+
184
+ ## scopedQuery
185
+
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
188
+ search `filters` already applied. (It is wrapped in an object because a Knex
189
+ builder is thenable and would run if an async method returned it.) Use it for
190
+ reports that `aggregate` cannot express, and as a subquery.
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
+
197
+ | Parameter | Default | Meaning |
198
+ | --- | --- | --- |
199
+ | `filters` | | Search filters, validated as `query` validates them |
200
+ | `alias` | the table name | Table alias for your column references |
201
+ | `purpose` | `'report'` | `'report'` includes shared rows; `'bulk-write'`, `'lock'` and `'claim'` do not. Row policies receive it as `queryPurpose` |
202
+ | `transaction` | | Knex transaction to read in |
203
+
204
+ ```js
205
+ const { query } = await api.resources.entries.scopedQuery({ alias: 'e', filters: { status: 'open' } }, context)
206
+ const totals = await query
207
+ .join('accounts as a', 'a.id', 'e.account_id')
208
+ .select('a.name')
209
+ .sum({ total: 'e.amount' })
210
+ .groupBy('a.name')
211
+ ```
212
+
213
+ The caller needs `query` permission on the resource. The builder is
214
+ SELECT-only: `insert`, `update`, `delete`, `forUpdate` and the other locking
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.
229
+
230
+ ## queryAll
231
+
232
+ `query` returns one page. `queryAll(params, context)` returns every matching
233
+ record, reading cursor pages in id order (or the given `queryParams.sort`):
234
+
235
+ ```js
236
+ const { data } = await api.resources.entries.queryAll({
237
+ queryParams: { filters: { account: accountId }, include: ['labels'] },
238
+ pageSize: 1000,
239
+ rowLimit: 10000
240
+ }, context)
241
+ ```
242
+
243
+ | Parameter | Default | Meaning |
244
+ | --- | --- | --- |
245
+ | `queryParams` | | As for `query`, without `page` |
246
+ | `pageSize` | 1,000 | Rows per statement |
247
+ | `rowLimit` | none | Throws `RestApiRowLimitError` once more rows than this are read |
248
+ | `format`, `transaction` | | As for `query` |
249
+
250
+ It costs one statement per page plus the page's includes. Use it for id-list
251
+ reads, one parent's rows and jobs; for request handlers, set a `rowLimit` and
252
+ send larger work to a background job.
253
+
254
+ ## Window includes
255
+
256
+ An include normally applies one limit to all included targets. With the
257
+ window strategy a to-many relationship includes up to `limit` related rows
258
+ per parent, ordered by `orderBy`, which is how to read "the newest snapshot of
259
+ each booking" in one query:
260
+
261
+ ```js
262
+ await api.addResource('bookings', {
263
+ schema: { reference: { type: 'string', required: true } },
264
+ relationships: {
265
+ snapshots: {
266
+ type: 'hasMany',
267
+ target: 'snapshots',
268
+ foreignKey: 'booking_id',
269
+ include: { strategy: 'window', limit: 1, orderBy: ['-revision'] }
270
+ }
271
+ }
272
+ })
273
+
274
+ const page = await api.resources.bookings.query({ queryParams: { include: ['snapshots'] } }, context)
275
+ ```
276
+
277
+ The strategy is part of the relationship definition, not of the query. The
278
+ included rows pass the target's ownership filters, row policy and permission
279
+ check before the window is applied. The database must support window functions
280
+ (`api.knex.capabilities.windowFunctions`); SQLite 3.25+, PostgreSQL, MySQL 8
281
+ and MariaDB 10.2+ do. AnyAPI storage applies the same rule.
282
+
283
+ ## Row locks
284
+
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.
314
+
315
+ `lockByIds({ ids, transaction, missing, format, queryParams: { fields } })`
316
+ locks the listed rows the caller can see, in id order, so two transactions that
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
321
+ `RestApiRecordsNotFoundError`, or are left out with `missing: 'skip'`. The
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.
369
+
370
+ `claim({ filters, sort, limit, transaction, format, queryParams: { fields } })`
371
+ takes up to `limit` matching rows (default 1, at most 1,000) that no other
372
+ transaction holds, in `sort` order (stored fields; default `['id']`). Rows other
373
+ workers hold are skipped (`SKIP LOCKED`), so several workers can share one
374
+ queue. Change the claimed rows in the same transaction, as the example does.
375
+ A row policy that joins other tables is refused for `claim`, because the
376
+ database would lock the joined rows too.
377
+
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.
403
+
404
+ ## Lock versions
405
+
406
+ `lockVersionField` names a stored, non-nullable integer field that the library
407
+ advances on every update: single `patch` and `put`, every many-row patch and
408
+ upsert merge set it to `column + 1` in the UPDATE itself. Callers cannot set it,
409
+ and a `put` need not repeat it.
410
+
411
+ ```js
412
+ await api.addResource('orders', {
413
+ schema: {
414
+ status: { type: 'string', required: true },
415
+ lock_version: { type: 'integer', defaultTo: 0 }
416
+ },
417
+ lockVersionField: 'lock_version'
418
+ })
419
+ ```
420
+
421
+ Combine it with `onlyIf` for compare-and-set updates (see
422
+ [many-row writes](27-many-row-writes.md#options)). It is separate from
423
+ `versionField`, the revision token of [HTTP validators](22-version-field-migrations.md):
424
+ the field may not be the id, the `versionField`, a relationship, or have
425
+ getters, setters or a storage serializer. AnyAPI storage refuses it.
426
+
427
+ ## System contexts
428
+
429
+ Some work legitimately spans every owner: nightly reports, imports, cleanup
430
+ jobs. Give it a system context instead of a context with forged scope values:
431
+
432
+ ```js
433
+ import { createSystemContext, isSystemContext } from 'json-rest-api'
434
+
435
+ const context = createSystemContext('close the month for every workspace', { jobId })
436
+ await api.resources.entries.patchWhere({ filters: { status: 'open' }, data: { status: 'closed' } }, context)
437
+ ```
438
+
439
+ `createSystemContext(reason, values)` returns a context marked with a private
440
+ symbol. Object spread keeps the marker, so contexts the library derives from it
441
+ stay system contexts; serialization drops it, so request input can never carry
442
+ it. `reason` is required and appears in diagnostics.
443
+
444
+ With a system context:
445
+
446
+ - autofilter applies no ownership filters, and creates must set every ownership
447
+ field explicitly (NULL creates a shared row);
448
+ - row policies receive `system: true` and decide what it means for them;
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
+ ```
460
+
461
+ A context with `system: true` but no marker throws
462
+ `REST_API_SYSTEM_CONTEXT_FORGED`. Create system contexts only in server code
463
+ that has already decided the work may see every owner's rows; never from
464
+ request input.