fastify-auto-sqlapi 0.2.0 → 0.2.2
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.md +50 -0
- package/AGENTS_BACKEND.md +312 -9
- package/AGENTS_FRONTEND.md +1 -1
- package/BREAKING_CHANGES.md +132 -0
- package/CHANGELOG.md +170 -0
- package/README.md +255 -877
- package/dist/bin/generate-schema.js +49 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/agent/manifest.d.ts +2 -0
- package/dist/lib/agent/manifest.d.ts.map +1 -1
- package/dist/lib/agent/manifest.js +4 -0
- package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
- package/dist/lib/cli/mysql-introspect.js +15 -5
- package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
- package/dist/lib/cli/pg-introspect.js +53 -14
- package/dist/lib/cli/schema-codegen.d.ts +2 -1
- package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
- package/dist/lib/cli/schema-codegen.js +17 -4
- package/dist/lib/cli/tables-codegen.d.ts +4 -0
- package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
- package/dist/lib/cli/tables-codegen.js +125 -27
- 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 +6 -5
- package/dist/lib/engine/read-hooks.d.ts +19 -0
- package/dist/lib/engine/read-hooks.d.ts.map +1 -0
- package/dist/lib/engine/read-hooks.js +20 -0
- package/dist/lib/engine/rest/get.d.ts.map +1 -1
- package/dist/lib/engine/rest/get.js +10 -6
- 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 +15 -11
- package/dist/lib/engine/search/join-families.d.ts +11 -2
- package/dist/lib/engine/search/join-families.d.ts.map +1 -1
- package/dist/lib/engine/search/join-families.js +11 -13
- package/dist/lib/engine/search/search.d.ts.map +1 -1
- package/dist/lib/engine/search/search.js +14 -7
- package/dist/lib/engine/validate.d.ts +3 -3
- package/dist/lib/engine/validate.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.d.ts +7 -3
- package/dist/lib/engine/write-helpers.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.js +29 -30
- 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/schema/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/schema/bulk-upsert.js +2 -1
- package/dist/lib/schema/get.d.ts +14 -0
- package/dist/lib/schema/get.d.ts.map +1 -0
- package/dist/lib/schema/get.js +18 -0
- package/dist/lib/schema/helpers.d.ts +21 -3
- package/dist/lib/schema/helpers.d.ts.map +1 -1
- package/dist/lib/schema/helpers.js +37 -5
- package/dist/lib/schema/insert.d.ts.map +1 -1
- package/dist/lib/schema/insert.js +7 -3
- package/dist/lib/schema/search.d.ts.map +1 -1
- package/dist/lib/schema/search.js +3 -11
- package/dist/lib/schema/update.d.ts.map +1 -1
- package/dist/lib/schema/update.js +7 -3
- package/dist/lib/sql-api.d.ts +9 -9
- package/dist/lib/sql-api.d.ts.map +1 -1
- package/dist/lib/sql-api.js +2 -1
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +169 -0
- package/dist/lib/tenant.d.ts +43 -8
- package/dist/lib/tenant.d.ts.map +1 -1
- package/dist/lib/tenant.js +169 -54
- package/dist/lib/write-access.d.ts +36 -0
- package/dist/lib/write-access.d.ts.map +1 -0
- package/dist/lib/write-access.js +58 -0
- package/dist/routes/auto/get.routes.d.ts.map +1 -1
- package/dist/routes/auto/get.routes.js +3 -2
- package/dist/routes/auto/plugin.d.ts.map +1 -1
- package/dist/routes/auto/plugin.js +8 -2
- package/dist/routes/auto/route-helpers.d.ts.map +1 -1
- package/dist/routes/auto/route-helpers.js +13 -4
- package/dist/routes/setup-swagger.d.ts.map +1 -0
- package/dist/{lib → routes}/sql-api-decorator.d.ts +1 -1
- package/dist/routes/sql-api-decorator.d.ts.map +1 -0
- package/dist/{lib → routes}/sql-api-decorator.js +4 -4
- package/dist/types/cli.d.ts +27 -1
- package/dist/types/cli.d.ts.map +1 -1
- package/dist/types/dml.d.ts +6 -4
- package/dist/types/dml.d.ts.map +1 -1
- package/dist/types/plugin.d.ts +22 -3
- package/dist/types/plugin.d.ts.map +1 -1
- package/dist/types/request.d.ts +24 -0
- package/dist/types/request.d.ts.map +1 -0
- package/dist/types/request.js +1 -0
- package/dist/types/schema.d.ts +15 -0
- package/dist/types/schema.d.ts.map +1 -1
- package/dist/types/search.d.ts +3 -0
- package/dist/types/search.d.ts.map +1 -1
- package/dist/types/table.d.ts +84 -11
- package/dist/types/table.d.ts.map +1 -1
- package/dist/types/tenant.d.ts +26 -2
- package/dist/types/tenant.d.ts.map +1 -1
- package/dist/types/validation.d.ts +3 -3
- package/dist/types/validation.d.ts.map +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -0
- package/docs/GLOSSARY.md +3 -1
- package/docs/adr/0004-updates-always-open.md +2 -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/0014-what-follows-a-write-join.md +84 -0
- package/docs/adr/0015-non-writable-columns.md +86 -0
- package/docs/adr/0016-a-view-is-a-table-config.md +92 -0
- package/docs/adr/0017-the-schema-is-the-contract.md +85 -0
- package/docs/adr/0018-one-seam-to-the-http-framework.md +68 -0
- package/docs/adr/README.md +6 -0
- package/package.json +1 -1
- package/dist/lib/setup-swagger.d.ts.map +0 -1
- package/dist/lib/sql-api-decorator.d.ts.map +0 -1
- /package/dist/{lib → routes}/setup-swagger.d.ts +0 -0
- /package/dist/{lib → routes}/setup-swagger.js +0 -0
package/BREAKING_CHANGES.md
CHANGED
|
@@ -455,3 +455,135 @@ 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.
|
|
539
|
+
|
|
540
|
+
---
|
|
541
|
+
|
|
542
|
+
# Breaking Change — secondaries run the child table's `beforeInsert`
|
|
543
|
+
|
|
544
|
+
## Why this changed
|
|
545
|
+
|
|
546
|
+
`processSecondaries` consulted the child table's configuration for its `excludeFromCreation` and
|
|
547
|
+
its `tenantScope`, but skipped its `beforeInsert`. A child table whose hook *transformed* a value
|
|
548
|
+
rather than adding one — encrypting a column, normalising a unit — therefore stored the raw
|
|
549
|
+
client value whenever its rows arrived as another table's secondaries, and correctly only through
|
|
550
|
+
its own route. Nothing failed at write time; the row read back as garbage later. See
|
|
551
|
+
[ADR 0014](./docs/adr/0014-what-follows-a-write-join.md).
|
|
552
|
+
|
|
553
|
+
## What changed
|
|
554
|
+
|
|
555
|
+
- A child table's **`beforeInsert` now runs** for rows written as `secondaries`, after that
|
|
556
|
+
table's `excludeFromCreation` strip and before the engine's FK auto-fill (which stays
|
|
557
|
+
authoritative). The child's `validate` and `afterInsert` still do not run.
|
|
558
|
+
- The **get response schema honours `readExclude`**: it previously advertised fields the engine
|
|
559
|
+
never selects. `schemaOverrides` is unchanged — still write bodies only.
|
|
560
|
+
- The **update body now requires the primary key**. It identifies the row (`PUT /rest/:table`
|
|
561
|
+
carries it in `main`), but the generated PK field is `Optional` and that modifier reached the
|
|
562
|
+
update body unchanged, so a request omitting `main.id` built its `WHERE` on `undefined`
|
|
563
|
+
instead of being rejected with a 400.
|
|
564
|
+
|
|
565
|
+
## Who is affected
|
|
566
|
+
|
|
567
|
+
- **A table of yours declares `beforeInsert` and is also a write-join target.** The hook now runs
|
|
568
|
+
on that path. If it was written assuming the table's own route — reading `req.params`, counting
|
|
569
|
+
invocations, calling an external service per record — it now runs where it did not before. It
|
|
570
|
+
receives the transaction connection, so what it writes rolls back with the host's write.
|
|
571
|
+
- **You send an update without `main.<pk>`.** It never identified a row — the `WHERE` was built
|
|
572
|
+
on `undefined` — and is now a 400.
|
|
573
|
+
- **A client of yours read a `readExclude`d field out of the get route's Swagger.** It was never
|
|
574
|
+
returned; only the documentation claimed it.
|
|
575
|
+
|
|
576
|
+
Not affected: tables with no `beforeInsert`, or none that are write-join targets. Nothing about
|
|
577
|
+
`schemaOverrides` changes.
|
|
578
|
+
|
|
579
|
+
## Migration
|
|
580
|
+
|
|
581
|
+
For a `beforeInsert` that must keep running on the table's own route only, guard on the context
|
|
582
|
+
it needs rather than on the path:
|
|
583
|
+
|
|
584
|
+
```typescript
|
|
585
|
+
beforeInsert: async (db, req, record) => {
|
|
586
|
+
record.slug = slugify(record.name); // shaping: correct on both paths, leave it
|
|
587
|
+
if (req.routeOptions?.url?.startsWith('/rest/')) await notify(record); // side effect: narrow it
|
|
588
|
+
},
|
|
589
|
+
```
|
package/CHANGELOG.md
CHANGED
|
@@ -9,8 +9,128 @@ Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BR
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`ApiRequest` — one seam between the core and the HTTP framework.** The engines, the schema
|
|
15
|
+
builders and the write pipeline never read a property off the request: it arrives from the
|
|
16
|
+
route, is forwarded to `getTenantId`, `validate` and the hooks, and is otherwise untouched. That
|
|
17
|
+
dependency was spread over thirteen files as a `FastifyRequest` type import; it is now declared
|
|
18
|
+
once in `src/types/request.ts` and exported from the package. `ApiRequest` *is* `FastifyRequest`,
|
|
19
|
+
so nothing changes for consumers — hooks keep their exact types. A new dependency-cruiser rule
|
|
20
|
+
fails the build on a `fastify` import (type-only included) anywhere under `src/lib/`,
|
|
21
|
+
`src/types/` or `src/bin/` but that file, and the two Fastify bridge helpers that lived in
|
|
22
|
+
`lib/` (`setupSwagger`, the `sqlApi` decorator) moved to `src/routes/` so the rule needs no
|
|
23
|
+
exceptions. See [ADR 0018](./docs/adr/0018-one-seam-to-the-http-framework.md).
|
|
24
|
+
|
|
25
|
+
- **Views are supported explicitly, and PostgreSQL materialized views at all.** A view was
|
|
26
|
+
already usable — both introspections read `information_schema.columns`, which lists view
|
|
27
|
+
columns like any other — but the generator treated it as a base table and **invented a
|
|
28
|
+
primary key from the column types**: on an aggregating view that takes a count as the key,
|
|
29
|
+
and `get/:id`, `update` and `delete` then address rows by it, silently. Introspection now
|
|
30
|
+
reports `is_view` on both engines, the generated Schema carries `isView`, and the table
|
|
31
|
+
generator refuses to guess: it uses `id` when the view has one and says the key was *inferred,
|
|
32
|
+
not read from the database*, and otherwise writes a `TODO_pick_a_unique_column` placeholder
|
|
33
|
+
with `operations: ['search']` and a real `defaultOrder` — a config that works as generated,
|
|
34
|
+
since search never reads the primary key. Writes start off in the template but are **not**
|
|
35
|
+
blocked at runtime: a view the engine can make updatable is a legitimate projection layer.
|
|
36
|
+
PostgreSQL **materialized views** are now introspected from `pg_catalog`; they are absent from
|
|
37
|
+
`information_schema` and were being skipped without a word. `sqlapi-generate-schema` prints
|
|
38
|
+
the views it found and the assumptions it had to make. See
|
|
39
|
+
[ADR 0016](./docs/adr/0016-a-view-is-a-table-config.md).
|
|
40
|
+
|
|
41
|
+
- **Columns the database computes are no longer offered as writable.** A
|
|
42
|
+
`GENERATED ALWAYS AS (<expr>)` column — PostgreSQL STORED, MySQL VIRTUAL or STORED — is
|
|
43
|
+
rejected by both engines when a write so much as names it, and the whole statement fails.
|
|
44
|
+
The plugin used to advertise those columns as ordinary writable fields, so a client sending
|
|
45
|
+
back what a read had just returned got a `500` for a payload nobody could have made valid.
|
|
46
|
+
`sqlapi-generate-schema` now detects them and records them in the generated Schema's
|
|
47
|
+
`generatedFields`; the runtime drops them from the insert, update and bulk-upsert bodies
|
|
48
|
+
(main and secondaries) and again inside the engines, which `sqlApi.*` reaches without those
|
|
49
|
+
schemas. **Regenerating the Schema files is enough** — the exclusion does not live in the
|
|
50
|
+
`Table*.ts` the generator never overwrites. The CLI also prints the columns it found, per
|
|
51
|
+
table, since a generated source file is not always read. See
|
|
52
|
+
[ADR 0015](./docs/adr/0015-non-writable-columns.md).
|
|
53
|
+
- **`writeExclude` on `defineTable`** — the write-side counterpart of `readExclude`, for the
|
|
54
|
+
non-writable columns introspection cannot know about (one a trigger owns, one a migration is
|
|
55
|
+
about to drop). Reads are untouched: the field stays projected, filterable and orderable. It
|
|
56
|
+
runs **after** the write hooks, unlike `excludeFromCreation` which is sanitized before them
|
|
57
|
+
(ADR 0005): a hook cannot put back a value the database will refuse. `defineTable` rejects an
|
|
58
|
+
unknown field, the primary key, and a field that is also `readExclude`d. It is static and
|
|
59
|
+
applies to everyone — a rule that depends on *who* is asking is still product logic (ADR 0004).
|
|
60
|
+
- **The agent manifest marks non-writable fields** `readOnly`, alongside the existing
|
|
61
|
+
`writeOnly` for `readExclude`.
|
|
62
|
+
|
|
63
|
+
- **`afterRead` — the read-side hook.** The counterpart of `beforeInsert`/`beforeUpdate`, for
|
|
64
|
+
turning a stored representation back into the API one: decrypting a column, unpacking a blob,
|
|
65
|
+
rescaling a unit. Called **once per result set** with camelCase rows mutated **in place** and
|
|
66
|
+
awaited, so a hook that calls a remote KMS gets one invocation per page instead of one per row.
|
|
67
|
+
Declared on the table that **owns** the column and it follows a join: the hook runs wherever
|
|
68
|
+
that table's rows surface — `get`, `search`, and a `joinMultiple`/`joinLeft` declared on
|
|
69
|
+
another table, with `ctx.source` and `ctx.alias` saying which — so a relation elsewhere needs
|
|
70
|
+
no extra wiring, the way `readExclude` and `tenantScope` already work (ADR 0010). It does not
|
|
71
|
+
run on `joinGroup` (aggregates, not table rows) nor on an empty result, and rows cannot be
|
|
72
|
+
added or removed: the pagination `COUNT` has already run. On the main table it runs *after* the
|
|
73
|
+
join side queries, which correlate on the values the database returned, so a hook rewriting the
|
|
74
|
+
column a relation joins on cannot break the correlation. `SearchParams`/`GetParams` now carry
|
|
75
|
+
the optional `request`, which `sqlApi.search()`/`sqlApi.get()` forward when given one.
|
|
76
|
+
|
|
77
|
+
- **`tenantScope` over several owner columns — `{ anyOf: [...] }`.** A row that belongs to two
|
|
78
|
+
parties and is visible to either — a message (`sender_id` / `recipient_id`), a transfer
|
|
79
|
+
(`from_account_id` / `to_account_id`), a shift swap — could not be expressed before, leaving
|
|
80
|
+
the table open or the route hand-written. Reads add
|
|
81
|
+
`AND (colA IN (…) OR colB IN (…))`, alias-qualified, and the predicate crosses every join
|
|
82
|
+
family including `joinLeft`, where it lands in the `ON` clause so `LEFT` semantics survive.
|
|
83
|
+
A `NULL` column does not match and does not stop the other one from matching.
|
|
84
|
+
Writes cannot auto-inject an owner, so the payload **anchors** the row instead: at least one
|
|
85
|
+
listed column present and holding a tenant id, otherwise `400` (nothing to anchor to) or `403`
|
|
86
|
+
(present but foreign); the other party is stored exactly as sent. Updates strip every listed
|
|
87
|
+
column from the `SET`, and upserts exclude them from the `DO UPDATE` — neither party can be
|
|
88
|
+
re-assigned. The upsert conflict guard is null-safe for this shape: a row whose parties are
|
|
89
|
+
all `NULL` is visible to nobody, where a plain `NOT IN` would have read "unknown" as "not
|
|
90
|
+
foreign" and handed it over. Purely additive: `{ column }` and `{ column, through }` produce
|
|
91
|
+
byte-identical SQL. The three forms do not mix — `defineTable` throws at startup on `anyOf`
|
|
92
|
+
combined with `column` or `through`, on an empty or malformed `anyOf`, and on a scope
|
|
93
|
+
declaring no owner column at all.
|
|
94
|
+
- `QueryClient.bulkInsertOrUpdate` takes an optional trailing `immutableCols`: columns written
|
|
95
|
+
on insert but left untouched when the statement lands on an existing row. Additive.
|
|
96
|
+
|
|
12
97
|
### Changed
|
|
13
98
|
|
|
99
|
+
- **BREAKING: the tenant follows the request, not the table it addresses.** `tenantScope` on a
|
|
100
|
+
table is now enforced even when the request was addressed to a different table that reaches it
|
|
101
|
+
through a relation. Previously the tenant was resolved from the addressed table alone, so a
|
|
102
|
+
host declaring no scope of its own carried no predicate into the scoped tables it joined or
|
|
103
|
+
wrote into — every scoped row of the neighbour was readable through the open host, and writable
|
|
104
|
+
through it as a secondary, while the declaration said otherwise. A relation is a read grant and
|
|
105
|
+
a write join a write grant ([ADR 0010](./docs/adr/0010-joins-do-not-run-route-guards.md)), and
|
|
106
|
+
`tenantScope` is documented as the cap that makes them safe to declare; it now actually is.
|
|
107
|
+
`TenantContext.scope` becomes optional — `ids` is request-level, `scope` is table-level and
|
|
108
|
+
absent means "no filter on this table", never "no tenant". `getTenantId` is now consulted for a
|
|
109
|
+
request addressed to an unscoped table when one of its declared relations points at a scoped
|
|
110
|
+
one; it is still never called when nothing scoped is in reach. Tables that already declared
|
|
111
|
+
their own scope emit unchanged SQL. Migration — including the `getTenantId` body that now needs
|
|
112
|
+
to tolerate an unauthenticated request — in
|
|
113
|
+
[BREAKING_CHANGES.md](./BREAKING_CHANGES.md#breaking-change--the-tenant-follows-the-request-not-the-table-it-addresses).
|
|
114
|
+
- **Database errors no longer reach clients verbatim.** Any error escaping an auto-generated
|
|
115
|
+
route that the plugin did not raise itself now answers `{"statusCode":500,"error":"Internal
|
|
116
|
+
Server Error","message":"Internal Server Error"}` instead of the driver message, which names
|
|
117
|
+
tables, columns and constraints (`duplicate key value violates unique constraint
|
|
118
|
+
"users_email_key"`). Errors the plugin raises are deliberate `4xx` written for clients and
|
|
119
|
+
are untouched; **no status code is remapped** — a unique violation is still a `500`, per
|
|
120
|
+
[ADR 0006](./docs/adr/0006-raw-db-errors.md). The original error is logged through
|
|
121
|
+
`request.log.error` and attached as `err.cause`, so a consumer `setErrorHandler` can still
|
|
122
|
+
apply its own policy (`23505` → `409`). The guard covers route plugins registered standalone
|
|
123
|
+
as well. The sanitized body carries `requestId` — the `reqId` Fastify logs on every line — so
|
|
124
|
+
the entry holding the real error is one grep away. While developing — where a client building
|
|
125
|
+
against the API (an agent especially) may have no way to read the server log — the new
|
|
126
|
+
**`exposeDebugInfo: true`** attaches the driver error (message, `code`, `constraint`, stack)
|
|
127
|
+
as a `debugInfo` payload. It is additive: `statusCode`, `error`, `message` and `requestId`
|
|
128
|
+
keep their production shape, so client-side error handling does not fork between
|
|
129
|
+
environments. Nothing else flips it — not `debug`, not `NODE_ENV`: `debug` belongs to the
|
|
130
|
+
consumer, who may wire it to an environment variable, and it governs the log (access
|
|
131
|
+
controlled) rather than the response body (not, and these routes are open by default). See
|
|
132
|
+
[ADR 0013](./docs/adr/0013-sanitized-db-errors.md).
|
|
133
|
+
|
|
14
134
|
- **Unknown `filters` keys are now rejected with `400 Unknown filter field: <key>`** instead
|
|
15
135
|
of being dropped in silence — on the main table and on every join family. The engine only
|
|
16
136
|
ever visited keys matching a schema field, an `extraFilters` entry or a computed field, so a
|
|
@@ -26,8 +146,58 @@ Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BR
|
|
|
26
146
|
`LEFT JOIN` alias), so the filter applied nothing. They are now rejected with a `400` naming
|
|
27
147
|
the reason and pointing at `joinMustExist` on the same relation.
|
|
28
148
|
|
|
149
|
+
- **The update body requires the primary key**, which identifies the row it updates
|
|
150
|
+
(`PUT /rest/:table` carries it in `main`). The generated PK field is `Optional` — it is
|
|
151
|
+
absent on insert — and that modifier reached the update body unchanged, so a request omitting
|
|
152
|
+
`main.id` built its `WHERE` on `undefined` instead of being rejected. The code comment said
|
|
153
|
+
"PK required" all along.
|
|
154
|
+
- **A secondary now runs the child table's `beforeInsert`.** `processSecondaries` already applied
|
|
155
|
+
the child's `excludeFromCreation` and its `tenantScope` but skipped its `beforeInsert`, so a
|
|
156
|
+
table whose hook *transformed* a value — encrypting a column, normalising a unit — stored the
|
|
157
|
+
raw client value whenever its rows arrived as another table's children, and the correct one
|
|
158
|
+
through its own route. Nothing failed at write time; the row read back as garbage later. The
|
|
159
|
+
hook runs after the exclusion strip and before the engine's FK auto-fill, which stays
|
|
160
|
+
authoritative, and receives the transaction connection. The child's `validate` and
|
|
161
|
+
`afterInsert` deliberately still do not run — see
|
|
162
|
+
[ADR 0014](./docs/adr/0014-what-follows-a-write-join.md). **Breaking** for a `beforeInsert`
|
|
163
|
+
written assuming it only ever ran on its own table's route.
|
|
164
|
+
- **The get response schema honours `readExclude`**, like the search response already did; it
|
|
165
|
+
previously advertised fields the engine never selects. It now lives in `lib/schema/get.ts`
|
|
166
|
+
(`GetTableResponse`) instead of inline in the route, which is how it had drifted.
|
|
167
|
+
`schemaOverrides` stays where it was — write bodies only: an override rules what the API
|
|
168
|
+
accepts from now on, while the rows already stored predate it, so narrowing the response
|
|
169
|
+
would promise something no one can retroactively make true.
|
|
170
|
+
|
|
29
171
|
### Fixed
|
|
30
172
|
|
|
173
|
+
- **A `tenantScope` naming a schema field instead of a column now fails at startup.**
|
|
174
|
+
`tenantScope` names real DB columns — the scope is applied to the SQL, after the payload has
|
|
175
|
+
been converted, and `through.foreignField` belongs to a parent table that may have no config of
|
|
176
|
+
its own. Nothing checked it, so writing `organizationId` where the column is `organization_id`
|
|
177
|
+
passed `defineTable` and surfaced as `column "organizationId" does not exist` on every request
|
|
178
|
+
to the table: an opaque `500`, failing closed but saying nothing. All four positions are now
|
|
179
|
+
checked (`column`, each `anyOf` entry, `through.localField`, and `through.foreignField` against
|
|
180
|
+
the parent schema). The check is narrow by design — it fires only when the name *is* a schema
|
|
181
|
+
field whose column differs, so a column absent from the schema raises nothing and a camelCase
|
|
182
|
+
database, where `col()` is the identity, raises nothing either.
|
|
183
|
+
- **The `no-static-optional-peer` gate had never been able to fire.** `includeOnly: '^src/'` in
|
|
184
|
+
`.dependency-cruiser.mjs` dropped every npm edge from the dependency graph, so the rule meant to
|
|
185
|
+
stop a static import of `pg` / `mysql2` / `@fastify/*` — which breaks installs that use the other
|
|
186
|
+
dialect — matched nothing, ever. The graph now keeps npm dependencies as leaves, and the rule is
|
|
187
|
+
qualified to what its name says: static, value-level imports only, since a type-only import is
|
|
188
|
+
erased at compile time and a dynamic one is what the rule asks for. Both gates were verified by
|
|
189
|
+
introducing a violation and watching them fail.
|
|
190
|
+
- **`defineTable` never validated `primary`.** A primary key naming a field the schema does not
|
|
191
|
+
have passed startup untouched and became a raw SQL error on the first request — a `500`. It
|
|
192
|
+
now throws at `defineTable` time, naming the field and the operations that need it. The one
|
|
193
|
+
exception is exact: a table exposing only `search` and declaring its own `defaultOrder` never
|
|
194
|
+
reads `primary`, which is the shape generated for a view whose key could not be inferred.
|
|
195
|
+
- **PostgreSQL identity columns made a table impossible to insert into.**
|
|
196
|
+
`GENERATED ... AS IDENTITY` reports no `column_default`, which is what the generator read to
|
|
197
|
+
decide that the database fills a column in — so the primary key came out **mandatory** in the
|
|
198
|
+
write body while PostgreSQL rejected any explicit value for it (`Column "id" is an identity
|
|
199
|
+
column defined as GENERATED ALWAYS`). Identity columns are now flagged like MySQL's
|
|
200
|
+
`AUTO_INCREMENT`: the field is Optional and the CLI lists it under `excludeFromCreation`.
|
|
31
201
|
- **Join validation no longer depends on the main result set.** `joinMultiple` / `joinLeft` /
|
|
32
202
|
`joinGroup` skip their side query entirely when the main query matched no rows, so everything
|
|
33
203
|
validated inside it was skipped with it — the same request answered 400 or 200 depending on
|