fastify-auto-sqlapi 0.1.14 → 0.2.1

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 (101) hide show
  1. package/AGENTS_BACKEND.md +52 -7
  2. package/BREAKING_CHANGES.md +86 -5
  3. package/CHANGELOG.md +57 -0
  4. package/README.md +110 -3
  5. package/dist/bin/generate-schema.js +89 -77
  6. package/dist/bin/generate-tables.js +72 -62
  7. package/dist/bin/utils.d.ts.map +1 -1
  8. package/dist/bin/utils.js +47 -33
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/lib/agent/manifest.d.ts +3 -2
  12. package/dist/lib/agent/manifest.d.ts.map +1 -1
  13. package/dist/lib/agent/manifest.js +102 -80
  14. package/dist/lib/cli/mysql-introspect.d.ts +4 -9
  15. package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
  16. package/dist/lib/cli/mysql-introspect.js +8 -7
  17. package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
  18. package/dist/lib/cli/pg-introspect.js +0 -1
  19. package/dist/lib/cli/schema-codegen.d.ts +3 -0
  20. package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
  21. package/dist/lib/cli/schema-codegen.js +3 -0
  22. package/dist/lib/cli/tables-codegen.d.ts +8 -1
  23. package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
  24. package/dist/lib/cli/tables-codegen.js +100 -118
  25. package/dist/lib/condition-methods.d.ts +14 -4
  26. package/dist/lib/condition-methods.d.ts.map +1 -1
  27. package/dist/lib/condition-methods.js +18 -4
  28. package/dist/lib/db.d.ts +7 -2
  29. package/dist/lib/db.d.ts.map +1 -1
  30. package/dist/lib/db.js +24 -6
  31. package/dist/lib/dialect.d.ts +2 -1
  32. package/dist/lib/dialect.d.ts.map +1 -1
  33. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  34. package/dist/lib/engine/bulk/bulk-upsert.js +44 -36
  35. package/dist/lib/engine/rest/delete.d.ts.map +1 -1
  36. package/dist/lib/engine/rest/get.d.ts.map +1 -1
  37. package/dist/lib/engine/rest/get.js +5 -4
  38. package/dist/lib/engine/rest/insert.d.ts.map +1 -1
  39. package/dist/lib/engine/rest/insert.js +1 -1
  40. package/dist/lib/engine/rest/update.d.ts.map +1 -1
  41. package/dist/lib/engine/rest/update.js +26 -19
  42. package/dist/lib/engine/search/aggregations.d.ts +33 -0
  43. package/dist/lib/engine/search/aggregations.d.ts.map +1 -0
  44. package/dist/lib/engine/search/aggregations.js +109 -0
  45. package/dist/lib/engine/search/conditions.d.ts +31 -0
  46. package/dist/lib/engine/search/conditions.d.ts.map +1 -0
  47. package/dist/lib/engine/search/conditions.js +131 -0
  48. package/dist/lib/engine/search/fields.d.ts +49 -0
  49. package/dist/lib/engine/search/fields.d.ts.map +1 -0
  50. package/dist/lib/engine/search/fields.js +142 -0
  51. package/dist/lib/engine/search/join-families.d.ts +35 -0
  52. package/dist/lib/engine/search/join-families.d.ts.map +1 -0
  53. package/dist/lib/engine/search/join-families.js +345 -0
  54. package/dist/lib/engine/search/joins.d.ts +55 -0
  55. package/dist/lib/engine/search/joins.d.ts.map +1 -0
  56. package/dist/lib/engine/search/joins.js +81 -0
  57. package/dist/lib/engine/search/order-by.d.ts +35 -0
  58. package/dist/lib/engine/search/order-by.d.ts.map +1 -0
  59. package/dist/lib/engine/search/order-by.js +104 -0
  60. package/dist/lib/engine/search/search.d.ts.map +1 -1
  61. package/dist/lib/engine/search/search.js +106 -791
  62. package/dist/lib/engine/validate.d.ts +0 -5
  63. package/dist/lib/engine/validate.d.ts.map +1 -1
  64. package/dist/lib/engine/write-helpers.d.ts +25 -17
  65. package/dist/lib/engine/write-helpers.d.ts.map +1 -1
  66. package/dist/lib/engine/write-helpers.js +86 -44
  67. package/dist/lib/errors.d.ts +24 -0
  68. package/dist/lib/errors.d.ts.map +1 -1
  69. package/dist/lib/errors.js +59 -0
  70. package/dist/lib/read-access.d.ts +12 -1
  71. package/dist/lib/read-access.d.ts.map +1 -1
  72. package/dist/lib/read-access.js +20 -4
  73. package/dist/lib/schema/helpers.d.ts +0 -15
  74. package/dist/lib/schema/helpers.d.ts.map +1 -1
  75. package/dist/lib/schema/helpers.js +7 -4
  76. package/dist/lib/schema/search.d.ts.map +1 -1
  77. package/dist/lib/schema/search.js +107 -128
  78. package/dist/lib/sql-api.d.ts.map +1 -1
  79. package/dist/lib/table-helpers.d.ts.map +1 -1
  80. package/dist/lib/table-helpers.js +48 -0
  81. package/dist/lib/tenant.d.ts +66 -5
  82. package/dist/lib/tenant.d.ts.map +1 -1
  83. package/dist/lib/tenant.js +209 -48
  84. package/dist/routes/auto/plugin.d.ts.map +1 -1
  85. package/dist/routes/auto/plugin.js +6 -0
  86. package/dist/routes/auto/route-helpers.d.ts +0 -7
  87. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  88. package/dist/routes/auto/route-helpers.js +58 -33
  89. package/dist/routes/auto/search.routes.js +0 -1
  90. package/dist/types/plugin.d.ts +19 -0
  91. package/dist/types/plugin.d.ts.map +1 -1
  92. package/dist/types/table.d.ts +1 -1
  93. package/dist/types/tenant.d.ts +26 -2
  94. package/dist/types/tenant.d.ts.map +1 -1
  95. package/docs/GLOSSARY.md +30 -0
  96. package/docs/adr/0006-raw-db-errors.md +2 -1
  97. package/docs/adr/0010-joins-do-not-run-route-guards.md +4 -1
  98. package/docs/adr/0012-anti-duplication-tooling.md +59 -0
  99. package/docs/adr/0013-sanitized-db-errors.md +114 -0
  100. package/docs/adr/README.md +4 -1
  101. package/package.json +29 -2
package/AGENTS_BACKEND.md CHANGED
@@ -275,6 +275,8 @@ export const TableCustomer = defineTable({
275
275
  // column: 'organization_id',
276
276
  // through: { schema: SchemaCustomer, localField: 'customerId', foreignField: 'id' },
277
277
  // },
278
+ // OR anyOf: several owner columns, the row is visible to any of them
279
+ // tenantScope: { anyOf: ['requester_agent_id', 'target_agent_id'] },
278
280
 
279
281
  // SCHEMA OVERRIDES — tighten auto-generated schema validation without editing Schema files
280
282
  schemaOverrides: {
@@ -462,7 +464,7 @@ export const TableCustomer = defineTable({
462
464
 
463
465
  ## Computed Fields (extension system)
464
466
 
465
- `computedFields` lets you declare **virtual fields** as SQL expressions on a per-table basis. Each computed becomes usable like a regular schema field across the search API: `filters` (equality), `conditions` (operators), `orderBy` (1-parte), `computeMin/Max/Sum/Avg`, and (opt-in) in `selectComputed` for the main response. Same machinery serves JSON column extraction, derived strings, dialect-aware date/calendar bucketing — without growing the library case-by-case.
467
+ `computedFields` lets you declare **virtual fields** as SQL expressions on a per-table basis. Each computed becomes usable like a regular schema field across the search API: `filters` (equality), `conditions` (operators), `orderBy` (1-part), `computeMin/Max/Sum/Avg`, and (opt-in) in `selectComputed` for the main response. Same machinery serves JSON column extraction, derived strings, dialect-aware date/calendar bucketing — without growing the library case-by-case.
466
468
 
467
469
  ```typescript
468
470
  import { defineTable, exportTableInfo, Type } from 'fastify-auto-sqlapi';
@@ -546,7 +548,7 @@ For `joinLeft` specifically, the computed expr is automatically alias-prefixed b
546
548
 
547
549
  - **Bound `values` in computed expressions** work anywhere the expression lands in the `WHERE` clause or in `ORDER BY`: `filters` and `conditions` (main, `joinMustExist`, `joinMultiple`, `joinGroup`, `joinLeft`) and `orderBy`. The ConditionBuilder assigns the placeholder positions there, so no coordination is needed from the caller.
548
550
  - They are still rejected with 400 on `selectComputed`, `computeMin/Max/Sum/Avg`, `joinGroup.aggregations.by` and `defaultOrder`. Those expressions are emitted in the `SELECT` / `GROUP BY` list, which precedes the `WHERE` values in the parameter order — there is no correct position for them there. Most use cases (JSON extraction, concat, dateTrunc, simple ops) need no bound values at all.
549
- - **Computed CAN be used in `joinGroup.aggregations.by`** (just pass the computed name as a string). Bound `values` are not supported in this position (rejected with 400 — it is emitted in the `SELECT`/`GROUP BY` list). The existing FK-correlation rule for `orderBy <alias>.<fn>.<field>` still applies: 3-parti aggregation orderBy on a `by` that isn't the correlation FK is rejected — by definition a computed-by produces multiple groups per main row, so this is never valid.
551
+ - **Computed CAN be used in `joinGroup.aggregations.by`** (just pass the computed name as a string). Bound `values` are not supported in this position (rejected with 400 — it is emitted in the `SELECT`/`GROUP BY` list). The existing FK-correlation rule for `orderBy <alias>.<fn>.<field>` still applies: 3-part aggregation orderBy on a `by` that isn't the correlation FK is rejected — by definition a computed-by produces multiple groups per main row, so this is never valid.
550
552
  - **Computed cannot be used as aggregation function** (`sum`/`min`/`max`/...). The values inside `aggregations.sum: ['<name>']` must be schema field names. To aggregate on a derived expression, declare the derivation as a computed and pass the computed name as the field, BUT only via `computeMin/Max/Sum/Avg` (top-level main aggregates), not the joinGroup ones.
551
553
  - **No chained computed** (a computed referencing another computed). Flat-only.
552
554
  - **Read-only**. Computed fields are not usable in insert/update bodies — the consumer's `expr` is for SELECT/WHERE/ORDER BY, never for writes.
@@ -635,8 +637,8 @@ const sqlApi = createSqlApi(db, dbTables, { dialect: 'mysql' });
635
637
  ```typescript
636
638
  // Search — full filter, join, pagination, aggregation support
637
639
  // orderBy supports two dotted notations:
638
- // - 3-parti `<alias>.<fn>.<field>` for joinGroup aggregations (must be declared in the request body)
639
- // - 2-parti `<alias>.<field>` for joinLeft parent fields
640
+ // - 3-part `<alias>.<fn>.<field>` for joinGroup aggregations (must be declared in the request body)
641
+ // - 2-part `<alias>.<field>` for joinLeft parent fields
640
642
  // See AGENTS_FRONTEND.md and BREAKING_CHANGES.md for the full reference.
641
643
  sqlApi.search(tableName, {
642
644
  filters?, conditions?,
@@ -771,6 +773,46 @@ Behavior:
771
773
  - **Update**: pre-check via SELECT with INNER JOIN (404 if not found). If the payload also changes the through-FK (`localField`), the new value is re-validated against the tenant — moving a record into another tenant's scope is rejected (403).
772
774
  - **Delete**: subquery `DELETE WHERE pk IN (SELECT ... INNER JOIN ... WHERE tenant IN (...))`
773
775
 
776
+ ### Shared tenant (`anyOf`: several owner columns)
777
+
778
+ For a row owned by two parties and visible to either — a message (`sender_id` / `recipient_id`),
779
+ a transfer (`from_account_id` / `to_account_id`), a shift swap:
780
+
781
+ ```typescript
782
+ const TableShiftSwapRequest = defineTable({
783
+ primary: 'id',
784
+ ...exportTableInfo(SchemaShiftSwapRequest),
785
+ tenantScope: { anyOf: ['requester_agent_id', 'target_agent_id'] },
786
+ });
787
+ ```
788
+
789
+ Behavior:
790
+ - **Read** (search, get, delete, bulk-delete): adds
791
+ `AND (requester_agent_id IN ($N) OR target_agent_id IN ($M))`. A `NULL` column does not match
792
+ and does not stop the other column from matching. A row with every listed column `NULL` is
793
+ visible to nobody but an admin.
794
+ - **Joins**: the predicate crosses every join family — `joinMultiple`, `joinGroup`,
795
+ `joinMustExist`, and `joinLeft` (in the `ON` clause, alias-qualified, so `LEFT` semantics
796
+ survive) — exactly as a single-column scope does, including from a host table that declares no
797
+ scope of its own.
798
+ - **Insert**: no auto-injection. The payload must **anchor** the row: at least one listed column
799
+ present and holding a tenant id → `400` if none is present, `403` if present but all foreign.
800
+ The other parties are stored exactly as sent. A multi-id caller is never ambiguous here,
801
+ because nothing is being guessed.
802
+ - **Update**: strips **every** listed column from the `SET` — neither party can be re-assigned —
803
+ and adds the OR predicate to the `WHERE`. A row outside the scope answers `404`.
804
+ - **Bulk upsert**: the anchor rule applies to every item (so an upsert must name a party even
805
+ when it only means to update), and the listed columns are excluded from the `DO UPDATE SET`,
806
+ so an upsert onto an existing row cannot take over the other party's slot. The conflict guard
807
+ rejects (`403`) a conflict key matching a row visible to neither party — including a row whose
808
+ parties are `NULL`.
809
+ - **Secondaries**: a table with an `anyOf` scope written through another table's
810
+ `allowedWriteJoins` follows the same anchor rule. The parent's FK is auto-filled but is not an
811
+ owner, so the child payload must still name a party.
812
+
813
+ Not combinable with `column` or `through` — `defineTable` throws at startup. An `anyOf` entry
814
+ reached through a parent FK is not supported; declare the scope on the parent instead.
815
+
774
816
  ### Multi-tenant users
775
817
 
776
818
  ```typescript
@@ -793,8 +835,10 @@ Tables without `tenantScope` are unaffected — no filtering regardless of `getT
793
835
  ### Error codes
794
836
 
795
837
  - **403** — Record doesn't belong to tenant, or explicit tenant value doesn't match
796
- - **400** — Multi-tenant user on insert without explicit tenant value (ambiguous)
797
- - **404** — Update with indirect tenant, record not found for this tenant
838
+ - **400** — Multi-tenant user on insert without explicit tenant value (ambiguous), or an
839
+ `anyOf` write naming no owner column at all
840
+ - **404** — Update with indirect tenant, record not found for this tenant; get/update/delete of
841
+ an `anyOf`-scoped row outside the scope
798
842
 
799
843
  ---
800
844
 
@@ -812,8 +856,9 @@ Tables without `tenantScope` are unaffected — no filtering regardless of `getT
812
856
  - **All hooks and validators receive camelCase records**: `validate` and the whole hook matrix (`beforeInsert`/`afterInsert`, `beforeUpdate`/`afterUpdate`, `beforeDelete`/`afterDelete`, `beforeBulkDelete`/`afterBulkDelete`) 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.
813
857
  - **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 } } })`.
814
858
  - **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.
859
+ - **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.
815
860
  - **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. Programmatic `sqlApi.*` calls are uncapped.
816
- - **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).
861
+ - **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`).
817
862
 
818
863
  ---
819
864
 
@@ -144,7 +144,7 @@ Real `LEFT JOIN` for N:1 (parent) relationships. The parent rows are returned in
144
144
 
145
145
  The engine attaches a real `LEFT JOIN <parentTable> AS <alias>` to the main query **only when needed**:
146
146
  - when the request includes `filters` or `conditions` on the parent (effectively turns into INNER JOIN behavior on those aliases — main rows whose parent doesn't match are excluded);
147
- - when `orderBy` uses 2-parti dotted notation referring to a parent field (`alias.field`).
147
+ - when `orderBy` uses 2-part dotted notation referring to a parent field (`alias.field`).
148
148
 
149
149
  Otherwise only a side query `WHERE pk IN (distinct fk values)` is issued — no LEFT JOIN, no row duplication. Either way, the response shape is the same.
150
150
 
@@ -158,7 +158,7 @@ Otherwise only a side query `WHERE pk IN (distinct fk values)` is issued — no
158
158
  | `<alias>.<field>` | `joinLeft` (`unique: true` aliases) | orders main query by a parent field via LEFT JOIN |
159
159
  | `<alias>.<fn>.<field>` | `joinGroup` (declared in the same body) | orders main query by an aggregation via correlated scalar subquery |
160
160
 
161
- Ambiguity: 2-parti and 3-parti are disambiguated by counting dots. The 2-parti is rejected unless the alias is in `joinLeft` allowlist; 3-parti is rejected unless the alias is in the request `joinGroup` declaration.
161
+ Ambiguity: 2-part and 3-part are disambiguated by counting dots. The 2-part is rejected unless the alias is in `joinLeft` allowlist; 3-part is rejected unless the alias is in the request `joinGroup` declaration.
162
162
 
163
163
  ## Response shape
164
164
 
@@ -248,7 +248,7 @@ If you used the join table's name as the secondaries key, change it to the new a
248
248
 
249
249
  `<table>.<fn>.<field>` still works for joinGroup, but `<table>` must now be the alias declared in `buildRelation`. If the alias differs from the old `tableName`, update the string.
250
250
 
251
- If you need to order by a parent field, **add a `joinLeft`-eligible relation** (with `unique: true`) and use 2-parti notation `alias.field` (NEW capability — not available before).
251
+ If you need to order by a parent field, **add a `joinLeft`-eligible relation** (with `unique: true`) and use 2-part notation `alias.field` (NEW capability — not available before).
252
252
 
253
253
  ## Validation summary (400 responses)
254
254
 
@@ -257,8 +257,8 @@ The engine rejects, with `statusCode: 400`, any of:
257
257
  - `joinLeft` referencing an alias declared with `unique: false`
258
258
  - `joinMultiple`/`joinMustExist`/`joinGroup` referencing an alias declared with `unique: true`
259
259
  - request keys referencing an alias not declared in `allowedReadJoins`
260
- - `orderBy` 2-parti `<alias>.<field>` whose alias is not in `joinLeft`-eligible (i.e. `unique:true`) declarations
261
- - `orderBy` 3-parti `<alias>.<fn>.<field>` whose alias or `(fn, field)` pair is not declared in `joinGroup` for the same request
260
+ - `orderBy` 2-part `<alias>.<field>` whose alias is not in `joinLeft`-eligible (i.e. `unique:true`) declarations
261
+ - `orderBy` 3-part `<alias>.<fn>.<field>` whose alias or `(fn, field)` pair is not declared in `joinGroup` for the same request
262
262
  - `defineTable` with two `allowedReadJoins`/`allowedWriteJoins` entries resolving to the same alias (thrown at startup, not at request time)
263
263
 
264
264
  ---
@@ -455,3 +455,84 @@ returns a `400` naming the reason.
455
455
 
456
456
  Use `joinMustExist` on the same relation when you need an extra filter on the parent — that path
457
457
  delegates to the target's own `filters()` and does run `extendedCondition`.
458
+
459
+ ---
460
+
461
+ # Breaking Change — the tenant follows the request, not the table it addresses
462
+
463
+ `tenantScope` on a table is now enforced even when the request was addressed to a **different**
464
+ table that reaches it through a relation. Previously the tenant was resolved from the addressed
465
+ table alone: if that table declared no `tenantScope`, no predicate was applied anywhere — not to
466
+ it, and not to the scoped tables it joined or wrote into.
467
+
468
+ ## Why this changed
469
+
470
+ A relation in `allowedReadJoins` is a read grant on its target, and one in `allowedWriteJoins` is
471
+ a write grant (see [ADR 0010](./docs/adr/0010-joins-do-not-run-route-guards.md)). `tenantScope`
472
+ was documented as the one request-derived protection that crosses a join — the cap that makes a
473
+ relation safe to declare. It did not cross when the *host* was open:
474
+
475
+ ```typescript
476
+ // shift is public on purpose: everyone may see who works when.
477
+ shift: {
478
+ ...exportTableInfo(SchemaShift),
479
+ allowedReadJoins: [buildRelation(SchemaShift, 'id', SchemaSwapRequest, 'shiftId', { alias: 'swap' })],
480
+ },
481
+ // …but why somebody wants to change the roster is not public.
482
+ shift_swap_request: {
483
+ ...exportTableInfo(SchemaSwapRequest),
484
+ tenantScope: { column: 'agent_id' },
485
+ },
486
+ ```
487
+
488
+ ```sql
489
+ -- POST /search/shift { "joinMultiple": { "swap": {} } } — before
490
+ SELECT … FROM shift_swap_request WHERE (shift_id IN ($1))
491
+ -- after
492
+ SELECT … FROM shift_swap_request WHERE (shift_id IN ($1) AND (agent_id IN ($2)))
493
+ ```
494
+
495
+ Every scoped row of the neighbour was readable through the open host, and writable through it as
496
+ a secondary. The declaration said "scoped"; the deployment was not. Nothing in the response said
497
+ so, which is the dangerous direction — the same reasoning as unknown filter keys above.
498
+
499
+ ## What changed
500
+
501
+ - `TenantContext.scope` is now **optional**. `ids` is request-level and travels with the request;
502
+ `scope` is table-level and absent when the addressed table declares none. Absent `scope` means
503
+ "no filter on *this* table", never "no tenant" — related tables still enforce their own.
504
+ - `getTenantId` is now called for a request addressed to an unscoped table **when one of that
505
+ table's declared relations points at a scoped table**. It is still not called when nothing
506
+ scoped is in reach, so genuinely tenant-free routes are unaffected. Reach is one level: joins
507
+ and secondaries resolve from the addressed table's own relations and never chain further.
508
+
509
+ ## Who is affected
510
+
511
+ - **You declare `tenantScope` on a table that another table joins or writes into, where that
512
+ other table has no scope of its own.** Those requests now return fewer rows and can now answer
513
+ `403`. This is the fix: the rows were never yours to serve. Check any UI that displayed them.
514
+ - **Your `getTenantId` assumes an authenticated request.** It now runs on routes that previously
515
+ skipped it — a public host with a scoped neighbour. A body like
516
+ `(request) => request.user.organizationId` throws there, turning a `200` into a `500`.
517
+ - **You read `tenant.scope` in TypeScript** (custom routes using the exported `TenantContext`).
518
+ Its type is now `TenantScope | undefined`.
519
+
520
+ Not affected: tables with no `tenantScope` anywhere in reach, deployments without `getTenantId`,
521
+ admins (`getTenantId` → `null`), and every table that already declared its own scope — their SQL
522
+ is unchanged.
523
+
524
+ ## Migration
525
+
526
+ Make `getTenantId` total. It already had to return `null` for admins, so the null case exists:
527
+
528
+ ```typescript
529
+ // Before — throws on a route that never had a user
530
+ getTenantId: (request) => request.user.organizationId,
531
+
532
+ // After — an absent user is simply an unscoped caller
533
+ getTenantId: (request) => request.user?.organizationId ?? null,
534
+ ```
535
+
536
+ Then decide, for each open table that relates to a scoped one, whether the relation was meant to
537
+ be a read grant at all. If it was not, remove it from `allowedReadJoins` — a relation is the
538
+ grant, and the scope is only the cap on it.
package/CHANGELOG.md CHANGED
@@ -9,8 +9,65 @@ Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BR
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ### Added
13
+
14
+ - **`tenantScope` over several owner columns — `{ anyOf: [...] }`.** A row that belongs to two
15
+ parties and is visible to either — a message (`sender_id` / `recipient_id`), a transfer
16
+ (`from_account_id` / `to_account_id`), a shift swap — could not be expressed before, leaving
17
+ the table open or the route hand-written. Reads add
18
+ `AND (colA IN (…) OR colB IN (…))`, alias-qualified, and the predicate crosses every join
19
+ family including `joinLeft`, where it lands in the `ON` clause so `LEFT` semantics survive.
20
+ A `NULL` column does not match and does not stop the other one from matching.
21
+ Writes cannot auto-inject an owner, so the payload **anchors** the row instead: at least one
22
+ listed column present and holding a tenant id, otherwise `400` (nothing to anchor to) or `403`
23
+ (present but foreign); the other party is stored exactly as sent. Updates strip every listed
24
+ column from the `SET`, and upserts exclude them from the `DO UPDATE` — neither party can be
25
+ re-assigned. The upsert conflict guard is null-safe for this shape: a row whose parties are
26
+ all `NULL` is visible to nobody, where a plain `NOT IN` would have read "unknown" as "not
27
+ foreign" and handed it over. Purely additive: `{ column }` and `{ column, through }` produce
28
+ byte-identical SQL. The three forms do not mix — `defineTable` throws at startup on `anyOf`
29
+ combined with `column` or `through`, on an empty or malformed `anyOf`, and on a scope
30
+ declaring no owner column at all.
31
+ - `QueryClient.bulkInsertOrUpdate` takes an optional trailing `immutableCols`: columns written
32
+ on insert but left untouched when the statement lands on an existing row. Additive.
33
+
12
34
  ### Changed
13
35
 
36
+ - **BREAKING: the tenant follows the request, not the table it addresses.** `tenantScope` on a
37
+ table is now enforced even when the request was addressed to a different table that reaches it
38
+ through a relation. Previously the tenant was resolved from the addressed table alone, so a
39
+ host declaring no scope of its own carried no predicate into the scoped tables it joined or
40
+ wrote into — every scoped row of the neighbour was readable through the open host, and writable
41
+ through it as a secondary, while the declaration said otherwise. A relation is a read grant and
42
+ a write join a write grant ([ADR 0010](./docs/adr/0010-joins-do-not-run-route-guards.md)), and
43
+ `tenantScope` is documented as the cap that makes them safe to declare; it now actually is.
44
+ `TenantContext.scope` becomes optional — `ids` is request-level, `scope` is table-level and
45
+ absent means "no filter on this table", never "no tenant". `getTenantId` is now consulted for a
46
+ request addressed to an unscoped table when one of its declared relations points at a scoped
47
+ one; it is still never called when nothing scoped is in reach. Tables that already declared
48
+ their own scope emit unchanged SQL. Migration — including the `getTenantId` body that now needs
49
+ to tolerate an unauthenticated request — in
50
+ [BREAKING_CHANGES.md](./BREAKING_CHANGES.md#breaking-change--the-tenant-follows-the-request-not-the-table-it-addresses).
51
+ - **Database errors no longer reach clients verbatim.** Any error escaping an auto-generated
52
+ route that the plugin did not raise itself now answers `{"statusCode":500,"error":"Internal
53
+ Server Error","message":"Internal Server Error"}` instead of the driver message, which names
54
+ tables, columns and constraints (`duplicate key value violates unique constraint
55
+ "users_email_key"`). Errors the plugin raises are deliberate `4xx` written for clients and
56
+ are untouched; **no status code is remapped** — a unique violation is still a `500`, per
57
+ [ADR 0006](./docs/adr/0006-raw-db-errors.md). The original error is logged through
58
+ `request.log.error` and attached as `err.cause`, so a consumer `setErrorHandler` can still
59
+ apply its own policy (`23505` → `409`). The guard covers route plugins registered standalone
60
+ as well. The sanitized body carries `requestId` — the `reqId` Fastify logs on every line — so
61
+ the entry holding the real error is one grep away. While developing — where a client building
62
+ against the API (an agent especially) may have no way to read the server log — the new
63
+ **`exposeDebugInfo: true`** attaches the driver error (message, `code`, `constraint`, stack)
64
+ as a `debugInfo` payload. It is additive: `statusCode`, `error`, `message` and `requestId`
65
+ keep their production shape, so client-side error handling does not fork between
66
+ environments. Nothing else flips it — not `debug`, not `NODE_ENV`: `debug` belongs to the
67
+ consumer, who may wire it to an environment variable, and it governs the log (access
68
+ controlled) rather than the response body (not, and these routes are open by default). See
69
+ [ADR 0013](./docs/adr/0013-sanitized-db-errors.md).
70
+
14
71
  - **Unknown `filters` keys are now rejected with `400 Unknown filter field: <key>`** instead
15
72
  of being dropped in silence — on the main table and on every join family. The engine only
16
73
  ever visited keys matching a schema field, an `extraFilters` entry or a computed field, so a
package/README.md CHANGED
@@ -37,7 +37,7 @@ Writes read the same way — *"sync these clients and their tags: update the one
37
37
 
38
38
  Conflict keys are declared server-side (`upsertMap`: here customer on `email`, tag on `(customerId, tag)`), so re-sending the same payload updates rows instead of duplicating them — client-side sync without hand-written diffing.
39
39
 
40
- And multi-tenancy is one plugin option (`getTenantId: (req) => req.user.organizationId`), enforced on reads *and* writes with zero code in handlers. Rows of other tenants are simply invisible (`404`); here a caller in org B tries to re-point one of their own orders to a customer belonging to org A:
40
+ And multi-tenancy is one plugin option (`getTenantId: (req) => req.user?.organizationId ?? null`), enforced on reads *and* writes with zero code in handlers. Rows of other tenants are simply invisible (`404`); here a caller in org B tries to re-point one of their own orders to a customer belonging to org A:
41
41
 
42
42
  ```jsonc
43
43
  // PUT /api/rest/order — order 7 is mine, customer 41 belongs to another tenant
@@ -368,7 +368,8 @@ await app.register(fastifyAutoSqlApi, {
368
368
  | `getTenantId` | `(req) => id \| null` | No | Tenant resolver for multi-tenant |
369
369
  | `maxItemsPerPage` | `number` | No | Max search page size, and the row `LIMIT` applied when no paginator is sent (default: `1000`) |
370
370
  | `maxBulkItems` | `number` | No | Max number of items accepted by the bulk endpoints (default: `1000`) |
371
- | `debug` | `boolean` | No | Log all SQL queries to console |
371
+ | `debug` | `boolean` | No | Log all SQL queries to console (server-side only — it never changes a response) |
372
+ | `exposeDebugInfo` | `boolean` | No | **Development only.** Attach the driver error (message, `code`, `constraint`, stack) to `500` responses as `debugInfo` (default: `false`) |
372
373
 
373
374
  The plugin picks up the connection from the Fastify instance: `fastify.pg` for PostgreSQL
374
375
  (what `@fastify/postgres` decorates) or `fastify.mysql` for MySQL/MariaDB. For MySQL,
@@ -476,6 +477,69 @@ malicious resource exhaustion — relevant precisely because the API is open by
476
477
  Both are configurable in the plugin options. The programmatic `fastify.sqlApi.*` methods are not
477
478
  capped — they run your own trusted code.
478
479
 
480
+ ### Error responses don't describe your schema
481
+
482
+ Driver messages name tables, columns and constraints (`duplicate key value violates unique
483
+ constraint "users_email_key"`). Any error the plugin did not raise itself is replaced with a
484
+ bare `500` before it reaches the client:
485
+
486
+ ```json
487
+ { "statusCode": 500, "error": "Internal Server Error", "message": "Internal Server Error", "requestId": "req-3" }
488
+ ```
489
+
490
+ `requestId` is the `reqId` Fastify logs on every line, so the entry holding the real error is
491
+ one grep away — without the error itself travelling to the client.
492
+
493
+ Errors the plugin raises are deliberate `4xx` with messages written for clients (validation,
494
+ not found, tenant violations) and are untouched. **No status code is remapped**: a unique
495
+ violation is still a `500`, not a `409` — deciding otherwise is product logic, and this is
496
+ where you do it:
497
+
498
+ ```typescript
499
+ app.setErrorHandler((err, request, reply) => {
500
+ if (err.cause?.code === '23505') {
501
+ return reply.status(409).send({ statusCode: 409, error: 'Conflict', message: 'Already exists' });
502
+ }
503
+ reply.send(err);
504
+ });
505
+ ```
506
+
507
+ The original error is never lost: it is logged through `request.log.error` and attached as
508
+ `err.cause`. Errors thrown by your own `onRequests` hooks run before the handler and are
509
+ unaffected.
510
+
511
+ **While developing, you probably want the detail on the wire** — an agent or a frontend
512
+ building against the API cannot always read your server log, and one round trip per failure
513
+ adds up. Say so explicitly:
514
+
515
+ ```typescript
516
+ await app.register(fastifyAutoSqlApi, {
517
+ DbTables: dbTables,
518
+ exposeDebugInfo: true, // development only — see below
519
+ });
520
+ ```
521
+
522
+ ```json
523
+ {
524
+ "statusCode": 500, "error": "Internal Server Error", "message": "Internal Server Error",
525
+ "requestId": "req-3",
526
+ "debugInfo": {
527
+ "message": "duplicate key value violates unique constraint \"users_email_key\"",
528
+ "code": "23505", "constraint": "users_email_key", "stack": "…"
529
+ }
530
+ }
531
+ ```
532
+
533
+ It is **additive**: `statusCode`, `error`, `message` and `requestId` keep the shape they have
534
+ in production, so your client-side error handling doesn't fork between environments.
535
+
536
+ Nothing else flips it — not `debug`, not `NODE_ENV`. `debug` is yours to wire (a constant in
537
+ one deployment, an environment variable in another), so the plugin cannot let it decide what
538
+ leaves the server: it writes to your log, which is access-controlled, while a response body is
539
+ not — and these routes are open by default. What a client sees is readable from the
540
+ registration call alone. See [ADR 0013](./docs/adr/0013-sanitized-db-errors.md) and
541
+ [ADR 0006](./docs/adr/0006-raw-db-errors.md).
542
+
479
543
  ### Write whitelist
480
544
 
481
545
  Insert/update/bulk-upsert bodies are `additionalProperties: false`: only columns present in the
@@ -590,7 +654,7 @@ Search with filters, advanced conditions, pagination, ordering, joins, and aggre
590
654
  - **`joinMustExist`** — EXISTS-based filter: "main rows where at least one related row matches". Accepts `{ filters, conditions }` (both optional). Aliases must come from `allowedReadJoins` declarations with `unique: false`.
591
655
  - **`joinMultiple`** — fetches related child rows in a side query. Accepts `{ filters, conditions, selection }`. Same `unique: false` aliases.
592
656
  - **`joinGroup`** — aggregations on the related table. Supports `sum`, `min`, `max`, `avg`, `count`, `distinctCount`, and optional `by` for GROUP BY (a schema field name or a computed-field name declared on the join table — e.g. for date bucketing declare a computed using `db.dateTrunc('month', qiCol('orderDate'))`). Accepts `{ filters, conditions }`. Same `unique: false` aliases.
593
- - **`joinLeft`** — embeds an N:1 parent. Real `LEFT JOIN` is added on demand (only when the request has `filters`/`conditions` on the parent or uses 2-parti `orderBy` on this alias). Aliases must be declared with `unique: true`. Accepts `{ filters, conditions, selection }`. Its `filters` accept schema and computed fields only: the parent's `extraFilters` need an alias-aware `extendedCondition`, so they are rejected with a `400` here instead of being silently ignored — use `joinMustExist` on the same relation when you need one.
657
+ - **`joinLeft`** — embeds an N:1 parent. Real `LEFT JOIN` is added on demand (only when the request has `filters`/`conditions` on the parent or uses 2-part `orderBy` on this alias). Aliases must be declared with `unique: true`. Accepts `{ filters, conditions, selection }`. Its `filters` accept schema and computed fields only: the parent's `extraFilters` need an alias-aware `extendedCondition`, so they are rejected with a `400` here instead of being silently ignored — use `joinMustExist` on the same relation when you need one.
594
658
 
595
659
  **Dot-notation in `orderBy` and `conditions`**:
596
660
 
@@ -717,6 +781,18 @@ Both schema-level (TypeBox/Ajv) and custom (`validate` / `validateBulk`) errors
717
781
 
718
782
  Custom validators return tuples `[field, code]` or `[field, code, message]` — `message` defaults to `code` if omitted. `validateBulk` replaces per-item `validate` in bulk-upsert requests.
719
783
 
784
+ ### Server errors (500)
785
+
786
+ Anything the plugin did not raise itself — a constraint violation, a hook failing — answers
787
+ with a fixed body carrying no database detail; `requestId` points at the log line with the
788
+ real error. Constraint names and driver messages stay on the server unless the deployment
789
+ opts in with `exposeDebugInfo: true`: see
790
+ [Error responses don't describe your schema](#error-responses-dont-describe-your-schema).
791
+
792
+ ```json
793
+ { "statusCode": 500, "error": "Internal Server Error", "message": "Internal Server Error", "requestId": "req-3" }
794
+ ```
795
+
720
796
  ## Multi-Tenant
721
797
 
722
798
  Automatic row-level isolation on all CRUD operations. Configure once, no code in handlers.
@@ -730,6 +806,12 @@ await app.register(fastifyAutoSqlApi, {
730
806
 
731
807
  When `getTenantId` returns `null`, no filtering is applied (admin mode).
732
808
 
809
+ The tenant belongs to the **request**, not to the table it addresses: a scoped table is filtered
810
+ even when it is reached as a join target, or written as a secondary, of a host table that
811
+ declares no scope of its own — a relation is a read grant, and `tenantScope` is the cap on it. So
812
+ `getTenantId` must tolerate a request with no authenticated user; return `null` there. It is not
813
+ called at all when neither the addressed table nor anything it relates to is scoped.
814
+
733
815
  ### Direct tenant (column on the table)
734
816
 
735
817
  ```typescript
@@ -753,6 +835,31 @@ defineTable({
753
835
  });
754
836
  ```
755
837
 
838
+ ### Shared tenant (several owner columns)
839
+
840
+ Some rows belong to two parties and either may see them — a message (`sender_id` /
841
+ `recipient_id`), a transfer (`from_account_id` / `to_account_id`), a shift swap. `anyOf`
842
+ declares that shape:
843
+
844
+ ```typescript
845
+ defineTable({
846
+ primary: 'id',
847
+ ...exportTableInfo(SchemaShiftSwapRequest),
848
+ tenantScope: { anyOf: ['requester_agent_id', 'target_agent_id'] },
849
+ });
850
+ ```
851
+
852
+ Reads add `AND (requester_agent_id IN (…) OR target_agent_id IN (…))`. A `NULL` column simply
853
+ does not match; the other party still decides. Writes cannot auto-inject an owner — the plugin
854
+ cannot know which party the caller is — so the payload must **anchor** the row: at least one
855
+ listed column present and holding a tenant id, otherwise `400` (nothing to anchor to) or `403`
856
+ (present but foreign). The other party is left exactly as sent, which is the point: a swap
857
+ request names a colleague by definition. Updates strip every listed column from the `SET` and
858
+ upserts leave them out of the `DO UPDATE` — neither party can be re-assigned.
859
+
860
+ The three forms do not mix: `defineTable` throws at startup on `anyOf` combined with `column`
861
+ or `through`.
862
+
756
863
  Tables without `tenantScope` are unaffected.
757
864
 
758
865
  ### Isolation guarantees on writes