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