fastify-auto-sqlapi 0.2.0 → 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 (40) hide show
  1. package/AGENTS_BACKEND.md +48 -3
  2. package/BREAKING_CHANGES.md +81 -0
  3. package/CHANGELOG.md +57 -0
  4. package/README.md +109 -2
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/lib/db.d.ts +6 -1
  8. package/dist/lib/db.d.ts.map +1 -1
  9. package/dist/lib/db.js +8 -2
  10. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  11. package/dist/lib/engine/bulk/bulk-upsert.js +4 -3
  12. package/dist/lib/engine/rest/get.d.ts.map +1 -1
  13. package/dist/lib/engine/rest/get.js +5 -4
  14. package/dist/lib/engine/rest/update.js +1 -1
  15. package/dist/lib/engine/search/search.d.ts.map +1 -1
  16. package/dist/lib/engine/search/search.js +5 -4
  17. package/dist/lib/engine/write-helpers.d.ts.map +1 -1
  18. package/dist/lib/engine/write-helpers.js +2 -2
  19. package/dist/lib/errors.d.ts +24 -0
  20. package/dist/lib/errors.d.ts.map +1 -1
  21. package/dist/lib/errors.js +59 -0
  22. package/dist/lib/table-helpers.d.ts.map +1 -1
  23. package/dist/lib/table-helpers.js +48 -0
  24. package/dist/lib/tenant.d.ts +41 -6
  25. package/dist/lib/tenant.d.ts.map +1 -1
  26. package/dist/lib/tenant.js +169 -54
  27. package/dist/routes/auto/plugin.d.ts.map +1 -1
  28. package/dist/routes/auto/plugin.js +6 -0
  29. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  30. package/dist/routes/auto/route-helpers.js +12 -3
  31. package/dist/types/plugin.d.ts +19 -0
  32. package/dist/types/plugin.d.ts.map +1 -1
  33. package/dist/types/tenant.d.ts +26 -2
  34. package/dist/types/tenant.d.ts.map +1 -1
  35. package/docs/GLOSSARY.md +3 -1
  36. package/docs/adr/0006-raw-db-errors.md +2 -1
  37. package/docs/adr/0010-joins-do-not-run-route-guards.md +4 -1
  38. package/docs/adr/0013-sanitized-db-errors.md +114 -0
  39. package/docs/adr/README.md +1 -0
  40. package/package.json +1 -1
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: {
@@ -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
 
@@ -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
@@ -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
package/dist/index.d.ts CHANGED
@@ -34,5 +34,5 @@ export { BulkDeleteTableBody, BulkDeleteTableResponse } from './lib/schema/bulk-
34
34
  export { default as bulkDeleteRoutes } from './routes/auto/bulk-delete.routes.js';
35
35
  export { Type, type Static } from '@sinclair/typebox';
36
36
  export { ConditionBuilder, Expression } from 'node-condition-builder';
37
- export type { SqlApiConfig, ColumnInfo, TableMap, Queryable, TransactionQueryable, SqlResult, DbRecord, DbRecordValue, SelectOptions, SchemaDefinition, JoinDefinition, ITable, TableOperation, DbTables, SqlApiPluginOptions, SwaggerOptions, SearchParams, SearchResult, Paginator, PaginationResult, TableFilterFn, ExtendedConditionFn, FilterRecord, AggregationRequest, JoinGroupRequest, ComputedFieldFn, ComputedFieldContext, ComputedFieldExpr, GetParams, GetResult, DeleteParams, DeleteResult, InsertParams, InsertResult, BulkUpsertItem, BulkUpsertParams, BulkUpsertResult, BulkDeleteParams, BulkDeleteResult, ConditionMethod, SearchCondition, UpdateParams, UpdateResult, TenantId, TenantScope, TenantScopeDirect, TenantScopeIndirect, TenantContext, ValidationError, ValidatorFn, BulkValidatorItem, BulkValidatorFn, } from './types.js';
37
+ export type { SqlApiConfig, ColumnInfo, TableMap, Queryable, TransactionQueryable, SqlResult, DbRecord, DbRecordValue, SelectOptions, SchemaDefinition, JoinDefinition, ITable, TableOperation, DbTables, SqlApiPluginOptions, SwaggerOptions, SearchParams, SearchResult, Paginator, PaginationResult, TableFilterFn, ExtendedConditionFn, FilterRecord, AggregationRequest, JoinGroupRequest, ComputedFieldFn, ComputedFieldContext, ComputedFieldExpr, GetParams, GetResult, DeleteParams, DeleteResult, InsertParams, InsertResult, BulkUpsertItem, BulkUpsertParams, BulkUpsertResult, BulkDeleteParams, BulkDeleteResult, ConditionMethod, SearchCondition, UpdateParams, UpdateResult, TenantId, TenantScope, TenantScopeDirect, TenantScopeIndirect, TenantScopeAnyOf, TenantContext, ValidationError, ValidatorFn, BulkValidatorItem, BulkValidatorFn, } from './types.js';
38
38
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/lib/db.d.ts CHANGED
@@ -37,7 +37,12 @@ export declare class QueryClient {
37
37
  insert(table: string, record: DbRecord, pkCol: string | string[]): Promise<Record<string, unknown>>;
38
38
  insertOrUpdate(table: string, record: DbRecord, conflictKeys: string[], pkCol: string | string[]): Promise<Record<string, unknown>>;
39
39
  bulkInsert(table: string, records: DbRecord[], pkCol: string | string[], chunkSize?: number): Promise<Record<string, unknown>[]>;
40
- bulkInsertOrUpdate(table: string, records: DbRecord[], conflictKeys: string[], pkCol: string | string[], chunkSize?: number): Promise<Record<string, unknown>[]>;
40
+ /**
41
+ * `immutableCols` are written when the row is inserted but left untouched when the statement
42
+ * lands on an existing row — like the conflict keys, which are never in the update set either.
43
+ * Used by tenant isolation to keep an upsert from re-assigning a row's owner.
44
+ */
45
+ bulkInsertOrUpdate(table: string, records: DbRecord[], conflictKeys: string[], pkCol: string | string[], chunkSize?: number, immutableCols?: string[]): Promise<Record<string, unknown>[]>;
41
46
  update(table: string, record: DbRecord, where: DbRecord, extraCondition?: ConditionBuilder): Promise<number>;
42
47
  delete(table: string, where: DbRecord, extraCondition?: ConditionBuilder): Promise<number>;
43
48
  select<T = Record<string, unknown>>({ tableName, columns, where, values, limit, orderBy, joins, distinct, }: SelectOptions): Promise<T[]>;
@@ -1 +1 @@
1
- {"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/lib/db.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/lib/db.ts"],"names":[],"mappings":"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"}
package/dist/lib/db.js CHANGED
@@ -195,11 +195,17 @@ export class QueryClient {
195
195
  }
196
196
  return results;
197
197
  }
198
- async bulkInsertOrUpdate(table, records, conflictKeys, pkCol, chunkSize = DEFAULT_CHUNK_SIZE) {
198
+ /**
199
+ * `immutableCols` are written when the row is inserted but left untouched when the statement
200
+ * lands on an existing row — like the conflict keys, which are never in the update set either.
201
+ * Used by tenant isolation to keep an upsert from re-assigning a row's owner.
202
+ */
203
+ async bulkInsertOrUpdate(table, records, conflictKeys, pkCol, chunkSize = DEFAULT_CHUNK_SIZE, immutableCols = []) {
199
204
  if (!records.length)
200
205
  return [];
201
206
  const fields = Object.keys(records[0]);
202
- const upsertClause = this.dialect.upsertSql(this.#q(conflictKeys), this.dialect.upsertUpdateSet(fields, conflictKeys));
207
+ const keepAsIs = immutableCols.length ? [...conflictKeys, ...immutableCols] : conflictKeys;
208
+ const upsertClause = this.dialect.upsertSql(this.#q(conflictKeys), this.dialect.upsertUpdateSet(fields, keepAsIs));
203
209
  const returning = this.dialect.returningPk(this.#quotedPkCols(pkCol));
204
210
  const results = [];
205
211
  for (let i = 0; i < records.length; i += chunkSize) {
@@ -1 +1 @@
1
- {"version":3,"file":"bulk-upsert.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/bulk/bulk-upsert.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAEV,gBAAgB,EAChB,gBAAgB,EAEjB,MAAM,mBAAmB,CAAC;AA4D3B,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CA0C5F"}
1
+ {"version":3,"file":"bulk-upsert.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/bulk/bulk-upsert.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAEV,gBAAgB,EAChB,gBAAgB,EAEjB,MAAM,mBAAmB,CAAC;AA+D3B,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CA0C5F"}
@@ -1,6 +1,6 @@
1
1
  import { camelcaseObject } from '../../naming.js';
2
2
  import { processSecondaries, processDeletions, prepareInsertRecord } from '../write-helpers.js';
3
- import { enforceTenantOnWrites, assertTenantOwnsConflicts } from '../../tenant.js';
3
+ import { enforceTenantOnWrites, assertTenantOwnsConflicts, tenantImmutableColumns, } from '../../tenant.js';
4
4
  import { runBulkValidation } from '../validate.js';
5
5
  import { primaryAsCols } from '../../../types.js';
6
6
  /** One bulk statement for every main row, returning the PKs only. */
@@ -12,9 +12,10 @@ function upsertMains(params, preparedMains, pkCol) {
12
12
  return db.bulkInsert(schema.tableName, preparedMains, pkCol);
13
13
  }
14
14
  const conflictCols = upsertKeys.map((k) => schema.col(k));
15
- // Tenant isolation: a conflicting upsert must not overwrite rows owned by another tenant.
15
+ // Tenant isolation: a conflicting upsert must not overwrite rows owned by another tenant, nor
16
+ // re-assign the owner of a row it legitimately updates.
16
17
  return assertTenantOwnsConflicts(db, tenant, schema.tableName, conflictCols, preparedMains)
17
- .then(() => db.bulkInsertOrUpdate(schema.tableName, preparedMains, conflictCols, pkCol));
18
+ .then(() => db.bulkInsertOrUpdate(schema.tableName, preparedMains, conflictCols, pkCol, undefined, tenantImmutableColumns(tenant)));
18
19
  }
19
20
  /** Secondaries, deletions and the afterInsert hook for one upserted item. */
20
21
  async function finalizeUpsertedItem(params, item, inputMain, pkRow) {
@@ -1 +1 @@
1
- {"version":3,"file":"get.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/get.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAE9D,wBAAsB,SAAS,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC,CAsCrE"}
1
+ {"version":3,"file":"get.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/get.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAE9D,wBAAsB,SAAS,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC,CAuCrE"}
@@ -15,10 +15,11 @@ export async function getEngine(params) {
15
15
  // shared with the through table would be ambiguous.
16
16
  cb.isEqual(`${db.qi(tableConf.Schema.tableName)}.${db.qi(pkCol)}`, id);
17
17
  const joins = [];
18
- if (tenant) {
19
- cb.append(buildTenantCondition(db, tenant.scope, tenant.ids, tableConf.Schema.tableName));
20
- if ('through' in tenant.scope) {
21
- joins.push(buildTenantJoin(db, tenant.scope, tableConf.Schema.tableName));
18
+ if (tenant?.scope) {
19
+ const scope = tenant.scope;
20
+ cb.append(buildTenantCondition(db, scope, tenant.ids, tableConf.Schema.tableName));
21
+ if ('through' in scope) {
22
+ joins.push(buildTenantJoin(db, scope, tableConf.Schema.tableName));
22
23
  }
23
24
  }
24
25
  const where = cb.build(1, db.ph);
@@ -54,7 +54,7 @@ export async function updateEngine(params) {
54
54
  // 6. Strip tenant column (user cannot change tenant of an existing record).
55
55
  // For indirect scopes the tenant link is the through-FK (localField): it may be changed,
56
56
  // but only to another value the caller owns — re-validate the new FK against the tenant.
57
- if (tenant) {
57
+ if (tenant?.scope) {
58
58
  stripTenantColumn(updateFields, tenant.scope);
59
59
  await enforceTenantFKOnUpdate(db, tenant, updateFields);
60
60
  }
@@ -1 +1 @@
1
- {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/search.ts"],"names":[],"mappings":"AAgCA,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EACZ,YAAY,EAGb,MAAM,mBAAmB,CAAC;AAwP3B,wBAAsB,YAAY,CAChC,QAAQ,EAAE,QAAQ,EAClB,MAAM,EAAE,YAAY,GACnB,OAAO,CAAC,YAAY,CAAC,CAsFvB"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/search.ts"],"names":[],"mappings":"AAgCA,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EACZ,YAAY,EAGb,MAAM,mBAAmB,CAAC;AAyP3B,wBAAsB,YAAY,CAChC,QAAQ,EAAE,QAAQ,EAClB,MAAM,EAAE,YAAY,GACnB,OAAO,CAAC,YAAY,CAAC,CAsFvB"}
@@ -153,10 +153,11 @@ function buildMainCondition(params) {
153
153
  applyConditions(condition, conditions, tableConf.Schema, tableConf, db);
154
154
  }
155
155
  const tenantJoins = [];
156
- if (tenant) {
157
- condition.append(buildTenantCondition(db, tenant.scope, tenant.ids, tableConf.Schema.tableName));
158
- if ('through' in tenant.scope) {
159
- tenantJoins.push(buildTenantJoin(db, tenant.scope, tableConf.Schema.tableName));
156
+ if (tenant?.scope) {
157
+ const scope = tenant.scope;
158
+ condition.append(buildTenantCondition(db, scope, tenant.ids, tableConf.Schema.tableName));
159
+ if ('through' in scope) {
160
+ tenantJoins.push(buildTenantJoin(db, scope, tableConf.Schema.tableName));
160
161
  }
161
162
  }
162
163
  return { condition, tenantJoins };
@@ -1 +1 @@
1
- {"version":3,"file":"write-helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/engine/write-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE9C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAQ5C,OAAO,KAAK,EACV,MAAM,EACN,QAAQ,EAGR,aAAa,EACd,MAAM,gBAAgB,CAAC;AAqDxB,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,QAAQ,EAClB,aAAa,EAAE,MAAM,GACpB,MAAM,GAAG,SAAS,CAKpB;AAED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,WAAW,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,QAAQ,CAAC;IACnB,sFAAsF;IACtF,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpC,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB;AAmCD,UAAU,UAAU;IAClB,EAAE,EAAE,WAAW,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,cAAc,CAAC;CACzB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,UAAU,EACf,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,EACvD,OAAO,CAAC,EAAE;IAAE,YAAY,CAAC,EAAE,OAAO,CAAA;CAAE,GACnC,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAC,CAe7E;AAkCD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,aAAa,EACnB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GACrD,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CAiDpD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,aAAa,EACnB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GACnD,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CAoBpD"}
1
+ {"version":3,"file":"write-helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/engine/write-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE9C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAS5C,OAAO,KAAK,EACV,MAAM,EACN,QAAQ,EAGR,aAAa,EACd,MAAM,gBAAgB,CAAC;AAqDxB,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,QAAQ,EAClB,aAAa,EAAE,MAAM,GACpB,MAAM,GAAG,SAAS,CAKpB;AAED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,WAAW,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,QAAQ,CAAC;IACnB,sFAAsF;IACtF,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpC,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB;AAmCD,UAAU,UAAU;IAClB,EAAE,EAAE,WAAW,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,cAAc,CAAC;CACzB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,UAAU,EACf,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,EACvD,OAAO,CAAC,EAAE;IAAE,YAAY,CAAC,EAAE,OAAO,CAAA;CAAE,GACnC,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAC,CAe7E;AAkCD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,aAAa,EACnB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GACrD,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CAkDpD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,aAAa,EACnB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GACnD,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CAoBpD"}
@@ -1,6 +1,6 @@
1
1
  import { camelcaseObject, snakecaseRecord } from '../naming.js';
2
2
  import { runValidation } from './validate.js';
3
- import { tenantForTable, buildTenantRowGuard, enforceTenantOnWrites, assertTenantOwnsConflicts, } from '../tenant.js';
3
+ import { tenantForTable, buildTenantRowGuard, enforceTenantOnWrites, assertTenantOwnsConflicts, tenantImmutableColumns, } from '../tenant.js';
4
4
  /**
5
5
  * Resolve the write join an alias names, or undefined when the table does not declare it.
6
6
  *
@@ -148,7 +148,7 @@ export function processSecondaries(pass, secondaries) {
148
148
  // A conflicting upsert must not overwrite — or re-parent — a child row owned by
149
149
  // another tenant, which the ON CONFLICT key alone would happily match.
150
150
  await assertTenantOwnsConflicts(db, childTenant, joinSchema.tableName, conflictCols, preparedRecords);
151
- pkRows = await db.bulkInsertOrUpdate(joinSchema.tableName, preparedRecords, conflictCols, childPkCol);
151
+ pkRows = await db.bulkInsertOrUpdate(joinSchema.tableName, preparedRecords, conflictCols, childPkCol, undefined, tenantImmutableColumns(childTenant));
152
152
  }
153
153
  else {
154
154
  pkRows = await db.bulkInsert(joinSchema.tableName, preparedRecords, childPkCol);
@@ -8,4 +8,28 @@
8
8
  export declare function httpError(statusCode: number, message: string): Error & {
9
9
  statusCode: number;
10
10
  };
11
+ /**
12
+ * Replace an error escaping a route handler with one that carries no database detail.
13
+ *
14
+ * Driver messages name tables, columns and constraints, and the route error handler sends
15
+ * `error.message` verbatim — so a unique violation describes the schema to whoever
16
+ * triggered it. Errors the plugin raises itself are deliberate `4xx` with messages written
17
+ * for clients (`httpError`, `validationErrors`) and pass through untouched; everything
18
+ * else becomes a bare `500`.
19
+ *
20
+ * The original is attached as `cause`, so a consumer `setErrorHandler` can still apply its
21
+ * own policy — mapping SQLSTATE to a status code stays the consumer's decision (ADR 0006).
22
+ * No status code is remapped here: a `500` stays a `500`.
23
+ *
24
+ * `requestId` is carried on the sanitized error and rendered in the response body: it is the
25
+ * same value Fastify logs as `reqId` on every line, so the log entry holding the real error
26
+ * is one grep away from the client's `500`.
27
+ *
28
+ * With `exposeDebugInfo`, the original is also described in a `debugInfo` payload rendered
29
+ * beside those fields — additive, so the response a client codes against is the same one it
30
+ * gets in production. It is off by default and derived from nothing: `debug` belongs to the
31
+ * consumer, who may wire it to an environment variable, and no option may open an external
32
+ * channel as a side effect of meaning something internal. See ADR 0013.
33
+ */
34
+ export declare function sanitizeRouteError(err: unknown, requestId?: string, exposeDebugInfo?: boolean): unknown;
11
35
  //# sourceMappingURL=errors.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/lib/errors.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,KAAK,GAAG;IAAE,UAAU,EAAE,MAAM,CAAA;CAAE,CAI7F"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/lib/errors.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,KAAK,GAAG;IAAE,UAAU,EAAE,MAAM,CAAA;CAAE,CAI7F;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,OAAO,EACZ,SAAS,CAAC,EAAE,MAAM,EAClB,eAAe,UAAQ,GACtB,OAAO,CAcT"}