fastify-auto-sqlapi 0.2.3 → 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.
Files changed (37) hide show
  1. package/AGENTS.md +1 -1
  2. package/AGENTS_BACKEND.md +72 -9
  3. package/AGENTS_FRONTEND.md +1 -1
  4. package/BREAKING_CHANGES.md +52 -0
  5. package/CHANGELOG.md +102 -0
  6. package/README.md +1 -1
  7. package/dist/bin/generate-schema.js +7 -1
  8. package/dist/lib/cli/schema-codegen.d.ts +14 -0
  9. package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
  10. package/dist/lib/cli/schema-codegen.js +67 -4
  11. package/dist/lib/schema/helpers.d.ts +7 -0
  12. package/dist/lib/schema/helpers.d.ts.map +1 -1
  13. package/dist/lib/schema/helpers.js +18 -0
  14. package/dist/lib/schema/insert.d.ts.map +1 -1
  15. package/dist/lib/schema/insert.js +3 -1
  16. package/dist/lib/table-helpers.d.ts.map +1 -1
  17. package/dist/lib/table-helpers.js +2 -1
  18. package/dist/lib/tenant.d.ts +6 -0
  19. package/dist/lib/tenant.d.ts.map +1 -1
  20. package/dist/lib/tenant.js +16 -0
  21. package/dist/routes/auto/bulk-delete.routes.js +1 -1
  22. package/dist/routes/auto/bulk-upsert.routes.js +1 -1
  23. package/dist/routes/auto/delete.routes.js +1 -1
  24. package/dist/routes/auto/get.routes.js +1 -1
  25. package/dist/routes/auto/insert.routes.js +1 -1
  26. package/dist/routes/auto/route-helpers.d.ts +5 -2
  27. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  28. package/dist/routes/auto/route-helpers.js +1 -1
  29. package/dist/routes/auto/search.routes.js +3 -3
  30. package/dist/routes/auto/update.routes.js +1 -1
  31. package/dist/types/cli.d.ts +10 -0
  32. package/dist/types/cli.d.ts.map +1 -1
  33. package/docs/adr/0017-the-schema-is-the-contract.md +2 -1
  34. package/docs/adr/0019-column-exclusion-is-generation-time-config.md +62 -0
  35. package/docs/adr/0020-no-conditional-or-mass-update-route.md +68 -0
  36. package/docs/adr/README.md +2 -0
  37. package/package.json +4 -4
package/AGENTS.md CHANGED
@@ -6,7 +6,7 @@ You are configuring a Fastify server that uses `fastify-auto-sqlapi` to auto-gen
6
6
 
7
7
  The plugin generates REST endpoints (search, get, insert, update, delete, bulk upsert, bulk delete) from database table definitions. No ORM — raw SQL. Supports **PostgreSQL**, **MySQL**, and **MariaDB**. The consumer defines table configurations, and the plugin handles routing, validation, and query execution.
8
8
 
9
- **The database schema is the API contract, on purpose.** The exposed shape follows the stored shape *by default*, and every part of that default is overridable — `readExclude`, `writeExclude`, a trimmed Schema, `schemaOverrides`, `computedFields`, `afterRead`, `operations`, relation `alias`/`fields`, `extraFilters`. Two consequences for how you work:
9
+ **The database schema is the API contract, on purpose.** The exposed shape follows the stored shape *by default*, and every part of that default is overridable — `readExclude`, `writeExclude`, `excludeColumns` (CLI config), `schemaOverrides`, `computedFields`, `afterRead`, `operations`, relation `alias`/`fields`, `extraFilters`. Two consequences for how you work:
10
10
 
11
11
  - Do not propose a DTO or mapping layer, and do not reshape a response by hand in a route wrapper. Reach for the lever that matches the concern (the table is in [ADR 0017](./docs/adr/0017-the-schema-is-the-contract.md)); when none fits, the operation is not CRUD — write a custom route and call `app.sqlApi.*` inside it, which keeps filters, joins, tenant scoping, hooks and validation.
12
12
  - The target surface is internal: back-office and admin tools where the schema *is* the domain model. If the user is building a **public or third-party API** — consumers they cannot redeploy with their schema, a versioned contract — say so, and keep those tables off the auto routes via `operations` rather than generating the surface from the schema.
package/AGENTS_BACKEND.md CHANGED
@@ -58,13 +58,16 @@ export default {
58
58
  dialect: 'postgres', // 'postgres' | 'mysql' | 'mariadb' (default: 'postgres')
59
59
  // envFile: '../../.env', // path to .env file, relative to cwd (default: '.env')
60
60
  // excludeTables: ['knex_*'],// tables invisible to schema generation (see below)
61
+ // excludeColumns: ['*.password_hash'], // columns invisible to schema generation (see below)
61
62
  };
62
63
  ```
63
64
 
64
- That's it — only these five fields exist. **No connection string, no migration path.** If the file is missing, defaults are used. The `dialect` can also be passed via CLI flag `--dialect mysql`. The `envFile` is useful in monorepo setups where the `.env` lives at the repo root.
65
+ That's it — only these six fields exist. **No connection string, no migration path.** If the file is missing, defaults are used. The `dialect` can also be passed via CLI flag `--dialect mysql`. The `envFile` is useful in monorepo setups where the `.env` lives at the repo root.
65
66
 
66
67
  **`excludeTables`** keeps migration bookkeeping, PostGIS internals and the like out of generation: exact table names or `*` globs, matched against the **DB table name** (not the generated schema name), with everything else literal. An excluded table is invisible to the generator — its schema file is not created, and one generated by an earlier run is removed as an orphan on the next full run, exactly like the schema of a dropped table. See [ADR 0009](./docs/adr/0009-schema-exclusion-config-blacklist.md).
67
68
 
69
+ **`excludeColumns`** does the same for single columns: `"<table>.<column>"` entries matched against the **DB names** (not the generated field names), with `*` as a wildcard on either side — `'user.password_hash'`, `'*.internal_*'`, `'audit_*.raw_payload'`. An excluded column does not exist for the API at all: it is not in the Schema, so no route reads, writes, filters or orders by it, and the manifest never lists it. This is the lever for *"this column must not exist for the API"*; `readExclude` / `writeExclude` on the table config are for a column that stays in the Schema and loses one side. A primary key column cannot be excluded (every route identifies rows with it), and a pattern that would leave a table with no columns fails the run: that is `excludeTables`' job. Never edit a generated Schema file by hand — it is overwritten on the next run. See [ADR 0019](./docs/adr/0019-column-exclusion-is-generation-time-config.md).
70
+
68
71
  ### 3. Configure database connection (env vars)
69
72
 
70
73
  The CLI reads DB connection from environment variables (NOT from the config file). `DATABASE_URL` takes priority for all dialects.
@@ -288,7 +291,18 @@ export const TableCustomer = defineTable({
288
291
 
289
292
  `exportTableInfo(Schema)` returns `{ Schema, filters, extraFilters }`. The `filters` function auto-builds WHERE conditions from any schema field present in the request.
290
293
 
291
- **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
+ ```
292
306
 
293
307
  ### Composite primary key (link/junction tables)
294
308
 
@@ -866,7 +880,7 @@ const TableCustomer = defineTable({
866
880
 
867
881
  Behavior:
868
882
  - **Read** (search, get, delete, bulk-delete): adds `AND organization_id IN ($N)` to WHERE
869
- - **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.
870
884
  - **Update**: strips `organization_id` from SET (can't change tenant), adds to WHERE condition
871
885
  - **Bulk upsert**: auto-injects on all records
872
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.
@@ -919,7 +933,8 @@ Behavior:
919
933
  - **Insert**: no auto-injection. The payload must **anchor** the row: at least one listed column
920
934
  present and holding a tenant id → `400` if none is present, `403` if present but all foreign.
921
935
  The other parties are stored exactly as sent. A multi-id caller is never ambiguous here,
922
- 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.
923
938
  - **Update**: strips **every** listed column from the `SET` — neither party can be re-assigned —
924
939
  and adds the OR predicate to the `WHERE`. A row outside the scope answers `404`.
925
940
  - **Bulk upsert**: the anchor rule applies to every item (so an upsert must name a party even
@@ -945,9 +960,14 @@ getTenantId: (request) => {
945
960
 
946
961
  Uses `IN ($N, $M, ...)` for all WHERE clauses (same as single tenant, just with more values).
947
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
+
948
967
  ### Admin bypass
949
968
 
950
- 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.
951
971
 
952
972
  ### Tables without tenant
953
973
 
@@ -955,7 +975,8 @@ Tables without `tenantScope` are unaffected — no filtering regardless of `getT
955
975
 
956
976
  ### Error codes
957
977
 
958
- - **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)
959
980
  - **400** — Multi-tenant user on insert without explicit tenant value (ambiguous), or an
960
981
  `anyOf` write naming no owner column at all
961
982
  - **404** — Update with indirect tenant, record not found for this tenant; get/update/delete of
@@ -1014,8 +1035,8 @@ output, it is the part that does not depend on anyone opening the generated file
1014
1035
  - **All fields Optional in response**: `RETURNING *` may return any subset. Response schemas use `Type.Partial`.
1015
1036
  - **Nullable columns use `Nullable(T)` (type-array form)**: the generator emits `Type.Optional(Nullable(T))` for nullable columns — `Nullable` (exported by the package) produces `{ type: ['integer', 'null'] }`. Do NOT replace it with `Type.Union([T, Type.Null()])`: under Fastify's default Ajv `coerceTypes` a union corrupts values through its branches (`null` → `0` with the Null branch last, `0`/`""` → `null` with it first), while the type array validates and serializes NULL correctly. A bare `Type.Optional(T)` is also wrong: NULL serializes as `0`/`""`. In `filters`, an explicit `null` on a schema field builds `WHERE col IS NULL`.
1016
1037
  - **`excludeFromCreation`**: **IMPORTANT** — auto-increment PKs (e.g. `id` serial/auto_increment) MUST be listed here, otherwise INSERT will try to send them and fail. The CLI auto-detects this and adds it by default. Also useful for `createdAt`/`updatedAt` columns managed by DB defaults or hooks. It strips **client-supplied** values only: the payload is sanitized before `beforeInsert` runs, so a value the hook assigns to an excluded field (e.g. a server-generated TEXT id) DOES reach the INSERT. Same for the engine's FK auto-fill on secondaries — an excluded FK column does not suppress it. It is an **ergonomics tool for creation, NOT a field-level security mechanism**: it does not apply to updates — every Schema field is updatable by default, by design. Field-level update rules (`isAdmin`, roles, owner/tenant columns, state fields) are product logic: enforce them in `beforeUpdate` (silent strip) or `validate` (loud 400), or move the privileged transition to a dedicated endpoint and keep it off the auto routes via `operations`.
1017
- - **`readExclude`**: fields hidden from every read — not projected by search/get, omitted from read response schemas and from this table's default (`*`) join selection when it is a join target. Referencing one from `filters`, `conditions`, `orderBy`, `joinGroup` aggregations or an explicit join `selection` returns 400: hiding a field from output while allowing it to be filtered would leak its value by bisection. **Writes are unaffected** — insert/update/bulk still accept the field, so a column can be writable but never readable (password hash, access token). Primary keys cannot be excluded. Complementary to trimming the Schema, which removes the column from reads *and* writes. It is **per table and static**: a column hidden here is hidden from every caller, including admins. To keep a column readable on the table's own routes but not through a relation, trim the Schema the *relation* is declared with instead — see the read-grant warning under [buildRelation](#buildrelation-signature).
1018
- - **`writeExclude`**: the write-side counterpart of `readExclude` — fields removed from the insert, update and bulk-upsert bodies (main *and* secondaries) and dropped again inside the engines, which `sqlApi.*` reaches without those schemas. Reads are untouched: the field is still projected, filterable and orderable. It exists for the columns the database refuses to be told about. **A `GENERATED ALWAYS AS (<expr>)` column is already covered without any config**: the CLI records it in the Schema's `generatedFields`, and both engines reject a statement that so much as names such a column, so offering it as writable could only turn a client mistake into a `500`. The `writeExclude` key is for the rest — a column a trigger owns, one a migration is about to drop. It is **static and applies to everyone**, admins included; a rule that depends on *who* is asking is product logic and belongs in `beforeUpdate` (silent strip) or `validate` (loud 400), per [ADR 0004](./docs/adr/0004-updates-always-open.md). Not to be confused with `excludeFromCreation`, which whitelists **client input on insert only** and deliberately lets a hook assign the field afterwards — `writeExclude` runs **after** the hooks, because the column refuses the value whoever assigned it. `defineTable` rejects a field that is not in the schema, the primary key (the update body identifies the row with it) and a field that is also in `readExclude` (neither readable nor writable means: remove it from the Schema). See [ADR 0015](./docs/adr/0015-non-writable-columns.md), which narrows [ADR 0004](./docs/adr/0004-updates-always-open.md).
1038
+ - **`readExclude`**: fields hidden from every read — not projected by search/get, omitted from read response schemas and from this table's default (`*`) join selection when it is a join target. Referencing one from `filters`, `conditions`, `orderBy`, `joinGroup` aggregations or an explicit join `selection` returns 400: hiding a field from output while allowing it to be filtered would leak its value by bisection. **Writes are unaffected** — insert/update/bulk still accept the field, so a column can be writable but never readable (password hash, access token). Primary keys cannot be excluded. Complementary to `excludeColumns` in `sqlapi.config.ts`, which removes the column from the Schema, hence from reads *and* writes. It is **per table and static**: a column hidden here is hidden from every caller, including admins. To keep a column readable on the table's own routes but not through a relation, trim the Schema the *relation* is declared with instead — see the read-grant warning under [buildRelation](#buildrelation-signature).
1039
+ - **`writeExclude`**: the write-side counterpart of `readExclude` — fields removed from the insert, update and bulk-upsert bodies (main *and* secondaries) and dropped again inside the engines, which `sqlApi.*` reaches without those schemas. Reads are untouched: the field is still projected, filterable and orderable. It exists for the columns the database refuses to be told about. **A `GENERATED ALWAYS AS (<expr>)` column is already covered without any config**: the CLI records it in the Schema's `generatedFields`, and both engines reject a statement that so much as names such a column, so offering it as writable could only turn a client mistake into a `500`. The `writeExclude` key is for the rest — a column a trigger owns, one a migration is about to drop. It is **static and applies to everyone**, admins included; a rule that depends on *who* is asking is product logic and belongs in `beforeUpdate` (silent strip) or `validate` (loud 400), per [ADR 0004](./docs/adr/0004-updates-always-open.md). Not to be confused with `excludeFromCreation`, which whitelists **client input on insert only** and deliberately lets a hook assign the field afterwards — `writeExclude` runs **after** the hooks, because the column refuses the value whoever assigned it. `defineTable` rejects a field that is not in the schema, the primary key (the update body identifies the row with it) and a field that is also in `readExclude` (neither readable nor writable means: `excludeColumns` in `sqlapi.config.ts`). See [ADR 0015](./docs/adr/0015-non-writable-columns.md), which narrows [ADR 0004](./docs/adr/0004-updates-always-open.md).
1019
1040
  - **Database-computed and identity columns are detected by the CLI**: `sqlapi-generate-schema` reads `is_generated` (PostgreSQL) / `GENERATION_EXPRESSION` (MySQL, non-empty only for a real `GENERATED ALWAYS AS (...)` column; `EXTRA` is a fallback matched on `VIRTUAL GENERATED` / `STORED GENERATED`, never on the word alone — MySQL writes `DEFAULT_GENERATED` there for any default *expression*, `DEFAULT CURRENT_TIMESTAMP` included, and such a column is writable) and prints the columns it found, per table, before writing anything — read that output. Generated columns land in the Schema's `generatedFields` and are named in a comment in the generated `Table*.ts`. Separately, `GENERATED ... AS IDENTITY` (PostgreSQL) has no `column_default` to reveal that the database fills it in: it is now flagged like MySQL's `AUTO_INCREMENT`, so the field is Optional and the CLI lists it under `excludeFromCreation` instead of emitting a mandatory primary key the database then refuses.
1020
1041
  - **`upsertMap`**: when present for a schema, INSERT becomes upsert. PostgreSQL: `ON CONFLICT (...) DO UPDATE`. MySQL/MariaDB: `ON DUPLICATE KEY UPDATE`. Applies to both main and secondary tables.
1021
1042
  - **`schemaOverrides`**: narrow an auto-generated field to a stricter TypeBox type (e.g. `{ email: Type.String({ format: 'email' }) }`) where `information_schema` could only report `text`. **Write bodies only** — insert, update, bulk-upsert. An override rules what the API accepts *from now on*, while the rows already stored predate it: a column narrowed to `format: 'email'` today holds whatever was accepted last year, and one made mandatory today is `NULL` on every pre-existing row. Narrowing the response would publish a promise nobody can retroactively make true, so responses and the `filters` map keep the generated type. (Nothing would break at runtime either way — Fastify does not validate responses, and `fast-json-stringify` ignores `format`/`pattern`/`minLength`/`minimum`/`enum` entirely; it acts only on `type`, by coercion.) The override is taken **verbatim**: no `Type.Optional` makes the field mandatory, no `Nullable` rejects an explicit `null`, whatever the column allows — which is how you make a column the DB had to leave nullable (a new column on a populated table) mandatory going forward. The original Schema file is never modified; the primary key is mandatory in the update body (it identifies the row); overrides appear in Swagger.
@@ -1025,7 +1046,7 @@ output, it is the part that does not depend on anyone opening the generated file
1025
1046
  - **All hooks and validators receive camelCase records**: `validate` and the whole hook matrix (`beforeInsert`/`afterInsert`, `beforeUpdate`/`afterUpdate`, `beforeDelete`/`afterDelete`, `beforeBulkDelete`/`afterBulkDelete`, `afterRead`) get records keyed by schema field names (camelCase). Mutations propagate to the SQL (plugin converts to DB column format via `colMap` after the hook). The engine internally uses `snakecaseRecord(..., schema)` after user mutations to map field names to actual DB columns.
1026
1047
  - **Secondaries run the child table's `beforeInsert`**: a child row written alongside a main record is a write like any other — the child's `excludeFromCreation`, its `tenantScope` and its `beforeInsert` all apply, in that order, before the engine auto-fills the FK to main (which always wins over anything the payload or the hook put there). Its `validate` and `afterInsert` deliberately do **not** run: they change when a rejection or a side effect fires, which belongs to the host's write. See [ADR 0014](./docs/adr/0014-what-follows-a-write-join.md).
1027
1048
  - **Filters validation**: TypeBox schemas use `additionalProperties: false`. By default Fastify strips unknown fields silently. For 400 errors on unknown filters: `Fastify({ ajv: { customOptions: { removeAdditional: false } } })`.
1028
- - **Write-body whitelist**: insert/update/bulk-upsert bodies (`main` + secondaries items) are `additionalProperties: false`. Unknown properties are rejected with 400 — only columns in the generated Schema (as narrowed by `schemaOverrides`/`excludeFromCreation`) can be written, so no mass assignment of unexposed columns. Trim the Schema to keep sensitive columns unwritable.
1049
+ - **Write-body whitelist**: insert/update/bulk-upsert bodies (`main` + secondaries items) are `additionalProperties: false`. Unknown properties are rejected with 400 — only columns in the generated Schema (as narrowed by `schemaOverrides`/`excludeFromCreation`) can be written, so no mass assignment of unexposed columns. Exclude a sensitive column at generation time (`excludeColumns`) to keep it out of the Schema, hence unwritable and unreadable.
1029
1050
  - **Error responses**: errors the plugin raises itself are `4xx` with messages written for clients (validation, not found, tenant). Anything else — a driver constraint violation, a hook throwing without a `statusCode` — answers a fixed `{"statusCode":500,"error":"Internal Server Error","message":"Internal Server Error"}`: driver messages name tables, columns and constraints, and they stay on the server. **No status code is remapped** (a unique violation is a `500`, not a `409` — that mapping is product logic). The body carries `requestId` (the `reqId` Fastify logs on every line) so the real error is one grep away in the log, where it is also written via `request.log.error` — and attached as `err.cause`, so a consumer `setErrorHandler` can map `err.cause.code === '23505'` to `409` itself. **`exposeDebugInfo: true` is the only way to get the driver detail on the wire** — set it while developing, where a client building against the API cannot always read your log: the `500` then also carries `debugInfo` (`message`, `code`, `constraint`, `detail`, `stack`), additively, so the four fields above keep their production shape. Nothing else flips it: not `debug` (server-side only, and it belongs to whoever registers the plugin), not `NODE_ENV`. Errors thrown by `onRequests` hooks run before the handler and are unaffected.
1030
1051
  - **Request limits**: `maxItemsPerPage` (default 1000) caps the search page size and is applied as the `LIMIT` even when no paginator is sent (so an empty-body search can't dump a whole table); over-limit `itemsPerPage` → 400. `maxBulkItems` (default 1000) caps the bulk array length via schema `maxItems` → 400. Two further caps are **fixed, not configurable**, and bound how much work one search may ask for: at most **100 `conditions`** and **20 comma-separated `orderBy` parts** — each dotted condition and each 3-part `orderBy` token becomes its own correlated subquery, which `maxItemsPerPage` does not bound. Over either limit → 400; the request schema also caps `orderBy` at 1024 characters. Those two live in the **search engine**, not in the route, so they apply to programmatic `sqlApi.search()` as well, together with the `paginator` integer guard. What `sqlApi.*` does *not* inherit are the two configurable caps, which belong to the routes: pass `maxRows` to `sqlApi.search()` for the same `LIMIT` protection, and bound your own arrays before a bulk call.
1031
1052
  - **Tenant filtering**: when `tenantScope` is set on a table and `getTenantId` is provided in plugin options, all CRUD operations are automatically scoped to the tenant. `getTenantId` returning `null`/`undefined` = admin (no filter). Returning an array = multi-tenant user (IN clause). The tenant belongs to the *request*, not to the table it addresses: a scoped table is filtered even when reached as a join target or written as a secondary of an unscoped host, so `getTenantId` must tolerate a request with no authenticated user (`req.user?.x ?? null`).
@@ -1211,6 +1232,48 @@ const TableUser = defineTable({
1211
1232
  For privileged transitions (promote to admin, move across tenants) prefer a dedicated
1212
1233
  endpoint with its own auth/audit, and keep the operation off the auto routes via `operations`.
1213
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
+
1214
1277
  ### Encrypt a column at rest (storage representation ≠ API representation)
1215
1278
 
1216
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,96 @@ 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
+
75
+ ## [0.2.4]
76
+
77
+ ### Added
78
+
79
+ - **`excludeColumns` config option** — the lever for *"this column must not exist for the API at
80
+ all"*. [ADR 0017](./docs/adr/0017-the-schema-is-the-contract.md), the README and `AGENTS.md`
81
+ all pointed at "trim it out of the Schema" for that case, and no such lever existed: Schema
82
+ files are generated and overwritten on every run, so the only way to drop a column was to
83
+ re-edit the file after each `sqlapi-generate-schema` — or to pair `readExclude` with
84
+ `writeExclude`, which `defineTable` refuses. `excludeColumns` in `sqlapi.config.ts` takes
85
+ `"<table>.<column>"` entries matched against the DB names, with `*` as a wildcard on either
86
+ side (`'user.password_hash'`, `'*.internal_*'`), and drops the column from the generated Schema
87
+ — its fields, `colMap` and `generatedFields` — so no route, filter, order or manifest ever
88
+ sees it. A primary key column is refused (every route identifies rows with it), and so is a
89
+ pattern that would leave a table with no columns: that is `excludeTables`' job. Applied after
90
+ `excludeTables`, so a column pattern is never checked against a table the generator does not
91
+ see. **Regenerate your schemas** after adding entries. See
92
+ [ADR 0019](./docs/adr/0019-column-exclusion-is-generation-time-config.md).
93
+
94
+ ### Changed
95
+
96
+ - **The docs no longer say "trim the Schema".** The README lever table, `AGENTS.md`,
97
+ `AGENTS_BACKEND.md` and the `defineTable` error for a field in both `readExclude` and
98
+ `writeExclude` now point at `excludeColumns`.
99
+
10
100
  ## [0.2.3]
11
101
 
12
102
  ### Fixed
@@ -595,6 +685,18 @@ the join API redesign (documented in [BREAKING_CHANGES.md](./BREAKING_CHANGES.md
595
685
  primary key support in updates, transactional insert/update with secondaries, the hook matrix,
596
686
  and bulk operations.
597
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
598
700
  [0.1.7]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.7
599
701
  [0.1.6]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.6
600
702
  [0.1.5]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.5
package/README.md CHANGED
@@ -215,7 +215,7 @@ each way they need to differ:
215
215
  |---|---|
216
216
  | a column must never be read | `readExclude` |
217
217
  | a column must never be written | `writeExclude` |
218
- | a column must not exist for the API at all | trim it out of the Schema |
218
+ | a column must not exist for the API at all | `excludeColumns` in `sqlapi.config.ts` |
219
219
  | the API type must be stricter than the column | `schemaOverrides` |
220
220
  | the value is derived, not stored | `computedFields` |
221
221
  | the stored representation is not the API one | `afterRead` (decrypt, unpack, rescale) |
@@ -4,7 +4,7 @@ import * as path from 'node:path';
4
4
  import { loadConfig } from '../lib/cli/config.js';
5
5
  import { buildConnectionString, introspectTables } from '../lib/cli/pg-introspect.js';
6
6
  import { buildMysqlConnectionConfig, introspectMysqlTables } from '../lib/cli/mysql-introspect.js';
7
- import { applyExcludeTables, buildTableMap, generateSchemaFile } from '../lib/cli/schema-codegen.js';
7
+ import { applyExcludeColumns, applyExcludeTables, buildTableMap, generateSchemaFile, } from '../lib/cli/schema-codegen.js';
8
8
  import { loadEnvFile, CONSOLE_COLORS, display, displayAsTableRow, parseArgs, runCli } from './utils.js';
9
9
  const STATUS_COLOR = {
10
10
  created: CONSOLE_COLORS.green,
@@ -157,6 +157,12 @@ await runCli('fastify-auto-sqlapi: generating schemas', async () => {
157
157
  if (excluded.length) {
158
158
  display(`Excluded by config (excludeTables): ${excluded.join(', ')}`, CONSOLE_COLORS.gray);
159
159
  }
160
+ // Runs after the table blacklist so a column pattern is never checked against a table the
161
+ // generator will not see anyway (its primary key included).
162
+ const excludedColumns = applyExcludeColumns(tableMap, config.excludeColumns);
163
+ if (excludedColumns.length) {
164
+ display(`Excluded by config (excludeColumns): ${excludedColumns.join(', ')}`, CONSOLE_COLORS.gray);
165
+ }
160
166
  const tableNames = cliArgs.tables;
161
167
  if (!keepOnlyRequestedTables(tableMap, tableNames)) {
162
168
  display(`No matching tables found for: ${tableNames.join(', ')}`, CONSOLE_COLORS.magenta);
@@ -16,5 +16,19 @@ export declare function buildTableMap(rows: ColumnInfo[]): TableMap;
16
16
  * Returns the names of the excluded tables, in map order.
17
17
  */
18
18
  export declare function applyExcludeTables(tableMap: TableMap, excludeTables?: string[]): string[];
19
+ /**
20
+ * Remove from every table in `tableMap` the columns matching any of the `excludeColumns`
21
+ * config patterns. A pattern is `<table>.<column>`, both parts matched against the DB names
22
+ * (not the generated field names) with `*` as the only wildcard on either side.
23
+ *
24
+ * An excluded column does not exist for the API at all: it leaves the Schema's fields, its
25
+ * `colMap` and its `generatedFields`, so every read, write, filter and manifest built from the
26
+ * Schema is unaware of it. That is also why the primary key is refused — every engine
27
+ * identifies rows with it — and why a table that would end up empty is refused too: that
28
+ * is `excludeTables`' job.
29
+ *
30
+ * Returns the excluded columns as `table.column`, in map order.
31
+ */
32
+ export declare function applyExcludeColumns(tableMap: TableMap, excludeColumns?: string[]): string[];
19
33
  export declare function generateSchemaFile(schemaName: string, tableName: string, fields: Record<string, string>, colMap: Record<string, string>, primary?: string[], generated?: string[], isView?: boolean): string;
20
34
  //# sourceMappingURL=schema-codegen.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"schema-codegen.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/schema-codegen.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE3D;;GAEG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,UAAU,EAAE;IACV,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB,GACA,MAAM,CA6DR;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,QAAQ,CA0C1D;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,EAAE,aAAa,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAoBzF;AAED,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,OAAO,CAAC,EAAE,MAAM,EAAE,EAClB,SAAS,CAAC,EAAE,MAAM,EAAE,EACpB,MAAM,CAAC,EAAE,OAAO,GACf,MAAM,CAoDR"}
1
+ {"version":3,"file":"schema-codegen.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/schema-codegen.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE3D;;GAEG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,UAAU,EAAE;IACV,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB,GACA,MAAM,CA6DR;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,QAAQ,CA0C1D;AAaD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,EAAE,aAAa,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAgBzF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,cAAc,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAiD3F;AAED,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,OAAO,CAAC,EAAE,MAAM,EAAE,EAClB,SAAS,CAAC,EAAE,MAAM,EAAE,EACpB,MAAM,CAAC,EAAE,OAAO,GACf,MAAM,CAoDR"}
@@ -95,6 +95,15 @@ export function buildTableMap(rows) {
95
95
  return acc;
96
96
  }, {});
97
97
  }
98
+ /** Compile a config glob — `*` is the only wildcard, everything else is literal. */
99
+ function globToRegExp(pattern) {
100
+ return new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replaceAll('\\*', '.*')}$`);
101
+ }
102
+ function assertPatternList(option, value) {
103
+ if (!Array.isArray(value) || value.some((p) => typeof p !== 'string')) {
104
+ throw new Error(`Invalid config: "${option}" must be an array of strings.`);
105
+ }
106
+ }
98
107
  /**
99
108
  * Remove from `tableMap` the tables matching any of the `excludeTables` config patterns.
100
109
  * Patterns are matched against the DB table name (not the generated schema name);
@@ -104,12 +113,10 @@ export function buildTableMap(rows) {
104
113
  export function applyExcludeTables(tableMap, excludeTables) {
105
114
  if (excludeTables === undefined)
106
115
  return [];
107
- if (!Array.isArray(excludeTables) || excludeTables.some((p) => typeof p !== 'string')) {
108
- throw new Error('Invalid config: "excludeTables" must be an array of strings.');
109
- }
116
+ assertPatternList('excludeTables', excludeTables);
110
117
  if (excludeTables.length === 0)
111
118
  return [];
112
- const regexes = excludeTables.map((pattern) => new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replaceAll('\\*', '.*')}$`));
119
+ const regexes = excludeTables.map(globToRegExp);
113
120
  const excluded = [];
114
121
  for (const schemaName of Object.keys(tableMap)) {
115
122
  const tableName = tableMap[schemaName].name;
@@ -120,6 +127,62 @@ export function applyExcludeTables(tableMap, excludeTables) {
120
127
  }
121
128
  return excluded;
122
129
  }
130
+ /**
131
+ * Remove from every table in `tableMap` the columns matching any of the `excludeColumns`
132
+ * config patterns. A pattern is `<table>.<column>`, both parts matched against the DB names
133
+ * (not the generated field names) with `*` as the only wildcard on either side.
134
+ *
135
+ * An excluded column does not exist for the API at all: it leaves the Schema's fields, its
136
+ * `colMap` and its `generatedFields`, so every read, write, filter and manifest built from the
137
+ * Schema is unaware of it. That is also why the primary key is refused — every engine
138
+ * identifies rows with it — and why a table that would end up empty is refused too: that
139
+ * is `excludeTables`' job.
140
+ *
141
+ * Returns the excluded columns as `table.column`, in map order.
142
+ */
143
+ export function applyExcludeColumns(tableMap, excludeColumns) {
144
+ if (excludeColumns === undefined)
145
+ return [];
146
+ assertPatternList('excludeColumns', excludeColumns);
147
+ if (excludeColumns.length === 0)
148
+ return [];
149
+ const matchers = excludeColumns.map((pattern) => {
150
+ const dot = pattern.indexOf('.');
151
+ if (dot <= 0 || dot === pattern.length - 1) {
152
+ throw new Error(`Invalid config: excludeColumns entry "${pattern}" must be in the form "table.column" ` +
153
+ `(a "*" wildcard is allowed on either side, e.g. "*.password_hash").`);
154
+ }
155
+ return {
156
+ table: globToRegExp(pattern.slice(0, dot)),
157
+ column: globToRegExp(pattern.slice(dot + 1)),
158
+ };
159
+ });
160
+ const excluded = [];
161
+ for (const schemaName of Object.keys(tableMap)) {
162
+ const table = tableMap[schemaName];
163
+ const tableMatchers = matchers.filter((m) => m.table.test(table.name));
164
+ if (tableMatchers.length === 0)
165
+ continue;
166
+ for (const field of Object.keys(table.fields)) {
167
+ const columnName = table.colMap[field];
168
+ if (!tableMatchers.some((m) => m.column.test(columnName)))
169
+ continue;
170
+ if (table.primary.includes(field)) {
171
+ throw new Error(`Invalid config: excludeColumns cannot exclude the primary key column ` +
172
+ `"${table.name}.${columnName}" — every route identifies rows with it.`);
173
+ }
174
+ delete table.fields[field];
175
+ delete table.colMap[field];
176
+ table.generated = table.generated.filter((f) => f !== field);
177
+ excluded.push(`${table.name}.${columnName}`);
178
+ }
179
+ if (Object.keys(table.fields).length === 0) {
180
+ throw new Error(`Invalid config: excludeColumns leaves table "${table.name}" with no columns. ` +
181
+ `To hide a whole table use excludeTables instead.`);
182
+ }
183
+ }
184
+ return excluded;
185
+ }
123
186
  export function generateSchemaFile(schemaName, tableName, fields, colMap, primary, generated, isView) {
124
187
  const fieldsStr = JSON.stringify(fields, null, 2).replace(/"(.*?)"\s*:\s*"Type(.*?)"/g, '$1: Type$2');
125
188
  const colMapStr = JSON.stringify(colMap, null, 2);
@@ -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).
@@ -1 +1 @@
1
- {"version":3,"file":"table-helpers.d.ts","sourceRoot":"","sources":["../../src/lib/table-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,yBAAyB,EAAiC,MAAM,wBAAwB,CAAC;AACzH,OAAO,EAAQ,KAAK,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,MAAM,EAEP,MAAM,aAAa,CAAC;AAcrB,KAAK,UAAU,CAAC,EAAE,IAAI,EAAE,SAAS,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAExH,wBAAgB,eAAe,CAC7B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,EAAE,SAAS,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,EAErE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,YAAY,GAAE,EAAa,EAC3B,iBAAiB,CAAC,EAAE,CAClB,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,yBAAyB;CAAE,KAC3E,IAAI,GACR;IAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAAC,OAAO,EAAE,aAAa,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAsChG;AAED,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAChB,MAAM,CAAC,CAAC,CAAC,CAWX;AAgSD,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AAgDD,wBAAgB,aAAa,CAC3B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAEjC,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,EAClD,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,EAC3B,OAAO,CAAC,EAAE,oBAAoB,GAC7B,cAAc,CAWhB;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,OAAO,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,GAC5B,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAE9B;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,KAAK,EAAE,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,EAAE,GACvC,GAAG,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAEjC"}
1
+ {"version":3,"file":"table-helpers.d.ts","sourceRoot":"","sources":["../../src/lib/table-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,yBAAyB,EAAiC,MAAM,wBAAwB,CAAC;AACzH,OAAO,EAAQ,KAAK,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,MAAM,EAEP,MAAM,aAAa,CAAC;AAcrB,KAAK,UAAU,CAAC,EAAE,IAAI,EAAE,SAAS,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAExH,wBAAgB,eAAe,CAC7B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,EAAE,SAAS,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,EAErE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,YAAY,GAAE,EAAa,EAC3B,iBAAiB,CAAC,EAAE,CAClB,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,yBAAyB;CAAE,KAC3E,IAAI,GACR;IAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAAC,OAAO,EAAE,aAAa,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAsChG;AAED,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAChB,MAAM,CAAC,CAAC,CAAC,CAWX;AAiSD,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AAgDD,wBAAgB,aAAa,CAC3B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAEjC,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,EAClD,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,EAC3B,OAAO,CAAC,EAAE,oBAAoB,GAC7B,cAAc,CAWhB;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,OAAO,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,GAC5B,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAE9B;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,KAAK,EAAE,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,EAAE,GACvC,GAAG,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAEjC"}
@@ -250,7 +250,8 @@ function validateWriteExclude(config) {
250
250
  }
251
251
  if (config.readExclude?.includes(field)) {
252
252
  throw new Error(`defineTable: field '${field}' on table '${table}' is in both readExclude and ` +
253
- `writeExclude, leaving it neither readable nor writable. Remove it from the Schema instead.`);
253
+ `writeExclude, leaving it neither readable nor writable. Exclude it at generation time ` +
254
+ `with excludeColumns in sqlapi.config.ts instead.`);
254
255
  }
255
256
  }
256
257
  }
@@ -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),
@@ -10,6 +10,16 @@ export interface SqlApiConfig {
10
10
  * Excluded schemas already on disk are removed as orphans on the next full run.
11
11
  */
12
12
  excludeTables?: string[];
13
+ /**
14
+ * Columns to leave out of the generated Schemas (`sqlapi-generate-schema`), as
15
+ * `"<table>.<column>"` entries matched against the DB names; `*` is a wildcard on either
16
+ * side (e.g. "user.password_hash", "*.internal_*"). An excluded column does not exist for
17
+ * the API at all — no read, no write, no filter — unlike `readExclude` / `writeExclude`
18
+ * on the table config, which hide one side of a column that stays in the Schema. A primary
19
+ * key column cannot be excluded, and a table left with no columns is refused: use
20
+ * `excludeTables` for that.
21
+ */
22
+ excludeColumns?: string[];
13
23
  }
14
24
  export interface ColumnInfo {
15
25
  table_name: string;
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/types/cli.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,yFAAyF;IACzF,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;;;;OAQG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,QAAQ;IACvB,CAAC,UAAU,EAAE,MAAM,GAAG;QACpB,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC/B,iFAAiF;QACjF,OAAO,EAAE,MAAM,EAAE,CAAC;QAClB,mFAAmF;QACnF,SAAS,EAAE,MAAM,EAAE,CAAC;QACpB,oFAAoF;QACpF,MAAM,EAAE,OAAO,CAAC;KACjB,CAAC;CACH"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/types/cli.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,yFAAyF;IACzF,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;;;;OAQG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,QAAQ;IACvB,CAAC,UAAU,EAAE,MAAM,GAAG;QACpB,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC/B,iFAAiF;QACjF,OAAO,EAAE,MAAM,EAAE,CAAC;QAClB,mFAAmF;QACnF,SAAS,EAAE,MAAM,EAAE,CAAC;QACpB,oFAAoF;QACpF,MAAM,EAAE,OAAO,CAAC;KACjB,CAAC;CACH"}
@@ -1,6 +1,7 @@
1
1
  # 0017. The database schema is the API contract — by default, and only by default
2
2
 
3
- - **Status**: accepted
3
+ - **Status**: accepted (the "trim it out of the Schema" lever below is `excludeColumns` since
4
+ [0019](./0019-column-exclusion-is-generation-time-config.md) — the Schema file is never edited)
4
5
  - **Date**: 2026-08-28
5
6
 
6
7
  ## Context
@@ -0,0 +1,62 @@
1
+ # 0019. Removing a column from the API is generation-time config — `excludeColumns`, never a Schema edit
2
+
3
+ - **Status**: accepted
4
+ - **Date**: 2026-09-13
5
+
6
+ ## Context
7
+
8
+ [ADR 0017](./0017-the-schema-is-the-contract.md) lists one lever per way the exposed shape may
9
+ differ from the stored one. For *"a column must not exist for the API at all"* it said: trim it
10
+ out of the Schema. That lever did not exist. Schema files are disposable mirrors regenerated on
11
+ every run ([ADR 0007](./0007-explicit-table-generation.md), [ADR 0009](./0009-schema-exclusion-config-blacklist.md)),
12
+ so a trimmed Schema survived exactly until the next `sqlapi-generate-schema`. The two runtime
13
+ levers do not cover the case either: `readExclude` hides one side, `writeExclude` the other, and
14
+ `defineTable` refuses a field in both — because a column that is neither readable nor writable
15
+ still sits in the validation schema, the Swagger output and the manifest, where it is a lie.
16
+
17
+ ## Decision
18
+
19
+ Column exclusion is a generation-time config, next to `excludeTables`: `excludeColumns:
20
+ string[]` in `sqlapi.config.ts`, entries of the form `"<table>.<column>"`, both parts matched
21
+ against the DB names with `*` as the only wildcard. A matched column is removed from the
22
+ generated Schema — its fields, its `colMap`, its `generatedFields` — so everything built from
23
+ the Schema (routes, filters, ordering, joins, Swagger, manifest, TypeScript types) is unaware of
24
+ it. There is nothing to hide at runtime because there is nothing there.
25
+
26
+ Two refusals, both loud at generation time:
27
+
28
+ - **A primary key column cannot be excluded.** Every route identifies rows with it; the table
29
+ generator would either invent a key or write a placeholder, and either is silent damage.
30
+ - **A table cannot be left with no columns.** That is what `excludeTables` is for, and one
31
+ mechanism per intent keeps the config readable.
32
+
33
+ `excludeColumns` runs after `excludeTables`, so a column pattern is never checked against a
34
+ table the generator will not see — a `*.id` glob does not trip on the primary key of a table
35
+ that is excluded anyway.
36
+
37
+ ## Alternatives considered
38
+
39
+ - **Editing the generated Schema file** — the documented answer until now, rejected: the file
40
+ is overwritten on every run, so the edit is a hand-maintained exception under `schemas/`,
41
+ exactly what ADR 0009 refused to support for whole tables.
42
+ - **A runtime `exclude` key on `defineTable`** — rejected: the Schema is imported by the
43
+ consumer's code before `defineTable` runs, so its TypeScript type, its `validation` object and
44
+ the `$id`-registered JSON Schema would still carry the column. A runtime lever can only hide;
45
+ the column would remain visible in Swagger, in the manifest and in the `TypeSchema` type, and
46
+ every reader of those would have to know about the second layer.
47
+ - **Allowing `readExclude` + `writeExclude` on the same field** — rejected for the same reason:
48
+ it is the runtime lever again, with the added cost of a rule that means "does not exist" but
49
+ reads as two independent hidings.
50
+ - **A per-column option inside the Schema file** — rejected: the Schema file carries no config,
51
+ by design; `sqlapi.config.ts` is where the generator's inputs already live.
52
+
53
+ ## Consequences
54
+
55
+ - Adding an entry to `excludeColumns` changes the generated Schema on the next run and, for
56
+ TypeScript consumers, fails compilation wherever the column was referenced. Intended: that is
57
+ ADR 0017's "checked, not implied" at work.
58
+ - The relation-level narrowing described in [ADR 0010](./0010-joins-do-not-run-route-guards.md)
59
+ — a relation declared with a narrower Schema object — is a different tool for a different
60
+ scope (per relation, not per API) and is unaffected.
61
+ - An `excludeColumns` entry that is not `table.column`, that reaches a primary key, or that
62
+ empties a table fails the run with an explicit error rather than being ignored.
@@ -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.
@@ -33,6 +33,8 @@ not by editing history.
33
33
  | [0016](./0016-a-view-is-a-table-config.md) | A view is a table config — the generator refuses to invent its primary key |
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
+ | [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 |
36
38
 
37
39
  ## When to write one
38
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fastify-auto-sqlapi",
3
- "version": "0.2.3",
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",