fastify-auto-sqlapi 0.2.4 → 0.3.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/AGENTS_BACKEND.md +65 -5
- package/AGENTS_FRONTEND.md +1 -1
- package/BREAKING_CHANGES.md +52 -0
- package/CHANGELOG.md +77 -0
- package/dist/lib/schema/helpers.d.ts +7 -0
- package/dist/lib/schema/helpers.d.ts.map +1 -1
- package/dist/lib/schema/helpers.js +18 -0
- package/dist/lib/schema/insert.d.ts.map +1 -1
- package/dist/lib/schema/insert.js +3 -1
- package/dist/lib/tenant.d.ts +6 -0
- package/dist/lib/tenant.d.ts.map +1 -1
- package/dist/lib/tenant.js +16 -0
- package/dist/routes/auto/bulk-delete.routes.js +1 -1
- package/dist/routes/auto/bulk-upsert.routes.js +1 -1
- package/dist/routes/auto/delete.routes.js +1 -1
- package/dist/routes/auto/get.routes.js +1 -1
- package/dist/routes/auto/insert.routes.js +1 -1
- package/dist/routes/auto/route-helpers.d.ts +5 -2
- package/dist/routes/auto/route-helpers.d.ts.map +1 -1
- package/dist/routes/auto/route-helpers.js +1 -1
- package/dist/routes/auto/search.routes.js +3 -3
- package/dist/routes/auto/update.routes.js +1 -1
- package/docs/adr/0020-no-conditional-or-mass-update-route.md +68 -0
- package/docs/adr/README.md +1 -0
- package/package.json +4 -4
package/AGENTS_BACKEND.md
CHANGED
|
@@ -291,7 +291,18 @@ export const TableCustomer = defineTable({
|
|
|
291
291
|
|
|
292
292
|
`exportTableInfo(Schema)` returns `{ Schema, filters, extraFilters }`. The `filters` function auto-builds WHERE conditions from any schema field present in the request.
|
|
293
293
|
|
|
294
|
-
**Important**: there is no `tableName` key in `defineTable()`. The SQL table name comes from `Schema.tableName` (set by the CLI). The route URL name is the key you use in the `dbTables` record (e.g. `{ customer: TableCustomer }` → `/search/customer`).
|
|
294
|
+
**Important**: there is no `tableName` key in `defineTable()`. The SQL table name comes from `Schema.tableName` (set by the CLI). The route URL name is the key you use in the `dbTables` record (e.g. `{ customer: TableCustomer }` → `/search/customer`). The key also names the table in search responses (`table`) and in the agent manifest.
|
|
295
|
+
|
|
296
|
+
Because routes follow the key, the same physical table can be exposed by several table configs, each with its own `operations`, `onRequests`, `tenantScope`, `readExclude` and joins:
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
export const dbTables: DbTables = {
|
|
300
|
+
conversation: TableConversation, // owner: list, rename, delete
|
|
301
|
+
sharedConversation: TableSharedConversation, // anyone with the link: search only, shared rows only
|
|
302
|
+
conversationHistory: TableConversationHistory, // server-side only: operations: []
|
|
303
|
+
};
|
|
304
|
+
// → /search/conversation, /rest/conversation, /search/sharedConversation — all SQL on "conversation"
|
|
305
|
+
```
|
|
295
306
|
|
|
296
307
|
### Composite primary key (link/junction tables)
|
|
297
308
|
|
|
@@ -869,7 +880,7 @@ const TableCustomer = defineTable({
|
|
|
869
880
|
|
|
870
881
|
Behavior:
|
|
871
882
|
- **Read** (search, get, delete, bulk-delete): adds `AND organization_id IN ($N)` to WHERE
|
|
872
|
-
- **Insert**: auto-injects `organization_id` if single tenant; validates if already present (403 if mismatch); 400 if multi-tenant without explicit value
|
|
883
|
+
- **Insert**: auto-injects `organization_id` if single tenant; validates if already present (403 if mismatch); 400 if multi-tenant without explicit value. The insert body (main and secondaries items) offers the column as **optional**, whatever the generated schema says: a single-tenant client omits it, a multi-tenant client names it. An admin (`getTenantId` → `null`) who omits it reaches the database's own `NOT NULL`, like any other column.
|
|
873
884
|
- **Update**: strips `organization_id` from SET (can't change tenant), adds to WHERE condition
|
|
874
885
|
- **Bulk upsert**: auto-injects on all records
|
|
875
886
|
- **Upsert conflict guard** (`upsertMap` set): before the `ON CONFLICT DO UPDATE`, a probe rejects (403) any incoming conflict key that matches a row owned by another tenant — an upsert cannot overwrite or re-assign a foreign tenant's row.
|
|
@@ -922,7 +933,8 @@ Behavior:
|
|
|
922
933
|
- **Insert**: no auto-injection. The payload must **anchor** the row: at least one listed column
|
|
923
934
|
present and holding a tenant id → `400` if none is present, `403` if present but all foreign.
|
|
924
935
|
The other parties are stored exactly as sent. A multi-id caller is never ambiguous here,
|
|
925
|
-
because nothing is being guessed.
|
|
936
|
+
because nothing is being guessed. Every listed column is **optional** in the insert body, so
|
|
937
|
+
the anchor rule is the runtime's to enforce, not the schema's.
|
|
926
938
|
- **Update**: strips **every** listed column from the `SET` — neither party can be re-assigned —
|
|
927
939
|
and adds the OR predicate to the `WHERE`. A row outside the scope answers `404`.
|
|
928
940
|
- **Bulk upsert**: the anchor rule applies to every item (so an upsert must name a party even
|
|
@@ -948,9 +960,14 @@ getTenantId: (request) => {
|
|
|
948
960
|
|
|
949
961
|
Uses `IN ($N, $M, ...)` for all WHERE clauses (same as single tenant, just with more values).
|
|
950
962
|
|
|
963
|
+
An **empty array** is a caller who belongs to no tenant, not an admin: every request that
|
|
964
|
+
reaches a scoped table (directly or through a relation) is refused with `403` before any SQL
|
|
965
|
+
runs. `0` and `''` are ordinary tenant ids and filter like any other value.
|
|
966
|
+
|
|
951
967
|
### Admin bypass
|
|
952
968
|
|
|
953
|
-
When `getTenantId` returns `null` or `undefined`, no filtering is applied (full access).
|
|
969
|
+
When `getTenantId` returns `null` or `undefined`, no filtering is applied (full access). Only
|
|
970
|
+
these two values bypass; `[]`, `0` and `''` do not.
|
|
954
971
|
|
|
955
972
|
### Tables without tenant
|
|
956
973
|
|
|
@@ -958,7 +975,8 @@ Tables without `tenantScope` are unaffected — no filtering regardless of `getT
|
|
|
958
975
|
|
|
959
976
|
### Error codes
|
|
960
977
|
|
|
961
|
-
- **403** — Record doesn't belong to tenant,
|
|
978
|
+
- **403** — Record doesn't belong to tenant, explicit tenant value doesn't match, or
|
|
979
|
+
`getTenantId` returned an empty array (a caller with no tenant at all)
|
|
962
980
|
- **400** — Multi-tenant user on insert without explicit tenant value (ambiguous), or an
|
|
963
981
|
`anyOf` write naming no owner column at all
|
|
964
982
|
- **404** — Update with indirect tenant, record not found for this tenant; get/update/delete of
|
|
@@ -1214,6 +1232,48 @@ const TableUser = defineTable({
|
|
|
1214
1232
|
For privileged transitions (promote to admin, move across tenants) prefer a dedicated
|
|
1215
1233
|
endpoint with its own auth/audit, and keep the operation off the auto routes via `operations`.
|
|
1216
1234
|
|
|
1235
|
+
### Conditional update — claim a row atomically
|
|
1236
|
+
|
|
1237
|
+
The `update` route matches the primary key (plus the tenant scope) and nothing else, so it
|
|
1238
|
+
cannot express "update only if the row is still in the state I saw": claiming a row that two
|
|
1239
|
+
clients must not both claim, a guarded state transition, optimistic locking on a `version`
|
|
1240
|
+
column. There is deliberately no auto route for that, nor for updating every row matching a
|
|
1241
|
+
filter ([ADR 0020](./docs/adr/0020-no-conditional-or-mass-update-route.md)). Write it as a
|
|
1242
|
+
custom route: the check and the write must be **one** `UPDATE`, which `db.update()` does with
|
|
1243
|
+
an `extraCondition`, returning how many rows it changed.
|
|
1244
|
+
|
|
1245
|
+
```typescript
|
|
1246
|
+
import { ConditionBuilder, createQueryClient, pgQueryable, Type } from 'fastify-auto-sqlapi';
|
|
1247
|
+
import { SchemaRequest } from './schemas/SchemaRequest';
|
|
1248
|
+
|
|
1249
|
+
const db = createQueryClient(pgQueryable(fastify.pg), 'postgres');
|
|
1250
|
+
|
|
1251
|
+
fastify.post('/requests/:id/claim', {
|
|
1252
|
+
schema: { params: Type.Object({ id: Type.Integer() }) },
|
|
1253
|
+
}, async (req, reply) => {
|
|
1254
|
+
const { id } = req.params as { id: number };
|
|
1255
|
+
const col = (f: string) => SchemaRequest.col(f); // real column names: QueryClient speaks the DB's
|
|
1256
|
+
|
|
1257
|
+
const guard = new ConditionBuilder('AND', db.cbDialect)
|
|
1258
|
+
.isNull(db.qi(col('inProgressSince')), true) // the precondition — second arg enables it
|
|
1259
|
+
.isEqual(db.qi(col('organizationId')), req.user.orgId); // tenant: QueryClient applies none for you
|
|
1260
|
+
// Record/where keys are quoted by db.update(); ConditionBuilder operands are raw SQL, so qi() them.
|
|
1261
|
+
|
|
1262
|
+
const affected = await db.update(
|
|
1263
|
+
SchemaRequest.tableName,
|
|
1264
|
+
{ [col('inProgressSince')]: new Date(), [col('inProgressBy')]: req.user.id },
|
|
1265
|
+
{ [col('id')]: id },
|
|
1266
|
+
guard
|
|
1267
|
+
);
|
|
1268
|
+
if (affected === 0) return reply.code(409).send({ message: 'Already claimed, or not found' });
|
|
1269
|
+
return { claimed: true };
|
|
1270
|
+
});
|
|
1271
|
+
```
|
|
1272
|
+
|
|
1273
|
+
`QueryClient` runs no hooks, no validation and **no tenant scoping**: the route owns all three.
|
|
1274
|
+
Keep the predicate server-chosen, as above. Letting the client name the column it compares on
|
|
1275
|
+
turns `affected` into an oracle on values it cannot read.
|
|
1276
|
+
|
|
1217
1277
|
### Encrypt a column at rest (storage representation ≠ API representation)
|
|
1218
1278
|
|
|
1219
1279
|
Three declarations on the table that owns the column — in on the write hooks, out on the read
|
package/AGENTS_FRONTEND.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
This file is the request GRAMMAR. For this deployment's tables/fields/aliases (the
|
|
4
4
|
vocabulary), fetch `GET {p}/agent/manifest.md` if available, or consult Swagger.
|
|
5
5
|
|
|
6
|
-
Rules: all request/response fields are camelCase schema field names — how the database names its columns is invisible here and irrelevant, the server maps them. `{p}` = deployment prefix (e.g. `/api`). Join keys are backend-declared ALIASES (default = joined table name; list per table in Swagger). Write responses are PK-only. Write bodies reject unknown properties (400) — send only real schema fields. Arrays for bulk are capped (default 1000/request; chunk client-side). Composite-PK tables have no get/delete/bulk-delete (404) — use search / update.
|
|
6
|
+
Rules: all request/response fields are camelCase schema field names — how the database names its columns is invisible here and irrelevant, the server maps them. `{p}` = deployment prefix (e.g. `/api`). `{table}` = the table's key in the manifest, which may differ from the database table name (one table can be exposed under several keys, each with its own rules). Join keys are backend-declared ALIASES (default = joined table name; list per table in Swagger). Write responses are PK-only. Write bodies reject unknown properties (400) — send only real schema fields. Arrays for bulk are capped (default 1000/request; chunk client-side). Composite-PK tables have no get/delete/bulk-delete (404) — use search / update.
|
|
7
7
|
|
|
8
8
|
## Endpoints
|
|
9
9
|
|
package/BREAKING_CHANGES.md
CHANGED
|
@@ -692,3 +692,55 @@ beforeInsert: async (db, req, record) => {
|
|
|
692
692
|
if (req.routeOptions?.url?.startsWith('/rest/')) await notify(record); // side effect: narrow it
|
|
693
693
|
},
|
|
694
694
|
```
|
|
695
|
+
|
|
696
|
+
---
|
|
697
|
+
|
|
698
|
+
# Breaking Change — the route URL is the `DbTables` key
|
|
699
|
+
|
|
700
|
+
*Landed in **0.3.0**.*
|
|
701
|
+
|
|
702
|
+
## Why this changed
|
|
703
|
+
|
|
704
|
+
The documentation always said *"the route URL name is the key you use in the `dbTables`
|
|
705
|
+
record"*, and everything but the URL agreed: the handler, `operations`, `onRequests`, the
|
|
706
|
+
Swagger tags and the agent manifest all resolved the table config by its key. The URL alone was
|
|
707
|
+
built from `Schema.tableName`. Two consequences:
|
|
708
|
+
|
|
709
|
+
- **Several table configs over one physical table could not coexist.** Exposing
|
|
710
|
+
`conversation` once for its owner and once for whoever holds a share link — each key with its
|
|
711
|
+
own `operations`, guards and joins — put both on `/search/conversation`, and Fastify refused
|
|
712
|
+
to start: `Method 'POST' already declared for route '/search/conversation'`.
|
|
713
|
+
- **A renamed key was served under the wrong name.** `{ orders: TableCustomerOrder }` started
|
|
714
|
+
fine, but answered on `/search/customer_order` while the manifest told agent clients to call
|
|
715
|
+
`orders`.
|
|
716
|
+
|
|
717
|
+
## What changed
|
|
718
|
+
|
|
719
|
+
- Every auto route is named after the key: `/search/<key>`, `/rest/<key>`, `/rest/<key>/:id`,
|
|
720
|
+
`/bulk/<key>`, `/bulk/<key>/delete`.
|
|
721
|
+
- The search response's `table` is the key, so it matches the URL that was called and the
|
|
722
|
+
manifest entry.
|
|
723
|
+
- SQL is unchanged: it still targets `Schema.tableName`.
|
|
724
|
+
|
|
725
|
+
## Who is affected
|
|
726
|
+
|
|
727
|
+
- **A `DbTables` key of yours differs from its schema's `tableName`.** Its routes move from the
|
|
728
|
+
table name to the key, and `table` in its search responses changes the same way.
|
|
729
|
+
|
|
730
|
+
Not affected: configs generated by `sqlapi-generate-tables`, whose keys are the table names, and
|
|
731
|
+
any hand-written config that kept that convention.
|
|
732
|
+
|
|
733
|
+
## Migration
|
|
734
|
+
|
|
735
|
+
Either call the new URLs, or rename the key back to the table name to keep the old ones:
|
|
736
|
+
|
|
737
|
+
```typescript
|
|
738
|
+
// before: routes answered on /search/customer_order
|
|
739
|
+
export const dbTables: DbTables = { orders: TableCustomerOrder };
|
|
740
|
+
|
|
741
|
+
// keep the old URLs
|
|
742
|
+
export const dbTables: DbTables = { customer_order: TableCustomerOrder };
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
A client that read `table` from a search response to recover the physical table name should
|
|
746
|
+
use the key it called instead — that is now what `table` returns.
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,71 @@ All notable changes to this project are documented here. This project follows
|
|
|
7
7
|
|
|
8
8
|
Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BREAKING_CHANGES.md)**.
|
|
9
9
|
|
|
10
|
+
## [Unreleased]
|
|
11
|
+
|
|
12
|
+
## [0.3.0]
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **BREAKING: the route URL is the `DbTables` key, as documented.** Every auto route built its
|
|
17
|
+
URL from `Schema.tableName` while the handler, `operations`, the Swagger tags and the agent
|
|
18
|
+
manifest all used the key. Two table configs over the same physical table (an owner view and
|
|
19
|
+
a shared-link view of `conversation`) collided on `/search/conversation` and the app refused
|
|
20
|
+
to start; a single renamed key started fine but was served under the table name, so the
|
|
21
|
+
manifest advertised a URL that did not exist. Routes are now `/search/<key>`,
|
|
22
|
+
`/rest/<key>`, `/bulk/<key>`, and the search response's `table` names the key too. Configs
|
|
23
|
+
generated by `sqlapi-generate-tables` use the table name as key and are unaffected. See
|
|
24
|
+
[BREAKING_CHANGES.md](./BREAKING_CHANGES.md#breaking-change--the-route-url-is-the-dbtables-key).
|
|
25
|
+
- **`fastify-plugin` 5 → 6.** The only runtime dependency bump: v6 renames its entry files and
|
|
26
|
+
drops the long-deprecated `PluginOptions` type (now `PluginMetadata`), neither of which this
|
|
27
|
+
library uses. No change for consumers; still Fastify 5.
|
|
28
|
+
- **Development dependencies updated** — `@fastify/swagger-ui` 6 (still covered by the
|
|
29
|
+
`>=5.0.0` peer range), `fastify` pinned to 5.12.4, and the latest minors of the lint and
|
|
30
|
+
gate tooling. TypeScript stays on 5.9: `typescript-eslint` does not support TypeScript 7 yet.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **An empty tenant list is refused, not executed.** `getTenantId` returning `[]` (a caller
|
|
35
|
+
who belongs to no tenant) used to reach the database as `organization_id IN ()` — a syntax
|
|
36
|
+
error on both dialects, so a `500` that failed closed only by accident, and one step away
|
|
37
|
+
from a leak had any builder dropped the empty predicate. `resolveTenant` now answers `403`
|
|
38
|
+
before any SQL exists, on every operation and through relations from an unscoped host.
|
|
39
|
+
`null`/`undefined` remain the only admin values; `0` and `''` remain ordinary tenant ids.
|
|
40
|
+
- **The tenant column is optional in the insert body.** The engine injects it for a
|
|
41
|
+
single-tenant caller and validates it for a multi-tenant or `anyOf` one, yet the body schema
|
|
42
|
+
still required it whenever the generated schema did — so the documented injection answered
|
|
43
|
+
`400 must have required property` over HTTP, and the only way out was a `schemaOverrides`
|
|
44
|
+
workaround (or `excludeFromCreation`, which silently broke multi-tenant callers by stripping
|
|
45
|
+
the value they must send). The own column of a direct scope and every column of an `anyOf`
|
|
46
|
+
scope are now `Optional` on insert `main` and on secondaries items of every write body — the
|
|
47
|
+
same rule that already made a secondary's FK optional. Indirect scopes are untouched: their
|
|
48
|
+
FK is an ordinary field.
|
|
49
|
+
|
|
50
|
+
### Documentation
|
|
51
|
+
|
|
52
|
+
- **[ADR 0020](./docs/adr/0020-no-conditional-or-mass-update-route.md): no conditional or mass
|
|
53
|
+
update route.** A conditional `updateWhere` operation was designed and rejected: it would be
|
|
54
|
+
the first route writing N rows chosen by the client, and it cuts across `QueryClient.update`,
|
|
55
|
+
the search condition builder, the tenant guards and the hook contract. Compare-and-set
|
|
56
|
+
(claiming a row two clients must not both claim) is a custom route on
|
|
57
|
+
`db.update(table, fields, where, extraCondition)`; `AGENTS_BACKEND.md` carries the pattern
|
|
58
|
+
("Conditional update — claim a row atomically").
|
|
59
|
+
|
|
60
|
+
### Internal
|
|
61
|
+
|
|
62
|
+
- **Tenant isolation test audit.** New specs pin what was enforced but unproven: the tenant
|
|
63
|
+
reaches every HTTP route and every `SqlApi` method; `deletions` bind the child to the main
|
|
64
|
+
row FK (an unscoped child under a scoped parent included) and follow indirect and `anyOf`
|
|
65
|
+
child scopes; a secondary cannot be re-parented by a payload FK; upserted secondaries are
|
|
66
|
+
conflict-guarded on update and bulk upsert; a multi-tenant caller cannot write an unanchored
|
|
67
|
+
secondary; direct-scope update and bulk upsert; indirect-scope delete and bulk delete; request
|
|
68
|
+
predicates on the tenant column never widen the scope; an indirect join-table scope through a
|
|
69
|
+
third table is enforced inside `joinMustExist`, `joinGroup` and aggregation subqueries.
|
|
70
|
+
- **`swagger: true` is now tested.** `setupSwagger` swallows a failed import of
|
|
71
|
+
`@fastify/swagger`/`@fastify/swagger-ui` and only logs a warning, so a dependency bump that
|
|
72
|
+
broke the documentation would have passed every test. Unit tests now assert the served
|
|
73
|
+
OpenAPI spec and UI — default path, custom `SwaggerOptions`, and under the plugin `prefix`.
|
|
74
|
+
|
|
10
75
|
## [0.2.4]
|
|
11
76
|
|
|
12
77
|
### Added
|
|
@@ -620,6 +685,18 @@ the join API redesign (documented in [BREAKING_CHANGES.md](./BREAKING_CHANGES.md
|
|
|
620
685
|
primary key support in updates, transactional insert/update with secondaries, the hook matrix,
|
|
621
686
|
and bulk operations.
|
|
622
687
|
|
|
688
|
+
[Unreleased]: https://github.com/LucaRainone/fastify-auto-sqlapi/compare/v0.3.0...HEAD
|
|
689
|
+
[0.3.0]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.3.0
|
|
690
|
+
[0.2.4]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.2.4
|
|
691
|
+
[0.2.3]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.2.3
|
|
692
|
+
[0.2.2]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.2.2
|
|
693
|
+
[0.2.1]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.2.1
|
|
694
|
+
[0.2.0]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.2.0
|
|
695
|
+
[0.1.14]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.14
|
|
696
|
+
[0.1.13]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.13
|
|
697
|
+
[0.1.12]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.12
|
|
698
|
+
[0.1.11]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.11
|
|
699
|
+
[0.1.10]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.10
|
|
623
700
|
[0.1.7]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.7
|
|
624
701
|
[0.1.6]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.6
|
|
625
702
|
[0.1.5]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.5
|
|
@@ -5,6 +5,13 @@ import type { ITable, SchemaDefinition, DbTables, JoinDefinition } from '../../t
|
|
|
5
5
|
* Falls back to `fallback` field if tableConf is undefined.
|
|
6
6
|
*/
|
|
7
7
|
export declare function pkSchema(tableConf: ITable | undefined, schema: SchemaDefinition, fallback: string): Record<string, TSchema>;
|
|
8
|
+
/**
|
|
9
|
+
* Make the tenant column(s) of a scoped table Optional in a write body. The engine injects the
|
|
10
|
+
* value for a single-tenant caller and validates the one a multi-tenant or `anyOf` caller sends,
|
|
11
|
+
* so the body must offer the field without demanding it — the same reason a secondary's FK is
|
|
12
|
+
* Optional. `tenantScope` names real DB columns; the schema field is found through `col()`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function relaxTenantColumns(fields: Record<string, TSchema>, tableConf: ITable | undefined, schema: SchemaDefinition): void;
|
|
8
15
|
/** The same schema without the Optional modifier, i.e. as a required property. */
|
|
9
16
|
export declare function asRequired(schema: TSchema): TSchema;
|
|
10
17
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAMzF;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQ3H;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,gBAAgB,GACvB,IAAI,CAMN;AAoCD,kFAAkF;AAClF,wBAAgB,UAAU,CAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CAGnD;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,SAAS,GAC5B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASzB;AAED,KAAK,iBAAiB,GAAG,CACvB,IAAI,EAAE,cAAc,EACpB,aAAa,EAAE,MAAM,GAAG,SAAS,KAC9B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAwB7B;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE;IAAE,aAAa,EAAE,OAAO,CAAC;IAAC,eAAe,EAAE,iBAAiB,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,CAAA;CAAE,GAC7F,IAAI,CA8BN;AAED,qGAAqG;AACrG,eAAO,MAAM,mBAAmB,EAAE,iBACmB,CAAC;AAEtD,uDAAuD;AACvD,eAAO,MAAM,uBAAuB,EAAE,iBACG,CAAC;AAE1C,4DAA4D;AAC5D,wBAAgB,cAAc,CAC5B,MAAM,EAAE,gBAAgB,EACxB,SAAS,EAAE,MAAM,GAAG,SAAS,GAC5B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAOzB"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Type, OptionalKind } from '@sinclair/typebox';
|
|
2
2
|
import { readableFieldNames } from '../read-access.js';
|
|
3
3
|
import { writableFields } from '../write-access.js';
|
|
4
|
+
import { tenantOwnColumns } from '../tenant.js';
|
|
4
5
|
import { findSecondaryTableConf } from '../engine/write-helpers.js';
|
|
5
6
|
/**
|
|
6
7
|
* Build a PK-only schema for response shapes (single or composite PK).
|
|
@@ -15,10 +16,26 @@ export function pkSchema(tableConf, schema, fallback) {
|
|
|
15
16
|
}
|
|
16
17
|
return result;
|
|
17
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Make the tenant column(s) of a scoped table Optional in a write body. The engine injects the
|
|
21
|
+
* value for a single-tenant caller and validates the one a multi-tenant or `anyOf` caller sends,
|
|
22
|
+
* so the body must offer the field without demanding it — the same reason a secondary's FK is
|
|
23
|
+
* Optional. `tenantScope` names real DB columns; the schema field is found through `col()`.
|
|
24
|
+
*/
|
|
25
|
+
export function relaxTenantColumns(fields, tableConf, schema) {
|
|
26
|
+
const columns = tenantOwnColumns(tableConf?.tenantScope);
|
|
27
|
+
if (columns.length === 0)
|
|
28
|
+
return;
|
|
29
|
+
for (const field of Object.keys(fields)) {
|
|
30
|
+
if (columns.includes(schema.col(field)))
|
|
31
|
+
fields[field] = Type.Optional(fields[field]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
18
34
|
/**
|
|
19
35
|
* Build secondary table fields for body schemas:
|
|
20
36
|
* - FK field becomes Optional (auto-filled from main)
|
|
21
37
|
* - excludeFromCreation fields become Optional
|
|
38
|
+
* - tenant column(s) become Optional (injected or validated by the engine)
|
|
22
39
|
*/
|
|
23
40
|
function buildSecondaryFields(joinSchema, joinField, secondaryTableConf) {
|
|
24
41
|
const fields = writableFields({ ...joinSchema.fields }, secondaryTableConf, joinSchema);
|
|
@@ -32,6 +49,7 @@ function buildSecondaryFields(joinSchema, joinField, secondaryTableConf) {
|
|
|
32
49
|
}
|
|
33
50
|
}
|
|
34
51
|
}
|
|
52
|
+
relaxTenantColumns(fields, secondaryTableConf, joinSchema);
|
|
35
53
|
return fields;
|
|
36
54
|
}
|
|
37
55
|
/** The same schema without the Optional modifier, i.e. as a required property. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"insert.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/insert.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAErE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"insert.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/insert.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAErE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAW/C,wBAAgB,eAAe,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAiC9E;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAalF"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Type } from '@sinclair/typebox';
|
|
2
2
|
import { primaryAsString } from '../../types.js';
|
|
3
3
|
import { writableFields } from '../write-access.js';
|
|
4
|
-
import { pkSchema, applySchemaOverrides, attachWriteJoinSections, writeJoinBodyFields, writeJoinResponseFields, } from './helpers.js';
|
|
4
|
+
import { pkSchema, applySchemaOverrides, attachWriteJoinSections, relaxTenantColumns, writeJoinBodyFields, writeJoinResponseFields, } from './helpers.js';
|
|
5
5
|
export function InsertTableBody(dbTables, tableName) {
|
|
6
6
|
const tableConf = dbTables[tableName];
|
|
7
7
|
const schema = tableConf.Schema;
|
|
@@ -15,6 +15,8 @@ export function InsertTableBody(dbTables, tableName) {
|
|
|
15
15
|
mainFields[field] = Type.Optional(mainFields[field]);
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
+
// The tenant column is the engine's to fill or check, never the client's to be forced to send.
|
|
19
|
+
relaxTenantColumns(mainFields, tableConf, schema);
|
|
18
20
|
const bodyProperties = {
|
|
19
21
|
// additionalProperties:false makes the schema the real write whitelist — unknown keys are
|
|
20
22
|
// rejected (400) instead of silently reaching the INSERT as columns (mass assignment).
|
package/dist/lib/tenant.d.ts
CHANGED
|
@@ -2,6 +2,12 @@ import type { ApiRequest } from '../types/request.js';
|
|
|
2
2
|
import type { QueryClient } from './db.js';
|
|
3
3
|
import { ConditionBuilder, type ConditionValue, type ConditionValueOrUndefined } from 'node-condition-builder';
|
|
4
4
|
import type { ITable, SqlApiPluginOptions, TenantId, TenantScope, TenantScopeDirect, TenantScopeIndirect, TenantScopeAnyOf, TenantContext } from '../types.js';
|
|
5
|
+
/**
|
|
6
|
+
* The columns of a scoped table that the engine fills or validates on insert, i.e. the ones a
|
|
7
|
+
* write body must offer without requiring: the own column of a direct scope, every column of an
|
|
8
|
+
* `anyOf` scope. An indirect scope names no column of its own — its FK is an ordinary field.
|
|
9
|
+
*/
|
|
10
|
+
export declare function tenantOwnColumns(scope: TenantScope | undefined): string[];
|
|
5
11
|
export declare function resolveTenant(options: SqlApiPluginOptions, tableConf: ITable, request: ApiRequest): Promise<TenantContext | undefined>;
|
|
6
12
|
/**
|
|
7
13
|
* The read predicate for a scope, as a fragment every caller appends to its own WHERE.
|
package/dist/lib/tenant.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tenant.d.ts","sourceRoot":"","sources":["../../src/lib/tenant.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC3C,OAAO,EAAE,gBAAgB,EAAE,KAAK,cAAc,EAAE,KAAK,yBAAyB,EAAE,MAAM,wBAAwB,CAAC;AAE/G,OAAO,KAAK,EAEV,MAAM,EACN,mBAAmB,EACnB,QAAQ,EACR,WAAW,EACX,iBAAiB,EACjB,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,EACd,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"tenant.d.ts","sourceRoot":"","sources":["../../src/lib/tenant.ts"],"names":[],"mappings":"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"}
|
package/dist/lib/tenant.js
CHANGED
|
@@ -14,6 +14,16 @@ function isAnyOf(scope) {
|
|
|
14
14
|
function ownColumns(scope) {
|
|
15
15
|
return isAnyOf(scope) ? scope.anyOf : [scope.column];
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* The columns of a scoped table that the engine fills or validates on insert, i.e. the ones a
|
|
19
|
+
* write body must offer without requiring: the own column of a direct scope, every column of an
|
|
20
|
+
* `anyOf` scope. An indirect scope names no column of its own — its FK is an ordinary field.
|
|
21
|
+
*/
|
|
22
|
+
export function tenantOwnColumns(scope) {
|
|
23
|
+
if (!scope || isIndirect(scope))
|
|
24
|
+
return [];
|
|
25
|
+
return ownColumns(scope);
|
|
26
|
+
}
|
|
17
27
|
/**
|
|
18
28
|
* Whether this request could touch a scoped row at all: the addressed table is scoped, or one of
|
|
19
29
|
* the tables it can reach through a declared relation is.
|
|
@@ -44,6 +54,12 @@ export async function resolveTenant(options, tableConf, request) {
|
|
|
44
54
|
if (raw == null)
|
|
45
55
|
return undefined; // admin
|
|
46
56
|
const ids = Array.isArray(raw) ? raw : [raw];
|
|
57
|
+
// An empty list is a caller with no tenant at all — not an admin. Every consumer of `ids`
|
|
58
|
+
// would emit `IN ()` (a syntax error on both dialects) or, worse, a builder could drop the
|
|
59
|
+
// empty predicate and open the table. Refuse here, once, before any SQL exists.
|
|
60
|
+
if (ids.length === 0) {
|
|
61
|
+
throw httpError(403, 'Tenant required: the caller belongs to no tenant');
|
|
62
|
+
}
|
|
47
63
|
// `scope` stays undefined for an unscoped host: the ids still travel, so the tables it reaches
|
|
48
64
|
// enforce their own scopes, while the host itself keeps no filter.
|
|
49
65
|
return { ids, scope: tableConf.tenantScope };
|
|
@@ -7,7 +7,7 @@ export default async function bulkDeleteRoutes(fastify, options) {
|
|
|
7
7
|
operation: 'bulkDelete',
|
|
8
8
|
singlePkOnly: true,
|
|
9
9
|
method: 'POST',
|
|
10
|
-
url: (
|
|
10
|
+
url: (tableName) => `/bulk/${tableName}/delete`,
|
|
11
11
|
successStatus: 200,
|
|
12
12
|
schemas: (db, table) => ({
|
|
13
13
|
body: BulkDeleteTableBody(db, table, options.maxBulkItems ?? DEFAULT_MAX_BULK_ITEMS),
|
|
@@ -5,7 +5,7 @@ export default async function bulkUpsertRoutes(fastify, options) {
|
|
|
5
5
|
await registerForAllTables(fastify, options, {
|
|
6
6
|
operation: 'bulkUpsert',
|
|
7
7
|
method: 'PUT',
|
|
8
|
-
url: (
|
|
8
|
+
url: (tableName) => `/bulk/${tableName}`,
|
|
9
9
|
successStatus: 200,
|
|
10
10
|
schemas: (db, table) => ({
|
|
11
11
|
body: BulkUpsertTableBody(db, table, options.maxBulkItems ?? DEFAULT_MAX_BULK_ITEMS),
|
|
@@ -6,7 +6,7 @@ export default async function deleteRoutes(fastify, options) {
|
|
|
6
6
|
operation: 'delete',
|
|
7
7
|
singlePkOnly: true,
|
|
8
8
|
method: 'DELETE',
|
|
9
|
-
url: (
|
|
9
|
+
url: (tableName) => `/rest/${tableName}/:id`,
|
|
10
10
|
successStatus: 200,
|
|
11
11
|
schemas: (_db, _table, tc) => {
|
|
12
12
|
const pkField = primaryAsString(tc.primary);
|
|
@@ -6,7 +6,7 @@ export default async function getRoutes(fastify, options) {
|
|
|
6
6
|
operation: 'get',
|
|
7
7
|
singlePkOnly: true,
|
|
8
8
|
method: 'GET',
|
|
9
|
-
url: (
|
|
9
|
+
url: (tableName) => `/rest/${tableName}/:id`,
|
|
10
10
|
successStatus: 200,
|
|
11
11
|
schemas: (db, table) => ({
|
|
12
12
|
params: Type.Object({ id: Type.String() }),
|
|
@@ -4,7 +4,7 @@ export default async function insertRoutes(fastify, options) {
|
|
|
4
4
|
await registerForAllTables(fastify, options, {
|
|
5
5
|
operation: 'insert',
|
|
6
6
|
method: 'POST',
|
|
7
|
-
url: (
|
|
7
|
+
url: (tableName) => `/rest/${tableName}`,
|
|
8
8
|
successStatus: 201,
|
|
9
9
|
schemas: (db, table) => ({
|
|
10
10
|
body: InsertTableBody(db, table),
|
|
@@ -18,8 +18,11 @@ export interface AutoRouteSpec {
|
|
|
18
18
|
*/
|
|
19
19
|
singlePkOnly?: boolean;
|
|
20
20
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
21
|
-
/**
|
|
22
|
-
|
|
21
|
+
/**
|
|
22
|
+
* URL builder receiving the `DbTables` key — never `Schema.tableName`: several table
|
|
23
|
+
* configs may expose the same physical table, and each key needs its own route.
|
|
24
|
+
*/
|
|
25
|
+
url: (tableName: string) => string;
|
|
23
26
|
successStatus: 200 | 201;
|
|
24
27
|
/** Build per-table schemas (body, params, querystring, response). Response is wrapped with `{ [successStatus]: ... }` automatically. */
|
|
25
28
|
schemas: (dbTables: DbTables, tableName: string, tableConf: ITable) => RouteSchema;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-helpers.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/route-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAI7E,OAAO,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAQ5F,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAQlG;AAED,UAAU,WAAW;IAEnB,IAAI,CAAC,EAAE,GAAG,CAAC;IAEX,MAAM,CAAC,EAAE,GAAG,CAAC;IAEb,WAAW,CAAC,EAAE,GAAG,CAAC;IAElB,QAAQ,CAAC,EAAE,GAAG,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,iGAAiG;IACjG,SAAS,EAAE,cAAc,CAAC;IAC1B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC1C
|
|
1
|
+
{"version":3,"file":"route-helpers.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/route-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAI7E,OAAO,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAQ5F,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAQlG;AAED,UAAU,WAAW;IAEnB,IAAI,CAAC,EAAE,GAAG,CAAC;IAEX,MAAM,CAAC,EAAE,GAAG,CAAC;IAEb,WAAW,CAAC,EAAE,GAAG,CAAC;IAElB,QAAQ,CAAC,EAAE,GAAG,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,iGAAiG;IACjG,SAAS,EAAE,cAAc,CAAC;IAC1B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC1C;;;OAGG;IACH,GAAG,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,CAAC;IACnC,aAAa,EAAE,GAAG,GAAG,GAAG,CAAC;IACzB,wIAAwI;IACxI,OAAO,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,WAAW,CAAC;IACnF,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,KAAK,MAAM,CAAC;IACpF;2EACuE;IACvE,MAAM,EAAE,CACN,OAAO,EAAE,eAAe,EACxB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,cAAc,EACvB,KAAK,EAAE,YAAY,KAChB,OAAO,CAAC,OAAO,CAAC,CAAC;CACvB;AA6DD,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,EAC5B,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,IAAI,CAAC,CA6Bf"}
|
|
@@ -72,7 +72,7 @@ export async function registerForAllTables(fastify, options, spec) {
|
|
|
72
72
|
const schemas = spec.schemas(DbTables, tableName, tableConf);
|
|
73
73
|
fastify.route({
|
|
74
74
|
method: spec.method,
|
|
75
|
-
url: spec.url(
|
|
75
|
+
url: spec.url(tableName),
|
|
76
76
|
schema: buildRouteSchema(tableName, tableConf, spec, schemas),
|
|
77
77
|
onRequest: mergeOnRequests(options, tableConf),
|
|
78
78
|
handler: async (request, reply) => {
|
|
@@ -6,7 +6,7 @@ export default async function searchRoutes(fastify, options) {
|
|
|
6
6
|
await registerForAllTables(fastify, options, {
|
|
7
7
|
operation: 'search',
|
|
8
8
|
method: 'POST',
|
|
9
|
-
url: (
|
|
9
|
+
url: (tableName) => `/search/${tableName}`,
|
|
10
10
|
successStatus: 200,
|
|
11
11
|
schemas: (db, table) => ({
|
|
12
12
|
body: SearchTableBodyPost(db, table),
|
|
@@ -24,7 +24,7 @@ export default async function searchRoutes(fastify, options) {
|
|
|
24
24
|
leftAliases.length && `Available joinLeft aliases: ${leftAliases.join(', ')}`,
|
|
25
25
|
].filter(Boolean).join('. ');
|
|
26
26
|
},
|
|
27
|
-
handle: async (fastify, tableName,
|
|
27
|
+
handle: async (fastify, tableName, _tc, request) => {
|
|
28
28
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
29
|
const body = request.body;
|
|
30
30
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -52,7 +52,7 @@ export default async function searchRoutes(fastify, options) {
|
|
|
52
52
|
computeAvg: query.computeAvg,
|
|
53
53
|
maxRows,
|
|
54
54
|
}, request);
|
|
55
|
-
return { table:
|
|
55
|
+
return { table: tableName, ...result };
|
|
56
56
|
},
|
|
57
57
|
});
|
|
58
58
|
}
|
|
@@ -4,7 +4,7 @@ export default async function updateRoutes(fastify, options) {
|
|
|
4
4
|
await registerForAllTables(fastify, options, {
|
|
5
5
|
operation: 'update',
|
|
6
6
|
method: 'PUT',
|
|
7
|
-
url: (
|
|
7
|
+
url: (tableName) => `/rest/${tableName}`,
|
|
8
8
|
successStatus: 200,
|
|
9
9
|
schemas: (db, table) => ({
|
|
10
10
|
body: UpdateTableBody(db, table),
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# 0020. No conditional or mass update route — a predicate write is a custom route
|
|
2
|
+
|
|
3
|
+
- **Status**: accepted
|
|
4
|
+
- **Date**: 2026-09-24
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
The `update` route addresses exactly one row: its `WHERE` is every primary key column plus
|
|
9
|
+
the tenant scope, and nothing the client sends can add to it. Two real needs fall outside
|
|
10
|
+
that shape:
|
|
11
|
+
|
|
12
|
+
- **Compare-and-set.** Claiming a row so that two clients cannot both claim it
|
|
13
|
+
(`UPDATE … SET in_progress_since = now() WHERE id = ? AND in_progress_since IS NULL`),
|
|
14
|
+
guarded state transitions (`… AND status = 'draft'`), optimistic locking on a `version`
|
|
15
|
+
column. Correctness depends on the check and the write being one statement; a
|
|
16
|
+
`beforeUpdate` that reads first and writes after is exactly the race it is meant to
|
|
17
|
+
prevent.
|
|
18
|
+
- **Mass update by predicate.** Setting a value on every row matching a filter.
|
|
19
|
+
|
|
20
|
+
A route was designed to cover both: a new `updateWhere` operation, opt-in (registered only
|
|
21
|
+
when listed in `operations`), `PUT {p}/update/{key}` with `{ where: { filters, conditions },
|
|
22
|
+
fields }` reusing the search grammar, the tenant guard injected, `validate`/`beforeUpdate`
|
|
23
|
+
run on `fields`, `{ affected }` in response. Compare-and-set is then a `where` that includes
|
|
24
|
+
the primary key.
|
|
25
|
+
|
|
26
|
+
## Decision
|
|
27
|
+
|
|
28
|
+
The plugin does not expose a conditional or mass update route, and the single-row `update`
|
|
29
|
+
does not accept a precondition. A write whose target is a predicate is built as a custom
|
|
30
|
+
route on `QueryClient.update(table, fields, where, extraCondition)`, which already takes a
|
|
31
|
+
`ConditionBuilder` and returns the number of rows it changed.
|
|
32
|
+
|
|
33
|
+
The design was sound; the cost was not worth it. It is the first route that writes N rows
|
|
34
|
+
chosen by the client, so a defect in it is destructive and tenant-crossing rather than a
|
|
35
|
+
wrong answer. Building it touches several security-sensitive pieces at once:
|
|
36
|
+
|
|
37
|
+
- `QueryClient.update`, which today assumes an equality `where` and would emit `WHERE AND …`
|
|
38
|
+
for a condition-only one;
|
|
39
|
+
- the search condition builder, shared with every read;
|
|
40
|
+
- the tenant guards, which on update are keyed on a primary key (`assertTenantOwnership`)
|
|
41
|
+
and would need the row-guard form for indirect scopes instead;
|
|
42
|
+
- the meaning of `validate`, `beforeUpdate` and `afterUpdate`, whose `record` would no longer
|
|
43
|
+
carry a primary key — breaking any existing hook that reads one.
|
|
44
|
+
|
|
45
|
+
Against that, the need is narrow and the custom route is a few lines, owned by the consumer
|
|
46
|
+
who knows the predicate it must enforce.
|
|
47
|
+
|
|
48
|
+
## Alternatives considered
|
|
49
|
+
|
|
50
|
+
- **`updateWhere` operation, opt-in** — the design above. Rejected for the risk and blast
|
|
51
|
+
radius described in the decision, not for a flaw in its shape.
|
|
52
|
+
- **`expect` on the single-row `update`** (a precondition next to the primary key, `409` when
|
|
53
|
+
it fails) — rejected: it narrows the write instead of widening it, so the blast radius is
|
|
54
|
+
small, but it still reopens the update `WHERE` to client-supplied predicates, and it needs
|
|
55
|
+
a readability rule to avoid an oracle (see Consequences). One custom route covers it.
|
|
56
|
+
- **Read-then-write in `beforeUpdate`** — not an alternative: two concurrent requests both
|
|
57
|
+
pass the read.
|
|
58
|
+
|
|
59
|
+
## Consequences
|
|
60
|
+
|
|
61
|
+
- Compare-and-set and predicate updates are custom routes. [AGENTS_BACKEND.md](../../AGENTS_BACKEND.md)
|
|
62
|
+
carries the pattern ("Conditional update — claim a row atomically").
|
|
63
|
+
- Everything the auto routes do for free is the custom route's job there: `QueryClient` runs
|
|
64
|
+
no hooks, no validation and **no tenant scoping**, so the route must add its own tenant
|
|
65
|
+
predicate and must not let a client-chosen predicate reach a column the caller cannot read —
|
|
66
|
+
otherwise `affected: 0 | 1` becomes an oracle on its value.
|
|
67
|
+
- Revisit only with a concrete, repeated need that a custom route serves badly; the design
|
|
68
|
+
notes above are the starting point.
|
package/docs/adr/README.md
CHANGED
|
@@ -34,6 +34,7 @@ not by editing history.
|
|
|
34
34
|
| [0017](./0017-the-schema-is-the-contract.md) | The database schema is the API contract — by default, and only by default |
|
|
35
35
|
| [0018](./0018-one-seam-to-the-http-framework.md) | The HTTP framework is reachable through one seam — `ApiRequest` |
|
|
36
36
|
| [0019](./0019-column-exclusion-is-generation-time-config.md) | Removing a column from the API is generation-time config — `excludeColumns`, never a Schema edit |
|
|
37
|
+
| [0020](./0020-no-conditional-or-mass-update-route.md) | No conditional or mass update route — a predicate write is a custom route |
|
|
37
38
|
|
|
38
39
|
## When to write one
|
|
39
40
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fastify-auto-sqlapi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Fastify plugin for automatic CRUD API generation from PostgreSQL, MySQL and MariaDB tables",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -83,20 +83,20 @@
|
|
|
83
83
|
"homepage": "https://github.com/LucaRainone/fastify-auto-sqlapi#readme",
|
|
84
84
|
"dependencies": {
|
|
85
85
|
"@sinclair/typebox": "^0.34.14",
|
|
86
|
-
"fastify-plugin": "^
|
|
86
|
+
"fastify-plugin": "^6.0.0",
|
|
87
87
|
"node-condition-builder": "^1.2.0"
|
|
88
88
|
},
|
|
89
89
|
"devDependencies": {
|
|
90
90
|
"@eslint/js": "^10.0.1",
|
|
91
91
|
"@fastify/postgres": "^6.0.2",
|
|
92
92
|
"@fastify/swagger": "^9.0.0",
|
|
93
|
-
"@fastify/swagger-ui": "^
|
|
93
|
+
"@fastify/swagger-ui": "^6.1.1",
|
|
94
94
|
"@types/node": "^22.13.4",
|
|
95
95
|
"@types/pg": "^8.11.11",
|
|
96
96
|
"dependency-cruiser": "^18.1.0",
|
|
97
97
|
"eslint": "^10.8.0",
|
|
98
98
|
"eslint-plugin-sonarjs": "^4.2.0",
|
|
99
|
-
"fastify": "5.
|
|
99
|
+
"fastify": "5.12.4",
|
|
100
100
|
"fuse.js": "^7.5.0",
|
|
101
101
|
"jscpd": "^5.0.14",
|
|
102
102
|
"knip": "^6.31.0",
|