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
|
@@ -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.
|
package/docs/GUIDE/index.md
CHANGED
|
@@ -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?
|
|
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.
|
|
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)
|
package/docs/QUICKSTART.md
CHANGED
|
@@ -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
|
|
21
|
-
[migration guide](GUIDE/
|
|
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
|
package/docs/contributing.md
CHANGED
|
@@ -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
|
|
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/
|
|
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'
|
package/lib/rest-api-errors.js
CHANGED
|
@@ -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": "
|
|
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": "^
|
|
145
|
+
"better-sqlite3": "^13.0.3",
|
|
145
146
|
"busboy": "^1.6.0",
|
|
146
147
|
"eslint": "^9.37.0",
|
|
147
148
|
"express": "^5.1.0",
|