fastify-auto-sqlapi 0.2.1 → 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 +264 -6
- package/AGENTS_FRONTEND.md +1 -1
- package/BREAKING_CHANGES.md +51 -0
- package/CHANGELOG.md +113 -0
- package/README.md +254 -983
- package/dist/bin/generate-schema.js +49 -2
- package/dist/index.d.ts +1 -1
- 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/engine/bulk/bulk-upsert.js +2 -2
- 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 +5 -2
- 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 +14 -10
- 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 +9 -3
- 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 +27 -28
- 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 +123 -2
- package/dist/lib/tenant.d.ts +2 -2
- package/dist/lib/tenant.d.ts.map +1 -1
- 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.js +2 -2
- package/dist/routes/auto/route-helpers.js +1 -1
- 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 +3 -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/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/adr/0004-updates-always-open.md +2 -1
- 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 +5 -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
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# 0015. A column the database computes is not writable — `writeExclude` (narrows 0004)
|
|
2
|
+
|
|
3
|
+
- **Status**: accepted
|
|
4
|
+
- **Date**: 2026-08-28
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
[ADR 0004](./0004-updates-always-open.md) rejected `excludeFromUpdate`: a static per-field
|
|
9
|
+
write block, proposed to protect `isAdmin`, `createdAt` and ownership columns. It lost on one
|
|
10
|
+
argument — those cases are **session-dependent**, a static list cannot express them, and
|
|
11
|
+
offering one would give a false sense of security to consumers who used it for exactly that.
|
|
12
|
+
That reasoning stands unchanged.
|
|
13
|
+
|
|
14
|
+
It does not, however, cover a case the plugin met later: a column the database itself refuses
|
|
15
|
+
to be told about.
|
|
16
|
+
|
|
17
|
+
```sql
|
|
18
|
+
subtotal INTEGER GENERATED ALWAYS AS (qty * price) STORED
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Both engines reject the whole statement — not the field — when a write so much as names such a
|
|
22
|
+
column: `428C9 cannot insert a non-DEFAULT value into column "subtotal"` on PostgreSQL,
|
|
23
|
+
`ER_NON_DEFAULT_VALUE_FOR_GENERATED_COLUMN` on MySQL. Yet nothing in the generated schema said
|
|
24
|
+
so, so the column was offered as an ordinary writable field, and a client that sent it back
|
|
25
|
+
what a read had just returned got a **500** — a raw driver error, per
|
|
26
|
+
[ADR 0006](./0006-raw-db-errors.md) — for a payload nobody could have made valid.
|
|
27
|
+
|
|
28
|
+
The same blind spot hid `GENERATED ... AS IDENTITY`, which has no `column_default` to reveal
|
|
29
|
+
that the database fills it in: the generated field came out mandatory and the table could not
|
|
30
|
+
be inserted into at all.
|
|
31
|
+
|
|
32
|
+
## Decision
|
|
33
|
+
|
|
34
|
+
Fields the database computes are excluded from every write path. The CLI records them in the
|
|
35
|
+
generated Schema's `generatedFields`, and the runtime removes them from the insert, update and
|
|
36
|
+
bulk-upsert bodies (main and secondaries) and again inside the engines, which `sqlApi.*`
|
|
37
|
+
reaches without those schemas.
|
|
38
|
+
|
|
39
|
+
`ITable.writeExclude` exposes the same mechanism for the columns introspection cannot know
|
|
40
|
+
about — one a trigger owns, one a migration is about to drop.
|
|
41
|
+
|
|
42
|
+
This narrows 0004 rather than reversing it, and the boundary is what the exclusion is *about*:
|
|
43
|
+
|
|
44
|
+
- **a fact about the column** — the database rejects the value whoever assigned it, so there is
|
|
45
|
+
no configuration in which offering the field helps anyone. This ADR.
|
|
46
|
+
- **a rule about the caller** — who may change what, when. Still product logic, still
|
|
47
|
+
`beforeUpdate` / `validate` / a dedicated endpoint. Still ADR 0004.
|
|
48
|
+
|
|
49
|
+
Three consequences follow from that boundary and are enforced:
|
|
50
|
+
|
|
51
|
+
- `writeExclude` runs **after** the write hooks — the opposite of `excludeFromCreation`, which
|
|
52
|
+
is sanitized before them so a hook can assign the field ([ADR 0005](./0005-insert-pipeline-sanitize-before-hooks.md)).
|
|
53
|
+
A hook cannot put back a value the database will refuse.
|
|
54
|
+
- `defineTable` rejects a field that is also in `readExclude`: neither readable nor writable is
|
|
55
|
+
what removing it from the Schema means.
|
|
56
|
+
- `defineTable` rejects the primary key, which the update body needs to identify the row.
|
|
57
|
+
|
|
58
|
+
## Alternatives considered
|
|
59
|
+
|
|
60
|
+
- **`Schema.generatedFields` only, no public key** — rejected: it covers the introspected case
|
|
61
|
+
and nothing else, and a consumer whose schema is hand-written or whose column is owned by a
|
|
62
|
+
trigger would be back to a driver error with no way out.
|
|
63
|
+
- **Let the write through and map the driver error to a 400** — rejected: it contradicts
|
|
64
|
+
[ADR 0006](./0006-raw-db-errors.md) (no SQLSTATE→HTTP mapping), it costs a round trip to the
|
|
65
|
+
database to learn what the schema already knew, and the field would still be advertised as
|
|
66
|
+
writable in Swagger and in the agent manifest.
|
|
67
|
+
- **Trim the column out of the generated Schema** — rejected: that removes it from reads too,
|
|
68
|
+
and a computed column is precisely the one a client wants to read.
|
|
69
|
+
- **Reopen 0004 and add a general `excludeFromUpdate`** — rejected: the false-sense-of-security
|
|
70
|
+
argument is untouched by anything here. The documentation for `writeExclude` says what it is
|
|
71
|
+
not for, and the key deliberately has no per-caller or per-role dimension that would invite
|
|
72
|
+
the misuse 0004 warned about.
|
|
73
|
+
|
|
74
|
+
## Consequences
|
|
75
|
+
|
|
76
|
+
- Under Fastify's default `removeAdditional: true`, a computed column in a write body is
|
|
77
|
+
**stripped silently** rather than rejected; the row is written with the value the database
|
|
78
|
+
computed. With `removeAdditional: false` the closed body schema answers 400. Either way the
|
|
79
|
+
driver error is gone — which was the point.
|
|
80
|
+
- A consumer upgrading gets the fix from **regenerating the Schema files alone**: the exclusion
|
|
81
|
+
travels in `generatedFields`, not in the `Table*.ts` the generator never overwrites.
|
|
82
|
+
- The generated `Table*.ts` names the computed columns in a comment and leaves `writeExclude`
|
|
83
|
+
commented out, and `sqlapi-generate-schema` prints them per table — a generated source file
|
|
84
|
+
is not always read, its command's output is.
|
|
85
|
+
- Reads are untouched: a computed column stays projected, filterable, orderable and
|
|
86
|
+
aggregatable, because it is a real column with a real value.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# 0016. A view is a table config — the generator refuses to invent its primary key
|
|
2
|
+
|
|
3
|
+
- **Status**: accepted
|
|
4
|
+
- **Date**: 2026-08-28
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Views were never designed for, and they worked anyway: both introspections read
|
|
9
|
+
`information_schema.columns`, which lists view columns exactly like table columns, so
|
|
10
|
+
`sqlapi-generate-schema` produced a Schema and searching the result worked with no special
|
|
11
|
+
support. Three things underneath that were wrong.
|
|
12
|
+
|
|
13
|
+
**The primary key was invented in silence.** A view carries no `PRIMARY KEY` constraint, so
|
|
14
|
+
`is_primary` is false on every column and the generated Schema had no `primaryKey`. The table
|
|
15
|
+
generator then fell through to its heuristic for hand-written schemas: `id` if present,
|
|
16
|
+
otherwise **the first integer column**, otherwise the first column of any kind. On
|
|
17
|
+
`SELECT status, count(*) AS order_count ... GROUP BY status` that hands back `order_count` — a
|
|
18
|
+
count — as the key, and `get/:id`, `update` and `delete` then address rows by it. Nothing said
|
|
19
|
+
so, in the file or on the console.
|
|
20
|
+
|
|
21
|
+
**Nothing validated `primary` at all.** `defineTable` checked `tenantScope`, `readExclude`,
|
|
22
|
+
`computedFields` and alias uniqueness, but a `primary` naming a field the schema does not have
|
|
23
|
+
passed straight through and became a raw SQL error on the first request — a 500, per
|
|
24
|
+
[ADR 0006](./0006-raw-db-errors.md).
|
|
25
|
+
|
|
26
|
+
**PostgreSQL materialized views were invisible.** They are not in `information_schema` at all;
|
|
27
|
+
they live only in `pg_catalog`. They were skipped without a word — and a materialized view is
|
|
28
|
+
precisely the kind of expensive read one wants to expose.
|
|
29
|
+
|
|
30
|
+
## Decision
|
|
31
|
+
|
|
32
|
+
A view **is** a table config. No `ViewName` file, no `View*` type, no `readOnly` flag: the
|
|
33
|
+
plugin has one concept for "a relation with columns you can query" and a view is that one. A
|
|
34
|
+
second name would fork the `dbTables` keys, the generator, the docs and the manifest to express
|
|
35
|
+
nothing the existing vocabulary cannot.
|
|
36
|
+
|
|
37
|
+
What changes is what may be **assumed**, and all of it lives in the generator and in startup
|
|
38
|
+
validation, not in the runtime semantics:
|
|
39
|
+
|
|
40
|
+
- Introspection reports `is_view` on both engines, and PostgreSQL materialized views are read
|
|
41
|
+
from `pg_catalog` and reported the same way. The generated Schema carries `isView: true`.
|
|
42
|
+
- For a view the generator **never guesses a key from a column type**. It accepts `id` when the
|
|
43
|
+
view has one — a strong enough signal — and says in the file that the key was *inferred, not
|
|
44
|
+
read from the database*. With no `id`, it writes a `TODO_pick_a_unique_column` placeholder
|
|
45
|
+
rather than a wrong answer.
|
|
46
|
+
- A view's generated config starts read-only: `operations: ['search', 'get']`, or
|
|
47
|
+
`['search']` when the key is unresolved. `defaultOrder` names a real column, so search never
|
|
48
|
+
falls back to the placeholder, and the config **works as generated** — the TODO is a note for
|
|
49
|
+
whoever wants to widen it, not a defect.
|
|
50
|
+
- `defineTable` now validates `primary` against the schema fields. The exception is exact and
|
|
51
|
+
narrow: a table exposing **only** `search` and naming its own `defaultOrder` never reads
|
|
52
|
+
`primary`, so it is allowed. Anything else fails at startup.
|
|
53
|
+
- `sqlapi-generate-schema` prints the views it found and both assumptions it had to make. A
|
|
54
|
+
generated source file is not always read; the output of the command that produced it is.
|
|
55
|
+
|
|
56
|
+
Writes through a view are **not** blocked at runtime. A view simple enough for the engine to
|
|
57
|
+
make updatable accepts INSERT and UPDATE on both PostgreSQL and MySQL, and using one as a
|
|
58
|
+
projection or permission layer is a legitimate pattern the plugin has no business refusing. The
|
|
59
|
+
read-only start is the *generated template* — a file the developer then owns — which is a
|
|
60
|
+
different thing from the runtime default that [ADR 0002](./0002-open-by-default.md) settles.
|
|
61
|
+
That default is untouched: a hand-written view config with no `operations` still exposes
|
|
62
|
+
everything.
|
|
63
|
+
|
|
64
|
+
## Alternatives considered
|
|
65
|
+
|
|
66
|
+
- **`ViewName` files / a distinct type** — rejected: it forks a concept for no expressive gain
|
|
67
|
+
and breaks the one-name-per-concept rule the glossary exists to enforce.
|
|
68
|
+
- **A `readOnly: true` flag on `defineTable`** — rejected: a second spelling of
|
|
69
|
+
`operations: ['search', 'get']`.
|
|
70
|
+
- **Blocking writes to views at runtime** — rejected: simple views are genuinely updatable on
|
|
71
|
+
both engines (verified), and the plugin cannot tell which ones from the schema alone. It
|
|
72
|
+
would remove a working feature to prevent an error the database already reports.
|
|
73
|
+
- **Keep guessing the key, just add a comment** — rejected: the wrong-key failure is silent and
|
|
74
|
+
addresses *rows*, which is the worst kind. A placeholder that refuses to start is the loud
|
|
75
|
+
version of the same information.
|
|
76
|
+
- **Skip views entirely, or leave materialized views unsupported and document it** — rejected:
|
|
77
|
+
they already worked for reads, which is the case that matters, and silence about a relation
|
|
78
|
+
the database has is the least defensible of the options.
|
|
79
|
+
|
|
80
|
+
## Consequences
|
|
81
|
+
|
|
82
|
+
- A view without an `id` column needs one manual edit before it can serve anything but
|
|
83
|
+
`search`. That is the point: the edit is a decision about uniqueness that only the author of
|
|
84
|
+
the view can make.
|
|
85
|
+
- `defineTable` now throws at startup on a `primary` that names no schema field. A config that
|
|
86
|
+
relied on that tolerance — a primary key deliberately kept out of the Schema — has to name a
|
|
87
|
+
schema field or restrict itself to `operations: ['search']`.
|
|
88
|
+
- Materialized views appear in generation runs where they did not before, so a project that
|
|
89
|
+
regenerates with `--all` will find new `Table*.ts` files for them.
|
|
90
|
+
- The `is_view` flag is generator-facing. Nothing in the request path branches on it: a view
|
|
91
|
+
searches, filters, orders, paginates, joins and (when the database allows) writes through the
|
|
92
|
+
same code as a base table.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# 0017. The database schema is the API contract — by default, and only by default
|
|
2
|
+
|
|
3
|
+
- **Status**: accepted
|
|
4
|
+
- **Date**: 2026-08-28
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
The first objection this plugin meets is always the same: *it couples the API to the database.*
|
|
9
|
+
Change a column and you change the API, and through it every client.
|
|
10
|
+
|
|
11
|
+
The objection is real and worth answering precisely, because the imprecise answer ("it's fine
|
|
12
|
+
for internal tools") concedes more than it has to and defends less than it should.
|
|
13
|
+
|
|
14
|
+
## Decision
|
|
15
|
+
|
|
16
|
+
The database schema is the API contract **by default**. That is the mechanism, not a shortcut,
|
|
17
|
+
and it rests on three things.
|
|
18
|
+
|
|
19
|
+
**The coupling is exposed, not created.** A hand-written CRUD layer over the same tables is
|
|
20
|
+
coupled to exactly the same schema — it just retypes it. Rename a column there and you edit the
|
|
21
|
+
model, the DTO, the mapper, the validator, the Swagger annotation and the client, in six places
|
|
22
|
+
instead of one. What that layer genuinely buys is different: an indirection that lets the
|
|
23
|
+
contract stay still while the schema moves. That is a real thing to want, and it is paid for
|
|
24
|
+
continuously and cashed in rarely. This plugin makes the opposite bet, and states it.
|
|
25
|
+
|
|
26
|
+
**It is checked, not implied.** The Schema files are generated from `information_schema`, so a
|
|
27
|
+
schema change that breaks the API breaks it when you regenerate and compile — for TypeScript
|
|
28
|
+
consumers, at build time. A hand-written mapper that nobody remembered to update fails in
|
|
29
|
+
production instead. (Untyped clients — a browser fetch — still find out at runtime; the
|
|
30
|
+
generated Swagger and `GET {prefix}/agent/manifest.md` are what they check against.)
|
|
31
|
+
|
|
32
|
+
**It is a default, and every part of it is overridable.** The exposed shape is not forced to be
|
|
33
|
+
the stored shape:
|
|
34
|
+
|
|
35
|
+
| To decouple | Use |
|
|
36
|
+
|---|---|
|
|
37
|
+
| a column exists but must never be read | `readExclude` |
|
|
38
|
+
| a column exists but must never be written | `writeExclude` ([ADR 0015](./0015-non-writable-columns.md)) |
|
|
39
|
+
| a column must not exist for the API at all | trim it out of the Schema |
|
|
40
|
+
| the API type must be stricter than the column | `schemaOverrides` |
|
|
41
|
+
| the API value is derived, not stored | `computedFields` |
|
|
42
|
+
| the stored representation is not the API one | `afterRead` (decrypt, unpack, rescale) |
|
|
43
|
+
| a table must not have all seven routes | `operations` |
|
|
44
|
+
| a relation must be named for the API, not the DB | the relation `alias`, plus its `fields` allowlist |
|
|
45
|
+
| a filter has no column behind it | `extraFilters` + `extendedCondition` |
|
|
46
|
+
| different callers need different surfaces | register the plugin more than once, under different prefixes |
|
|
47
|
+
|
|
48
|
+
And when none of that fits, the operation is not a CRUD operation: write the route by hand and
|
|
49
|
+
call `app.sqlApi.*` inside it. That is not the fallback path — it is the design. The custom
|
|
50
|
+
route keeps filters, joins, tenant scoping, hooks and validation; what it does not keep is the
|
|
51
|
+
assumption that the endpoint looks like a table.
|
|
52
|
+
|
|
53
|
+
**Where the objection is right.** A public or third-party API — consumers you cannot redeploy
|
|
54
|
+
alongside your schema, a contract with a version number and a deprecation policy — is a surface
|
|
55
|
+
whose stability has to outlive refactors. There an indirection that survives schema movement
|
|
56
|
+
earns its cost, and generating that surface from the schema is the wrong default. Do not
|
|
57
|
+
auto-expose it: keep those tables off the auto routes with `operations`, or behind their own
|
|
58
|
+
hand-written endpoints, and let the plugin serve the surface it is for.
|
|
59
|
+
|
|
60
|
+
That surface is the internal one: back-office and admin tools where superadmin, admin and
|
|
61
|
+
tenant-admin roles need full control over the domain, the schema *is* the domain model, and the
|
|
62
|
+
people who change a column are the people who change the screen that shows it. There, the
|
|
63
|
+
translation layer has no reader — it exists only to be kept in sync with the thing it
|
|
64
|
+
translates.
|
|
65
|
+
|
|
66
|
+
## Alternatives considered
|
|
67
|
+
|
|
68
|
+
- **A mapping/DTO layer inside the plugin** — rejected: that is the hand-written layer again,
|
|
69
|
+
re-expressed as configuration instead of code, with the same maintenance cost and less
|
|
70
|
+
freedom. The per-concern levers above cover what most projects actually need to hide or
|
|
71
|
+
reshape, and a custom route covers the rest without inventing a second schema language.
|
|
72
|
+
- **Frozen schema snapshots, versioned independently of the database** — rejected: a second
|
|
73
|
+
source of truth to keep in sync, which is precisely what [ADR 0001](./0001-no-orm-raw-sql.md)
|
|
74
|
+
refused when it refused an ORM's models and migrations.
|
|
75
|
+
- **Saying nothing and letting readers work it out** — rejected: it is the first question every
|
|
76
|
+
reviewer asks, and an unanswered one reads as an unconsidered one.
|
|
77
|
+
|
|
78
|
+
## Consequences
|
|
79
|
+
|
|
80
|
+
- Renaming a column changes the API field. Accepted: it is one regeneration, and typed
|
|
81
|
+
consumers fail to compile rather than failing quietly.
|
|
82
|
+
- The plugin is not the right tool for a versioned public API, and says so in the README rather
|
|
83
|
+
than leaving it to be discovered.
|
|
84
|
+
- Every "the API should not look like the table" request has a specific answer in the table
|
|
85
|
+
above, or is a custom route. Neither is a gap in the plugin.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# 0018. The HTTP framework is reachable through one seam — `ApiRequest`
|
|
2
|
+
|
|
3
|
+
- **Status**: accepted
|
|
4
|
+
- **Date**: 2026-08-29
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
The question was what it would take to serve the same engine over something other than Fastify.
|
|
9
|
+
Measuring first turned up a codebase that was already almost free of one:
|
|
10
|
+
|
|
11
|
+
- of 74 files under `src/`, 26 named a Fastify type and **not one imported a value from it**;
|
|
12
|
+
- **nothing under `src/lib/` ever read a property off the request** — no `body`, no `params`, no
|
|
13
|
+
`log`. It arrives from the route, is forwarded to `getTenantId`, `validate` and the hooks, and
|
|
14
|
+
is otherwise untouched;
|
|
15
|
+
- `httpError` produces a plain `Error` carrying `statusCode`. Fastify happens to read that; the
|
|
16
|
+
plugin does not produce it *for* Fastify.
|
|
17
|
+
|
|
18
|
+
So the engine, the schema builders and the write pipeline depend on a web framework for exactly
|
|
19
|
+
one thing: the **name of a type they never dereference**. That dependency was spread across
|
|
20
|
+
thirteen files, where every new hook signature quietly widened it, and nothing prevented an
|
|
21
|
+
engine from starting to read `request.headers` tomorrow.
|
|
22
|
+
|
|
23
|
+
## Decision
|
|
24
|
+
|
|
25
|
+
The request type is declared once, in `src/types/request.ts`, and everything in the core refers
|
|
26
|
+
to that name:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
export type ApiRequest = FastifyRequest;
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`.dependency-cruiser.mjs` enforces it: `http-framework-behind-one-seam` fails the build on a
|
|
33
|
+
`fastify` import — **including a type-only one** — anywhere under `src/lib/`, `src/types/` or
|
|
34
|
+
`src/bin/` other than that file. The two Fastify bridge helpers that lived in `lib/`
|
|
35
|
+
(`setup-swagger`, `sql-api-decorator`) moved to `src/routes/`, which is where the adapter is, so
|
|
36
|
+
the rule needs no exceptions.
|
|
37
|
+
|
|
38
|
+
A plain alias, not a generic parameter on `ITable`. `ITable` appears as a bare type in over a
|
|
39
|
+
hundred places; threading `<Req>` through all of them — and through `DbTables`, and every
|
|
40
|
+
`*Params` — changes the public type surface today to buy a property that only pays off once the
|
|
41
|
+
core ships as a package of its own. The alias makes the seam **one file**, which is the whole
|
|
42
|
+
requirement, and leaves the generic available later without having spent anything on it now.
|
|
43
|
+
|
|
44
|
+
This decision is about where the dependency lives, not about supporting a second framework. It
|
|
45
|
+
records what the measurement found, and stops it from silently degrading.
|
|
46
|
+
|
|
47
|
+
## Alternatives considered
|
|
48
|
+
|
|
49
|
+
- **`ITable<F, Req = FastifyRequest>`** — rejected for now, as above: the churn is large, it
|
|
50
|
+
changes the public types, and mixed `DbTables` maps get awkward. It is the right shape for a
|
|
51
|
+
three-package split and can be done then, from this seam, in one file's worth of decisions.
|
|
52
|
+
- **Leave `FastifyRequest` inline and write the constraint in the docs** — rejected: a property
|
|
53
|
+
that thirteen files must remember is a property that lasts until the next hook is added.
|
|
54
|
+
Gates, not documentation (ADR 0012).
|
|
55
|
+
- **Make the core read nothing by typing the request as `unknown`** — rejected: it would break
|
|
56
|
+
every consumer's typed hook to express something the consumer does not need to know.
|
|
57
|
+
|
|
58
|
+
## Consequences
|
|
59
|
+
|
|
60
|
+
- Consumers see no change: `ApiRequest` *is* `FastifyRequest`, and it is exported from the
|
|
61
|
+
package for anyone declaring a hook outside a `defineTable` call.
|
|
62
|
+
- The core cannot regress. An engine that starts naming a Fastify type fails `npm run verify`.
|
|
63
|
+
- A second HTTP adapter is now a question about `src/routes/` (582 lines) plus what Fastify does
|
|
64
|
+
for free — request validation against the generated schemas, and response serialization, which
|
|
65
|
+
is part of the contract rather than plumbing — and not a question about the engine at all.
|
|
66
|
+
- Fixing the rule so it could fire revealed that `no-static-optional-peer` had never been able
|
|
67
|
+
to: `includeOnly: '^src/'` dropped every npm edge from the graph. It is live now, qualified to
|
|
68
|
+
what its name says (static, value-level imports of optional peers).
|
package/docs/adr/README.md
CHANGED
|
@@ -28,6 +28,11 @@ not by editing history.
|
|
|
28
28
|
| [0011](./0011-join-fields-allowlist.md) | Per-relation field restriction is an allowlist, not a blocklist |
|
|
29
29
|
| [0012](./0012-anti-duplication-tooling.md) | Repository rules are enforced by executable gates, not by documentation |
|
|
30
30
|
| [0013](./0013-sanitized-db-errors.md) | Database error messages are sanitized on the wire by default (narrows 0006) |
|
|
31
|
+
| [0014](./0014-what-follows-a-write-join.md) | A secondary runs the child table's transform, not its verdicts |
|
|
32
|
+
| [0015](./0015-non-writable-columns.md) | A column the database computes is not writable — `writeExclude` (narrows 0004) |
|
|
33
|
+
| [0016](./0016-a-view-is-a-table-config.md) | A view is a table config — the generator refuses to invent its primary key |
|
|
34
|
+
| [0017](./0017-the-schema-is-the-contract.md) | The database schema is the API contract — by default, and only by default |
|
|
35
|
+
| [0018](./0018-one-seam-to-the-http-framework.md) | The HTTP framework is reachable through one seam — `ApiRequest` |
|
|
31
36
|
|
|
32
37
|
## When to write one
|
|
33
38
|
|
package/package.json
CHANGED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"setup-swagger.d.ts","sourceRoot":"","sources":["../../src/lib/setup-swagger.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAC/C,OAAO,KAAK,EAAE,mBAAmB,EAAkB,MAAM,aAAa,CAAC;AAEvE,wBAAsB,YAAY,CAChC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,IAAI,CAAC,mBAAmB,EAAE,SAAS,CAAC,GAC5C,OAAO,CAAC,IAAI,CAAC,CA6Bf"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"sql-api-decorator.d.ts","sourceRoot":"","sources":["../../src/lib/sql-api-decorator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAI/C,OAAO,EAAgB,KAAK,MAAM,EAAE,MAAM,cAAc,CAAC;AACzD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAEvD,OAAO,QAAQ,SAAS,CAAC;IACvB,UAAU,eAAe;QACvB,MAAM,EAAE,MAAM,CAAC;KAChB;CACF;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,GAC3B,IAAI,CA2BN"}
|
|
File without changes
|
|
File without changes
|