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
@@ -0,0 +1,325 @@
1
+ ---
2
+ title: "Migrating to v3"
3
+ chapter: 35
4
+ chapter_label: "35"
5
+ ---
6
+
7
+ # 35. Migrating to v3
8
+
9
+ Version 3 adds a data-access layer for work that does not fit one record at a
10
+ time: reports, imports and background jobs. It replaces the 2.x bulk methods,
11
+ which wrote one row per call, with many-row writes that run one statement per
12
+ chunk. Single-record methods, relationship methods, HTTP routes other than
13
+ `/bulk` and stored data are unchanged. There is no compatibility mode; the old
14
+ bulk options are rejected with a message that names their replacement.
15
+
16
+ ## What is new
17
+
18
+ - **Many-row writes:** `postMany`, `patchMany`, `patchWhereIds`, `patchWhere`,
19
+ `deleteMany` and `deleteWhere`, all-or-nothing, with upserts (`onConflict`)
20
+ and compare-and-set (`onlyIf`). See [many-row writes](27-many-row-writes.md).
21
+ - **Scoped reads:** `scopedQuery`, a read-only Knex builder limited to the
22
+ caller's rows, and `queryAll`, every matching row in cursor pages.
23
+ - **Row locks:** `lockByIds`, `lockWhere` and `claim` (`SKIP LOCKED`), inside a
24
+ transaction.
25
+ - **Lock versions:** `lockVersionField`, a counter every update advances.
26
+ - **Shared rows:** an autofilter filter with `shared: true` lets every owner
27
+ read rows whose owner field is NULL.
28
+ - **System contexts:** `createSystemContext(reason)` for work across every
29
+ owner; `isSystemContext(context)`; `readOnlyQueryBuilder(builder)`.
30
+ - **Batch hooks:** `beforeProcessingPostMany`, `beforeDataCallPostMany`,
31
+ `afterDataCallPostMany` and the `PatchMany` and `DeleteMany` forms; hook
32
+ registrations take `{ batch: true }`.
33
+ - **Aggregates:** measures take a `where` condition; `groupBy` takes date
34
+ buckets (`day`, `week`, `month`, `year`) with a time zone; date fields with a
35
+ storage serializer can be bucketed and take `min` and `max`. In a system
36
+ context, a hidden ownership field may be grouped and counted distinct. See
37
+ [AGGREGATE](../API.md#aggregate---grouped-totals).
38
+ - **One read for `get`:** a resource with no `checkPermissions`,
39
+ `checkDataPermissions`, `checkDataPermissionsGet`, `beforeData` or
40
+ `beforeDataGet` hook reads its record in one statement; such a get no
41
+ longer sets `context.minimalRecord` (read `context.record` in later hooks).
42
+ - **Skipping writes in one statement:** `patchMany`, `patchWhereIds` and
43
+ `deleteMany` with `missing: 'skip'` put the scope in the UPDATE or DELETE
44
+ instead of reading the visible ids first, when no permission or
45
+ before-data-call batch hook needs those rows (not on MySQL and MariaDB,
46
+ where InnoDB would lock every listed row before checking the scope; there a
47
+ patch of one visible row runs its UPDATE without a locking read first).
48
+ - **Fewer reads before single writes:** on table and AnyAPI storage, `patch`
49
+ and `delete` read their row once, scoped, before the UPDATE or DELETE, and a
50
+ `put` that replaces a row reads it twice (whether it exists, then scoped)
51
+ instead of three times. The unscoped read that checked the row still existed
52
+ is gone; the write's own row count answers a row that vanished in between
53
+ (`not_found`, as before).
54
+ - **Single writes keep to their owner:** on table and AnyAPI storage, the
55
+ UPDATE or DELETE of a single `patch`, `put` or `delete` carries the
56
+ ownership (autofilter) conditions its scoped read found, as many-row writes
57
+ already did. A row that another writer moved to a different owner after the
58
+ read answers `not_found` instead of being written. On AnyAPI storage a row
59
+ that vanished after the read now answers `not_found` too, where the write
60
+ used to change nothing and report success.
61
+ - **Links to hidden targets survive a write-back:** a `patch`, `put`,
62
+ many-row patch or merge upsert that sets a belongs-to to null keeps the
63
+ stored link when its target exists but is hidden from the writer, as the read
64
+ showed the link as unassigned. It used to clear it. Clear such links in a
65
+ system context.
66
+ - **Polymorphic links are set through the relationship only:** the type and id
67
+ fields behind a polymorphic belongs-to (`owner_type`, `owner_id`) are refused
68
+ in attributes, as foreign keys already were (rule
69
+ `foreign_key_in_attributes`), so a write cannot skip the target check or half
70
+ clear a link. Send `owner: { _type, id }` (plain) or `relationships.owner`
71
+ (JSON:API) instead.
72
+ - **Selection writes:** `patchWhere` and `deleteWhere` carry the scope and the
73
+ filters in every UPDATE or DELETE and return the rows they wrote; a row that
74
+ vanishes or stops matching in the meantime is left out, never an error.
75
+ - **Counts:** `returning: 'count'` on every many-row write returns
76
+ `{ meta: { count } }`, the number of rows written, without reading rows back.
77
+ - **Upserts on the id:** on a resource with no ownership fields and no row
78
+ policy, `onConflict: { keys: ['id'], action }` (or the `idProperty`, such as
79
+ an email natural key) creates the rows that do not exist yet, atomically.
80
+ - **Integer fields over BIGINT columns read as numbers:** an `integer` or
81
+ `number` field whose driver returns a string (PostgreSQL's BIGINT, MySQL's
82
+ BIGINT past 2^53) reads as a number when a JavaScript number holds it exactly,
83
+ on every read and write response, as aggregates already did. It used to stay
84
+ a string, such as `"4400"`. A longer integer, or a fraction a number cannot
85
+ hold exactly, keeps its digits as a string, where an aggregate used to round
86
+ it; decimal fields (`precision` and `scale`) keep their strings.
87
+ - **Numbered pages without counts:** with `enablePaginationCounts: false` an
88
+ offset page reads one extra row and reports `hasMore` and a `next` link,
89
+ still without a count query.
90
+
91
+ Scoped reads, row locks, lock versions, shared rows and system contexts are
92
+ described in [scoped reads, locks and system work](34-scoped-reads-and-locks.md).
93
+
94
+ ## Many-row writes replace the bulk methods
95
+
96
+ `bulkPost`, `bulkPatch` and `bulkDelete` are removed. Their replacements run
97
+ the per-row checks in memory and one INSERT, UPDATE or DELETE per chunk.
98
+
99
+ | 2.x | 3.0 |
100
+ | --- | --- |
101
+ | `bulkPost({ data: [...] })` | `postMany({ data: [...] })` |
102
+ | `bulkPost({ document: { data: [...] } })` | `postMany({ document: { data: [...] } })` |
103
+ | `bulkPatch({ operations: [{ id, data: { ... } }] })` | `patchMany({ data: [{ id, ... }] })` |
104
+ | `bulkPatch({ operations: [{ id, document: { data } }] })` | `patchMany({ document: { data: [{ type, id, attributes }] } })` |
105
+ | the same change for many ids | `patchWhereIds({ ids, data })` or `patchWhere({ filters, data })` |
106
+ | `bulkDelete({ ids })` | `deleteMany({ ids })` or `deleteWhere({ filters })` |
107
+ | the same change for, or deletion of, every row | `patchWhere({ all: true, data })`, `deleteWhere({ all: true })`: without `filters`, `all: true` is required |
108
+ | `atomic: true` | remove: every call is all-or-nothing |
109
+ | `atomic: false` | remove; for rows that must succeed independently, call single writes in a loop |
110
+ | `expectedVersion`, `expectedVersions` | removed: resources with a `versionField` refuse many-row writes. Use single writes with `expectedVersion`, or a `lockVersionField` with `onlyIf` |
111
+ | result `{ meta: { total, succeeded, failed, atomic }, errors, data }` | nothing by default; `returning: 'minimal'` gives `{ data: [{ type, id }] }`, `'full'` gives `{ data: [records] }`, `'count'` gives `{ meta: { count } }` |
112
+ | `errors[i].index` for a failed entry | the call throws; a validation error names the row in `details.index`, and its field paths start with `data.<index>.` |
113
+ | `meta.deleted` | `deleteMany({ ids, returning: 'minimal' }).data` |
114
+ | default `returning` of the resource | `'none'` unless the call says otherwise |
115
+
116
+ ```js
117
+ // Before (2.x)
118
+ await api.resources.books.bulkPatch({
119
+ operations: rows.map(row => ({ id: row.id, data: { title: row.title } })),
120
+ atomic: true,
121
+ format: 'plain'
122
+ })
123
+
124
+ // After (3.0)
125
+ await api.resources.books.patchMany({
126
+ data: rows.map(row => ({ id: row.id, title: row.title })),
127
+ format: 'plain'
128
+ })
129
+ ```
130
+
131
+ Ids that are missing or hidden from the caller now fail the whole call with
132
+ `RestApiRecordsNotFoundError` (404) before anything is written; pass
133
+ `missing: 'skip'` to ignore them instead.
134
+
135
+ ### Behaviour that bulk writes had and many-row writes refuse
136
+
137
+ The 2.x methods ran the full single-record pipeline for every entry, so they
138
+ accepted anything a single write did. Many-row writes throw
139
+ `RestApiBulkWriteUnsupportedError` (`REST_API_BULK_WRITE_UNSUPPORTED`, 422)
140
+ before writing in these cases; keep single writes for them:
141
+
142
+ | Case | Single-write alternative |
143
+ | --- | --- |
144
+ | AnyAPI storage | single writes |
145
+ | a resource with a `versionField`, or whose writes advance another resource's version | single writes with `expectedVersion` |
146
+ | rows that set many-to-many or has-many relationships | single writes or the relationship methods |
147
+ | rows that set file fields | single writes with uploads |
148
+ | creating positioned records, or moving them | single writes |
149
+ | per-record write hooks without a batch form | add `{ batch: true }` and a batch hook (below) |
150
+
151
+ ### Hooks
152
+
153
+ Many-row writes do not run the per-record write hooks (`beforeProcessing`,
154
+ `beforeSchemaValidate*`, `beforeDataCall*`, `afterDataCall*`, `finish*` for
155
+ post, patch and delete). If a resource has such a handler, its many-row writes
156
+ are refused, naming the handler. For each one, either:
157
+
158
+ - register it with `{ batch: true }` and add the matching batch hook
159
+ (`beforeProcessingPostMany`, `beforeDataCallPostMany`,
160
+ `afterDataCallPostMany`, `beforeProcessingPatchMany`,
161
+ `beforeDataCallPatchMany`, `afterDataCallPatchMany`,
162
+ `beforeDataCallDeleteMany`, `afterDataCallDeleteMany`); or
163
+ - register it with `{ batch: true }` alone when it does not apply to many-row
164
+ writes.
165
+
166
+ ```js
167
+ // Before (2.x): ran once for every bulk entry
168
+ api.addHook('afterDataCallPost', 'audit', {}, auditOne)
169
+
170
+ // After (3.0)
171
+ api.addHook('afterDataCallPost', 'audit', { batch: true }, auditOne)
172
+ api.addHook('afterDataCallPostMany', 'audit-many', {}, ({ context }) => auditMany(context.ids))
173
+ ```
174
+
175
+ `context.bulkOperation` is gone. `checkPermissions` still runs once per row, with
176
+ the row's position in `originalContext.bulkIndex`. `afterCommit` and
177
+ `afterRollback` run once per call. The built-in autofilter, Socket.IO and query
178
+ projection plugins already have batch forms.
179
+
180
+ ### BulkOperationsPlugin and the HTTP routes
181
+
182
+ The plugin now only adds the HTTP routes; programs call the resource methods.
183
+
184
+ | 2.x | 3.0 |
185
+ | --- | --- |
186
+ | `{ maxBulkOperations, defaultAtomic }` | `{ maxBulkOperations }`; `defaultAtomic` is rejected |
187
+ | `maxBulkOperations` default 100 | default 1,000 |
188
+ | `POST /bulk` body `{ data: [...] }` | unchanged; runs `postMany` |
189
+ | `PATCH /bulk` body `{ operations: [{ id, data }] }` | `{ data: [{ type, id, attributes }] }`; runs `patchMany` |
190
+ | `DELETE /bulk` body `{ ids: [...] }` | `{ data: [{ type, id }] }`; runs `deleteMany` |
191
+ | `?atomic=true` / `?atomic=false` | removed; any query parameter is a 422 |
192
+ | `expectedVersions` in the body | removed; versioned resources answer 422 |
193
+ | 200/201 with `{ meta, errors, data }`, even when entries failed | 201 or 200 with `{ data }` when every row was written; otherwise an error response (404, 403 or 422) and nothing is written |
194
+
195
+ ### Types
196
+
197
+ `types/bulk-methods` and its exports (`BulkResourceMethods`, `BulkPostInput`,
198
+ `BulkPatchInput`, `BulkWriteResult`, `BulkDeleteResult`, `BulkMeta`,
199
+ `BulkError`, `BulkTransactionOptions`) are removed. Resource handles carry the
200
+ new methods; their parameter types are exported from the package root
201
+ (`ManyRowWriteOptions`, `ManyRowCreateInput`, `ManyRowPatchInput`,
202
+ `SharedPatchInput`, `UpsertOptions`, `ManyRowResult`, `ScopedQueryParams`,
203
+ `RecordsResult` and others). `BulkOperationsPluginOptions` has only
204
+ `maxBulkOperations`.
205
+
206
+ ## Objects in single-value fields
207
+
208
+ Only `object` and `array` fields store a structure. A write, filter or cursor
209
+ that gives any other field an object or array, as a `none` or `blob` field
210
+ accepted from JSON, now fails with `RestApiValidationError` (rule
211
+ `single_value`) before any SQL runs. In 2.x, MySQL drivers that format queries
212
+ on the client (mysql2 before 3.17, mysql) expanded such an object into
213
+ `column = value` pairs, so a value for one column could set others; PostgreSQL
214
+ stored the JSON text and SQLite refused it. This also ships in 2.4.1.
215
+
216
+ | 2.x | 3.0 |
217
+ | --- | --- |
218
+ | `{ type: 'none' }` holding an object | `{ type: 'object' }`, or a `storage.serialize` function |
219
+ | `{ type: 'blob' }` given JSON | a `Buffer` or `Uint8Array` from server code |
220
+
221
+ ## `get` reads once
222
+
223
+ A `get` on a resource with no `checkPermissions`, `checkDataPermissions`,
224
+ `checkDataPermissionsGet`, `beforeData` or `beforeDataGet` hook (for the
225
+ resource or global) reads its record in one statement, scope and row policy
226
+ included, instead of a minimal read followed by a full read. Not found stays
227
+ `not_found` and still answers before an invalid fieldset. Such a get no longer
228
+ sets `context.minimalRecord`.
229
+
230
+ | 2.x | 3.0 |
231
+ | --- | --- |
232
+ | `enrichRecord`, `finish` or `finishGet` reading `context.minimalRecord` | read `context.record`, or register a `checkPermissions` or `beforeDataGet` hook to keep the minimal read |
233
+
234
+ ## Write failure logs
235
+
236
+ The `Error in METHOD method` entry that a failed write logs at error level now
237
+ carries only the resource, the operation and error codes. In 2.x it also held
238
+ the request's `inputRecord` (hidden fields redacted, every other value kept)
239
+ and the formatted error, whose message and SQL can repeat the values being
240
+ written, so logs collected payment state, ciphertext and other stored secrets.
241
+ The error the call throws is unchanged and still carries its `cause`.
242
+
243
+ | 2.x log field | 3.0 |
244
+ | --- | --- |
245
+ | `inputRecord` | removed; log what you need from the input yourself, in your own code |
246
+ | `error`, `errorSummary` (message, stack, details) | `code`: the error's `code`, or its `name` when it has none |
247
+ | `cleanupErrors: [{ phase, error }]` | `cleanupErrors: [{ phase, code }]` |
248
+ | `method`, `scopeName`, `phase`, `backend`, `transactionOutcome` | unchanged |
249
+
250
+ ## HTTP error answers and logs
251
+
252
+ A 500 answer for an error that is not one of the library's own `RestApi…Error`
253
+ classes, such as a database error or a plain `Error` thrown by a hook, now has
254
+ the detail `An unexpected error occurred`. In 2.x it carried the error's
255
+ message, and a database error's message repeats the SQL with its values (Knex
256
+ puts the statement and its bindings in it), so a duplicate key answered with
257
+ the value that clashed. For a database error, the connector's `HTTP request
258
+ error` or `Fastify request error` log entry now records only its `name` and
259
+ `code`; PostgreSQL's `detail` and MySQL's `sql` repeat the values too.
260
+
261
+ | 2.x | 3.0 |
262
+ | --- | --- |
263
+ | 500 `detail` is the error's message | the message for a `RestApi…Error`; otherwise `An unexpected error occurred` |
264
+ | a hook throws `new Error(message)` to tell the client why | throw a `RestApiResourceError` or `RestApiValidationError`, or call `context.reject` in a transport hook |
265
+ | the HTTP error log entry holds the database error (message, SQL, values) | `{ name, code }` of the database error |
266
+
267
+ ## System contexts and `context.system`
268
+
269
+ The autofilter and row-policy plugins now reserve `context.system`. A context
270
+ with `system: true` that was not made by `createSystemContext` throws
271
+ `REST_API_SYSTEM_CONTEXT_FORGED`. If your application used its own `system`
272
+ flag, rename it; if it used one to skip ownership filters, use
273
+ `createSystemContext(reason)`.
274
+
275
+ ```js
276
+ // Before (2.x): a hand-made flag that each hook had to check
277
+ await api.resources.entries.query({}, { system: true })
278
+
279
+ // After (3.0)
280
+ await api.resources.entries.query({}, createSystemContext('monthly report'))
281
+ ```
282
+
283
+ A system create must set every autofilter field explicitly; NULL creates a
284
+ shared row on a `shared` filter. Row policies receive `system` alongside
285
+ `context`.
286
+
287
+ ## Autofilter `shared`
288
+
289
+ Filter definitions take `shared: true | false` (default false). A value other
290
+ than a boolean, or `shared: true` on a field that is not `nullable`, fails when
291
+ the resource is added. `getScopeConfig` reports `shared` for each filter.
292
+ Outside a system context, shared rows refuse `patch`, `put`, `delete`,
293
+ `postRelationship` and `deleteRelationship`, and a create may not leave the
294
+ field NULL, also when an optional (`required: false`) filter does not resolve.
295
+
296
+ ## Values and links that read or write differently
297
+
298
+ | 2.x | 3.0 |
299
+ | --- | --- |
300
+ | an `integer` field over a PostgreSQL BIGINT column reads `"4400"` | `4400`; a value past 2^53 stays a string |
301
+ | an aggregate group, `min` or `max` of a BIGINT past 2^53 is a rounded number | the exact digits, as a string |
302
+ | a `patch` or `put` of `accountId: null` by a writer who cannot see the account clears the link | keeps it; clear it in a system context |
303
+ | `owner_type` and `owner_id` in attributes set a polymorphic link | refused; send `owner: { _type, id }` or `relationships.owner` |
304
+
305
+ ## Custom storage plugins
306
+
307
+ The storage helpers gain `dataScopedQuery`, `dataVisibleIds`, `dataIdsByKeys`,
308
+ `dataInsertMany`, `dataUpdateByIds`, `dataDeleteByIds`, `dataLockByIds` and
309
+ `dataClaim`. They are optional: a storage plugin without them keeps working,
310
+ and the new methods refuse its resources with
311
+ `RestApiBulkWriteUnsupportedError`. `RestApiKnexPlugin` implements all of them;
312
+ `RestApiAnyapiKnexPlugin` implements only `dataScopedQuery`.
313
+
314
+ ## Verify the port
315
+
316
+ 1. Search for `bulkPost`, `bulkPatch`, `bulkDelete`, `atomic`,
317
+ `expectedVersions`, `defaultAtomic` and `operations:`, and port each call.
318
+ 2. Search for `system: true` in contexts.
319
+ 3. Run the application's tests. A many-row write on a resource with per-record
320
+ hooks fails with `RestApiBulkWriteUnsupportedError`; its `details.hooks`
321
+ names the hooks to give a batch form.
322
+ 4. Search for `patchWhere` and `deleteWhere` calls without `filters`, and add
323
+ `all: true` where every visible row is meant.
324
+ 5. Update HTTP clients of `/RESOURCE/bulk` to the new PATCH and DELETE bodies
325
+ and to error responses for failed batches.
@@ -9,7 +9,8 @@ resources over HTTP. Start with chapters 01–04, then follow the relationship
9
9
  tutorials or jump to the contract you need. Each runnable chapter uses a fresh
10
10
  database unless it explicitly says otherwise.
11
11
 
12
- Upgrading an application? Start with [33. Migrating to v2](33-migrating-to-v2.md).
12
+ Upgrading an application? From 2.x, read [35. Migrating to v3](35-migrating-to-v3.md);
13
+ from 1.x, start with [33. Migrating to v2](33-migrating-to-v2.md).
13
14
  Before choosing storage, read [30. Backend capabilities and limits](30-backend-capabilities.md).
14
15
 
15
16
  The examples require Node.js 24 or newer and familiarity with JavaScript and npm.
@@ -56,7 +57,7 @@ Development verification targets Node 24.
56
57
  - [24. CORS](24-cors.md)
57
58
  - [25. URL management](25-url-management.md)
58
59
  - [26. File uploads](26-file-uploads.md)
59
- - [27. Bulk operations](27-bulk-operations.md)
60
+ - [27. Many-row writes](27-many-row-writes.md)
60
61
  - [28. Socket.IO integration](28-socketio.md)
61
62
  - [29. Writing plugins](29-writing-plugins.md)
62
63
 
@@ -66,3 +67,5 @@ Development verification targets Node 24.
66
67
  - [31. Positioning (experimental)](31-positioning.md)
67
68
  - [32. Date and time](32-date-and-time.md)
68
69
  - [33. Migrating to v2](33-migrating-to-v2.md)
70
+ - [34. Scoped reads, locks and system work](34-scoped-reads-and-locks.md)
71
+ - [35. Migrating to v3](35-migrating-to-v3.md)
@@ -17,8 +17,8 @@ npm pkg set type=module
17
17
  npm install json-rest-api knex better-sqlite3 express
18
18
  ```
19
19
 
20
- These examples use the revised API described in the
21
- [migration guide](GUIDE/33-migrating-to-v2.md). Until that release is published,
20
+ These examples use the v3 API; upgrading applications follow the
21
+ [migration guide](GUIDE/35-migrating-to-v3.md). Until that release is published,
22
22
  install the intended library tarball in place of the registry package.
23
23
 
24
24
  ## Define resources
@@ -70,7 +70,7 @@ application migration.
70
70
  ## Resource stress checks
71
71
 
72
72
  For smaller repeatable measurements, `node scripts/measure-write-lifecycles.js`
73
- records SQL counts and checks child-hook order for bulk and reverse-relationship
73
+ records SQL counts and checks child-hook order for reverse-relationship
74
74
  writes. It supports both storage modes and the database runner.
75
75
  `node scripts/measure-diagnostics.js` reports bounded logger formatting cost;
76
76
  its local timings are observations, not pass/fail performance thresholds.
package/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export * from './types/file-storage.js'
3
3
  export type * from './types/representations.js'
4
4
  export type * from './types/resource-methods.js'
5
5
  export type * from './types/relationship-methods.js'
6
- export type * from './types/bulk-methods.js'
6
+ export type * from './types/data-access-methods.js'
7
7
  export type { ManagedTransaction, ManagedTransactionParam, TransactionMethods } from './types/transactions.js'
8
8
  export type * from './types/plugin-options.js'
9
9
  export * from './types/anyapi.js'
@@ -31,6 +31,13 @@ export const RestApiAnyapiKnexPlugin: LibraryPlugin<'rest-api-anyapi-knex'>
31
31
  export const ExpressPlugin: LibraryPlugin<'express'>
32
32
  export const FastifyPlugin: LibraryPlugin<'fastify'>
33
33
 
34
+ // A context for population-wide work: ownership filters are skipped, row policies get system: true.
35
+ export function createSystemContext<Values extends Record<string, unknown> = Record<string, never>>(
36
+ reason: string, values?: Values
37
+ ): Values & { system: true; systemReason: string }
38
+ export function isSystemContext(context: unknown): boolean
39
+ export function readOnlyQueryBuilder<Builder extends import('knex').Knex.QueryBuilder>(builder: Builder, options?: { resourceType?: string }): Builder
40
+
34
41
  export function getUrlPrefix(
35
42
  context?: { urlPrefixOverride?: string; urlPrefix?: string } | null,
36
43
  scope?: { vars?: { transport?: { publicBaseUrl?: string; mountPath?: string } } } | null
package/index.js CHANGED
@@ -25,4 +25,8 @@ export { LocalStorage } from './plugins/storage/local-storage.js'
25
25
 
26
26
  export * from './lib/rest-api-errors.js'
27
27
 
28
+ // Population-wide work (jobs) and the SELECT-only builder of scopedQuery
29
+ export { createSystemContext, isSystemContext } from './lib/system-context.js'
30
+ export { readOnlyQueryBuilder } from './plugins/core/lib/querying/read-only-query.js'
31
+
28
32
  export { getUrlPrefix } from './plugins/core/lib/querying/url-helpers.js'
@@ -255,3 +255,82 @@ export class RestApiTemporalDataError extends RestApiError {
255
255
  }
256
256
  }
257
257
  }
258
+
259
+ /**
260
+ * A many-row operation cannot run on this resource as one statement per chunk.
261
+ * It never falls back to a per-row loop; the reason names what to change.
262
+ */
263
+ export class RestApiBulkWriteUnsupportedError extends RestApiError {
264
+ constructor ({ resourceType, operation, reason, hooks } = {}) {
265
+ super(`${operation} on '${resourceType}' is not supported: ${reason}`, 'REST_API_BULK_WRITE_UNSUPPORTED')
266
+ this.type = 'rest_api_bulk_write_unsupported'
267
+ this.statusCode = 422
268
+ this.details = { resourceType, operation, reason, ...(hooks ? { hooks } : {}) }
269
+ }
270
+
271
+ toJSON () {
272
+ return { name: this.name, message: this.message, code: this.code, type: this.type, statusCode: this.statusCode, details: this.details, stack: this.stack }
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Requested records are missing or outside the caller's scope. Only the count
278
+ * is reported, so the error does not reveal which identifiers exist elsewhere.
279
+ */
280
+ export class RestApiRecordsNotFoundError extends RestApiResourceError {
281
+ constructor ({ resourceType, missing } = {}) {
282
+ super(`${missing} requested ${resourceType} record(s) not found`, { subtype: 'not_found', resourceType })
283
+ this.details = { resourceType, missing }
284
+ }
285
+ }
286
+
287
+ /** A MySQL multi-row insert could not prove which identifiers it generated; the transaction rolls back and the call is safe to retry. */
288
+ export class RestApiBulkInsertIdsError extends RestApiError {
289
+ constructor ({ resourceType, expected, verified } = {}) {
290
+ super(`Could not verify the identifiers of ${expected} inserted ${resourceType} record(s)`, 'REST_API_BULK_INSERT_IDS')
291
+ this.type = 'rest_api_bulk_insert_ids'
292
+ this.details = { resourceType, expected, verified }
293
+ }
294
+
295
+ toJSON () {
296
+ return { name: this.name, message: this.message, code: this.code, type: this.type, details: this.details, stack: this.stack }
297
+ }
298
+ }
299
+
300
+ /** Row locks only last as long as a transaction; a lock taken outside one would end immediately. */
301
+ export class RestApiLockOutsideTransactionError extends RestApiValidationError {
302
+ constructor ({ resourceType, operation } = {}) {
303
+ super(`${operation} on '${resourceType}' needs a transaction from api.transaction`, {
304
+ fields: ['transaction'],
305
+ violations: [{ field: 'transaction', rule: 'lock_transaction', message: 'Row locks need an active transaction' }]
306
+ })
307
+ this.type = 'rest_api_lock_outside_transaction'
308
+ }
309
+ }
310
+
311
+ /** A scoped query is SELECT-only; writes and locks go through the resource's write and lock methods. */
312
+ export class RestApiScopedQueryReadOnlyError extends RestApiError {
313
+ constructor ({ resourceType, method } = {}) {
314
+ super(`The scoped query of '${resourceType}' is read-only: ${method}() is not allowed`, 'REST_API_SCOPED_QUERY_READ_ONLY')
315
+ this.type = 'rest_api_scoped_query_read_only'
316
+ this.details = { resourceType, method }
317
+ }
318
+
319
+ toJSON () {
320
+ return { name: this.name, message: this.message, code: this.code, type: this.type, details: this.details, stack: this.stack }
321
+ }
322
+ }
323
+
324
+ /** queryAll stopped at the caller's rowLimit instead of reading an unbounded set. */
325
+ export class RestApiRowLimitError extends RestApiError {
326
+ constructor ({ resourceType, rowLimit, operation = 'queryAll' } = {}) {
327
+ const advice = operation === 'queryAll' ? 'read them in a background job or narrow the filters' : 'narrow the filters'
328
+ super(`${operation} on '${resourceType}' matched more than ${rowLimit} rows; ${advice}`, 'REST_API_ROW_LIMIT')
329
+ this.type = 'rest_api_row_limit'
330
+ this.details = { resourceType, rowLimit, operation }
331
+ }
332
+
333
+ toJSON () {
334
+ return { name: this.name, message: this.message, code: this.code, type: this.type, details: this.details, stack: this.stack }
335
+ }
336
+ }
@@ -1,12 +1,15 @@
1
1
  export class Hooks {
2
2
  #events = new Map()
3
3
 
4
+ // batch: true marks a per-record handler whose work many-row operations do
5
+ // through a batch hook (or that does not apply to them), so they may skip it.
4
6
  add (event, name, options, handler, resource) {
5
7
  if (typeof handler !== 'function') throw new TypeError(`Hook '${name}' needs a function`)
6
- const { beforeFunction, afterFunction, ...unsupported } = options
8
+ const { beforeFunction, afterFunction, batch = false, ...unsupported } = options
7
9
  if (Object.keys(unsupported).length || (beforeFunction && afterFunction)) {
8
10
  throw new TypeError(`Hook '${name}' requires at most one beforeFunction or afterFunction`)
9
11
  }
12
+ if (typeof batch !== 'boolean') throw new TypeError(`Hook '${name}' option batch must be a boolean`)
10
13
  const handlers = this.#events.get(event) || []
11
14
  const anchor = beforeFunction || afterFunction
12
15
  let index = handlers.length
@@ -15,7 +18,7 @@ export class Hooks {
15
18
  if (index < 0) throw new Error(`Hook '${event}' has no handler '${anchor}'`)
16
19
  if (afterFunction) index++
17
20
  }
18
- handlers.splice(index, 0, { name, handler, resource })
21
+ handlers.splice(index, 0, { name, handler, resource, batch })
19
22
  this.#events.set(event, handlers)
20
23
  }
21
24
 
@@ -24,6 +27,17 @@ export class Hooks {
24
27
  return (this.#events.get(event) || []).some(entry => !entry.resource || entry.resource === resource)
25
28
  }
26
29
 
30
+ // Handlers of these events that would run for this resource and have no batch form.
31
+ perRecord (events, resource) {
32
+ const found = []
33
+ for (const event of events) {
34
+ for (const entry of this.#events.get(event) || []) {
35
+ if (!entry.batch && (!entry.resource || entry.resource === resource)) found.push({ event, name: entry.name })
36
+ }
37
+ }
38
+ return found
39
+ }
40
+
27
41
  async run (event, args) {
28
42
  for (const entry of [...(this.#events.get(event) || [])]) {
29
43
  if (entry.resource && entry.resource !== args.scope) continue
@@ -87,7 +101,8 @@ export class JsonRestApi {
87
101
  helpers: resource?.helpers || this.helpers,
88
102
  log: this.log,
89
103
  runHooks: (event, nextContext = context) => this.#hooks.run(event, this.#arguments(resource, {}, nextContext)),
90
- hasHooks: (event, scope = resource) => this.#hooks.has(event, scope)
104
+ hasHooks: (event, scope = resource) => this.#hooks.has(event, scope),
105
+ perRecordHooks: (events, scope = resource) => this.#hooks.perRecord(events, scope)
91
106
  }
92
107
  }
93
108
 
@@ -0,0 +1,42 @@
1
+ // @ts-check
2
+
3
+ // Module-private: only createSystemContext can attach the marker. Object spread
4
+ // copies it, so contexts the library derives from a system context stay system.
5
+ const systemMarker = Symbol('jsonRestApiSystemContext')
6
+ const marker = Object.freeze({})
7
+
8
+ /**
9
+ * A context for population-wide work, such as background jobs. Ownership
10
+ * predicates (autofilter) are skipped for it, row policies receive
11
+ * `system: true`, and creates must name their ownership values explicitly.
12
+ * Request input cannot produce one: the marker is a private symbol.
13
+ * @param {string} reason Why this work may see every owner's rows; shown in diagnostics
14
+ * @param {Record<string, unknown>} [values] Extra context fields for hooks and policies
15
+ */
16
+ export function createSystemContext (reason, values = {}) {
17
+ if (typeof reason !== 'string' || !reason.trim()) {
18
+ throw new TypeError('createSystemContext needs a reason: a non-empty string naming the work')
19
+ }
20
+ if (values === null || typeof values !== 'object' || Array.isArray(values)) {
21
+ throw new TypeError('createSystemContext values must be an object')
22
+ }
23
+ return { ...values, system: true, systemReason: reason, [systemMarker]: marker }
24
+ }
25
+
26
+ /** @param {unknown} context */
27
+ export function isSystemContext (context) {
28
+ return context !== null && typeof context === 'object' && /** @type {Record<symbol, unknown>} */ (context)[systemMarker] === marker
29
+ }
30
+
31
+ /**
32
+ * A context claiming `system: true` without the marker was not made by
33
+ * createSystemContext; trusting it would let a copied flag skip ownership.
34
+ * @param {unknown} context
35
+ */
36
+ export function assertGenuineSystemFlag (context) {
37
+ if (context !== null && typeof context === 'object' && /** @type {{ system?: unknown }} */ (context).system === true && !isSystemContext(context)) {
38
+ const error = new Error('context.system is set, but the context was not created by createSystemContext')
39
+ Object.assign(error, { code: 'REST_API_SYSTEM_CONTEXT_FORGED' })
40
+ throw error
41
+ }
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "json-rest-api",
3
- "version": "2.4.0",
3
+ "version": "3.0.0",
4
4
  "main": "index.js",
5
5
  "types": "index.d.ts",
6
6
  "type": "module",
@@ -83,6 +83,7 @@
83
83
  "test:databases:sqlite": "node scripts/test-databases.js better-sqlite3",
84
84
  "test:databases:pg": "node scripts/test-databases.js pg",
85
85
  "test:databases:mysql": "node scripts/test-databases.js mysql2",
86
+ "test:databases:mariadb": "node scripts/test-databases.js mariadb",
86
87
  "typecheck": "tsc --project tsconfig.json"
87
88
  },
88
89
  "repository": {
@@ -141,7 +142,7 @@
141
142
  "devDependencies": {
142
143
  "@socket.io/redis-adapter": "^8.3.0",
143
144
  "@types/node": "24.0.14",
144
- "better-sqlite3": "^11.10.0",
145
+ "better-sqlite3": "^13.0.3",
145
146
  "busboy": "^1.6.0",
146
147
  "eslint": "^9.37.0",
147
148
  "express": "^5.1.0",