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.
- package/README.md +7 -2
- package/docs/API.md +262 -54
- package/docs/GUIDE/01-getting-started.md +1 -1
- package/docs/GUIDE/07-polymorphic-relationships.md +4 -2
- package/docs/GUIDE/09-pagination-and-sorting.md +4 -2
- package/docs/GUIDE/13-hooks-and-lifecycle.md +34 -6
- package/docs/GUIDE/14-custom-resource-methods.md +3 -2
- package/docs/GUIDE/16-autofiltering.md +54 -2
- package/docs/GUIDE/17-row-policies.md +15 -5
- package/docs/GUIDE/19-managed-transactions.md +8 -9
- package/docs/GUIDE/20-transaction-outcomes.md +3 -4
- package/docs/GUIDE/21-schema-and-migrations.md +7 -1
- package/docs/GUIDE/22-version-field-migrations.md +3 -3
- package/docs/GUIDE/27-many-row-writes.md +458 -0
- package/docs/GUIDE/28-socketio.md +10 -2
- package/docs/GUIDE/30-backend-capabilities.md +42 -2
- package/docs/GUIDE/31-positioning.md +3 -1
- package/docs/GUIDE/33-migrating-to-v2.md +2 -2
- package/docs/GUIDE/34-scoped-reads-and-locks.md +464 -0
- package/docs/GUIDE/35-migrating-to-v3.md +325 -0
- package/docs/GUIDE/index.md +5 -2
- package/docs/QUICKSTART.md +2 -2
- package/docs/contributing.md +1 -1
- package/index.d.ts +8 -1
- package/index.js +4 -0
- package/lib/rest-api-errors.js +79 -0
- package/lib/runtime/json-rest-api.js +18 -3
- package/lib/system-context.js +42 -0
- package/package.json +3 -2
- package/plugins/core/bulk-operations-plugin.js +54 -436
- package/plugins/core/connectors/lib/transport-http-helpers.js +34 -3
- package/plugins/core/file-handling-plugin.js +17 -2
- package/plugins/core/lib/anyapi/anyapi-registry.js +3 -2
- package/plugins/core/lib/anyapi/canonical-link-store.js +5 -2
- package/plugins/core/lib/anyapi/canonical-relationship-reader.js +56 -60
- package/plugins/core/lib/dbIntrospection.js +106 -6
- package/plugins/core/lib/querying/aggregate-query.js +215 -22
- package/plugins/core/lib/querying/include-query-helpers.js +11 -13
- package/plugins/core/lib/querying/include-to-one.js +54 -59
- package/plugins/core/lib/querying/knex-pagination-helpers.js +9 -2
- package/plugins/core/lib/querying/query-constraint.d.ts +11 -0
- package/plugins/core/lib/querying/query-constraint.js +45 -0
- package/plugins/core/lib/querying/read-only-query.js +92 -0
- package/plugins/core/lib/querying-writing/compile-schemas.js +14 -0
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
- package/plugins/core/lib/storage/canonical-data-helpers.js +103 -39
- package/plugins/core/lib/storage/many-row-data-helpers.js +738 -0
- package/plugins/core/lib/storage/ordinary-data-helpers.js +63 -79
- package/plugins/core/lib/storage/single-row-writes.js +71 -0
- package/plugins/core/lib/storage/storage-adapter.js +18 -8
- package/plugins/core/lib/storage/storage-mapping.js +4 -1
- package/plugins/core/lib/storage/storage-types.d.ts +21 -3
- package/plugins/core/lib/writing/many-to-many-manipulations.js +10 -5
- package/plugins/core/lib/writing/relationship-processor.js +33 -37
- package/plugins/core/rest-api-anyapi-knex-plugin.js +3 -0
- package/plugins/core/rest-api-autofilter-plugin.js +137 -10
- package/plugins/core/rest-api-knex-plugin.js +2 -0
- package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
- package/plugins/core/rest-api-plugin-methods/common.js +144 -84
- package/plugins/core/rest-api-plugin-methods/delete-many.js +132 -0
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +22 -0
- package/plugins/core/rest-api-plugin-methods/get.js +48 -42
- package/plugins/core/rest-api-plugin-methods/many-row-common.js +274 -0
- package/plugins/core/rest-api-plugin-methods/patch-many.js +333 -0
- package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
- package/plugins/core/rest-api-plugin-methods/post-many.js +295 -0
- package/plugins/core/rest-api-plugin-methods/put.js +5 -1
- package/plugins/core/rest-api-plugin-methods/scoped-reads.js +221 -0
- package/plugins/core/rest-api-plugin.js +17 -0
- package/plugins/core/rest-api-positioning-plugin.js +22 -4
- package/plugins/core/rest-api-query-projections-plugin.js +9 -1
- package/plugins/core/rest-api-row-policy-plugin.js +4 -0
- package/plugins/core/socketio-plugin.js +80 -2
- package/types/autofilter.d.ts +5 -1
- package/types/data-access-methods.d.ts +141 -0
- package/types/errors.d.ts +45 -0
- package/types/hook-context.d.ts +21 -0
- package/types/plugin-options.d.ts +3 -1
- package/types/resource-methods.d.ts +27 -4
- package/types/row-policy.d.ts +3 -0
- package/types/runtime.d.ts +7 -1
- package/docs/GUIDE/27-bulk-operations.md +0 -277
- 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
|
|
10
|
-
[
|
|
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
|
|
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
|
|
2035
|
-
|
|
2034
|
+
offset metadata contains `page` and `pageSize` (and, since 3.0, `hasMore`, from
|
|
2035
|
+
one extra row); it does not promise totals. Cursor pages use one extra row for `hasMore` and never issue a count
|
|
2036
2036
|
query, regardless of this setting. Ordinary storage previously issued a count
|
|
2037
2037
|
on the first cursor page and discarded its result; that extra query and its
|
|
2038
2038
|
count-filter hook invocation have been removed. Collection predicates still
|
|
@@ -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.
|