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.
- package/AGENTS_BACKEND.md +48 -3
- package/BREAKING_CHANGES.md +81 -0
- package/CHANGELOG.md +57 -0
- package/README.md +109 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/lib/db.d.ts +6 -1
- package/dist/lib/db.d.ts.map +1 -1
- package/dist/lib/db.js +8 -2
- package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-upsert.js +4 -3
- package/dist/lib/engine/rest/get.d.ts.map +1 -1
- package/dist/lib/engine/rest/get.js +5 -4
- package/dist/lib/engine/rest/update.js +1 -1
- package/dist/lib/engine/search/search.d.ts.map +1 -1
- package/dist/lib/engine/search/search.js +5 -4
- package/dist/lib/engine/write-helpers.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.js +2 -2
- package/dist/lib/errors.d.ts +24 -0
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +59 -0
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +48 -0
- package/dist/lib/tenant.d.ts +41 -6
- package/dist/lib/tenant.d.ts.map +1 -1
- package/dist/lib/tenant.js +169 -54
- package/dist/routes/auto/plugin.d.ts.map +1 -1
- package/dist/routes/auto/plugin.js +6 -0
- package/dist/routes/auto/route-helpers.d.ts.map +1 -1
- package/dist/routes/auto/route-helpers.js +12 -3
- package/dist/types/plugin.d.ts +19 -0
- package/dist/types/plugin.d.ts.map +1 -1
- package/dist/types/tenant.d.ts +26 -2
- package/dist/types/tenant.d.ts.map +1 -1
- package/docs/GLOSSARY.md +3 -1
- package/docs/adr/0006-raw-db-errors.md +2 -1
- package/docs/adr/0010-joins-do-not-run-route-guards.md +4 -1
- package/docs/adr/0013-sanitized-db-errors.md +114 -0
- package/docs/adr/README.md +1 -0
- 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
|
-
|
|
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
|
|
package/BREAKING_CHANGES.md
CHANGED
|
@@ -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
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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
|
-
|
|
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[]>;
|
package/dist/lib/db.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/lib/db.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACjF,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAQ/C,sCAAsC;AACtC,eAAO,MAAM,WAAW,GAAI,GAAG,MAAM,WAA0B,CAAC;AAEhE,qBAAa,WAAW;;IACtB,OAAO,CAAC,MAAM,CAAY;IAC1B,OAAO,CAAC,OAAO,CAAa;IAC5B,OAAO,CAAC,KAAK,CAAS;gBAEV,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,UAAU;IAKnD,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,CAE/B;IAED,IAAI,EAAE,IAAI,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAE9B;IAED,IAAI,WAAW,IAAI,MAAM,CAExB;IAED,iFAAiF;IACjF,IAAI,SAAS,IAAI,UAAU,CAAC,WAAW,CAAC,CAEvC;IAED,mFAAmF;IACnF,IAAI,iBAAiB,IAAI,OAAO,CAE/B;IAED,uEAAuE;IACvE,IAAI,WAAW,IAAI,UAAU,CAAC,aAAa,CAAC,CAE3C;IAED,IAAI,SAAS,IAAI,UAAU,CAAC,WAAW,CAAC,CAEvC;IAED,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI;IAI7B,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU;IAIrC,KAAK,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,OAAO,EAAE,GACjB,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;IAQxB;;;;;;;;;OASG;IACG,eAAe,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAmHnE,MAAM,CACV,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,QAAQ,EAChB,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GACvB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAgB7B,cAAc,CAClB,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,QAAQ,EAChB,YAAY,EAAE,MAAM,EAAE,EACtB,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GACvB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAkB7B,UAAU,CACd,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,QAAQ,EAAE,EACnB,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,EACxB,SAAS,SAAqB,GAC7B,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;
|
|
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
|
-
|
|
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
|
|
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":"
|
|
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,
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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;
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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;
|
|
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);
|
package/dist/lib/errors.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/errors.d.ts.map
CHANGED
|
@@ -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"}
|