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 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, or explicit tenant value doesn't match
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
@@ -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
 
@@ -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;AAKzF;;;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;AAiCD,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
+ {"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;AAU/C,wBAAgB,eAAe,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CA+B9E;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAalF"}
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).
@@ -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.
@@ -1 +1 @@
1
- {"version":3,"file":"tenant.d.ts","sourceRoot":"","sources":["../../src/lib/tenant.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"tenant.d.ts","sourceRoot":"","sources":["../../src/lib/tenant.ts"],"names":[],"mappings":"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"}
@@ -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: (tc) => `/bulk/${tc.Schema.tableName}/delete`,
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: (tc) => `/bulk/${tc.Schema.tableName}`,
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: (tc) => `/rest/${tc.Schema.tableName}/:id`,
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: (tc) => `/rest/${tc.Schema.tableName}/:id`,
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: (tc) => `/rest/${tc.Schema.tableName}`,
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
- /** URL builder receiving the table configuration to read `Schema.tableName`. */
22
- url: (tableConf: ITable) => string;
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,gFAAgF;IAChF,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"}
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(tableConf),
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: (tc) => `/search/${tc.Schema.tableName}`,
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, tc, request) => {
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: tc.Schema.tableName, ...result };
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: (tc) => `/rest/${tc.Schema.tableName}`,
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.
@@ -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.2.4",
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": "^5.1.0",
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": "^5.0.0",
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.8.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",