fastify-auto-sqlapi 0.2.3 → 0.3.0
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 +1 -1
- package/AGENTS_BACKEND.md +72 -9
- package/AGENTS_FRONTEND.md +1 -1
- package/BREAKING_CHANGES.md +52 -0
- package/CHANGELOG.md +102 -0
- package/README.md +1 -1
- package/dist/bin/generate-schema.js +7 -1
- package/dist/lib/cli/schema-codegen.d.ts +14 -0
- package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
- package/dist/lib/cli/schema-codegen.js +67 -4
- package/dist/lib/schema/helpers.d.ts +7 -0
- package/dist/lib/schema/helpers.d.ts.map +1 -1
- package/dist/lib/schema/helpers.js +18 -0
- package/dist/lib/schema/insert.d.ts.map +1 -1
- package/dist/lib/schema/insert.js +3 -1
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +2 -1
- package/dist/lib/tenant.d.ts +6 -0
- package/dist/lib/tenant.d.ts.map +1 -1
- package/dist/lib/tenant.js +16 -0
- package/dist/routes/auto/bulk-delete.routes.js +1 -1
- package/dist/routes/auto/bulk-upsert.routes.js +1 -1
- package/dist/routes/auto/delete.routes.js +1 -1
- package/dist/routes/auto/get.routes.js +1 -1
- package/dist/routes/auto/insert.routes.js +1 -1
- package/dist/routes/auto/route-helpers.d.ts +5 -2
- package/dist/routes/auto/route-helpers.d.ts.map +1 -1
- package/dist/routes/auto/route-helpers.js +1 -1
- package/dist/routes/auto/search.routes.js +3 -3
- package/dist/routes/auto/update.routes.js +1 -1
- package/dist/types/cli.d.ts +10 -0
- package/dist/types/cli.d.ts.map +1 -1
- package/docs/adr/0017-the-schema-is-the-contract.md +2 -1
- package/docs/adr/0019-column-exclusion-is-generation-time-config.md +62 -0
- package/docs/adr/0020-no-conditional-or-mass-update-route.md +68 -0
- package/docs/adr/README.md +2 -0
- package/package.json +4 -4
package/AGENTS.md
CHANGED
|
@@ -6,7 +6,7 @@ You are configuring a Fastify server that uses `fastify-auto-sqlapi` to auto-gen
|
|
|
6
6
|
|
|
7
7
|
The plugin generates REST endpoints (search, get, insert, update, delete, bulk upsert, bulk delete) from database table definitions. No ORM — raw SQL. Supports **PostgreSQL**, **MySQL**, and **MariaDB**. The consumer defines table configurations, and the plugin handles routing, validation, and query execution.
|
|
8
8
|
|
|
9
|
-
**The database schema is the API contract, on purpose.** The exposed shape follows the stored shape *by default*, and every part of that default is overridable — `readExclude`, `writeExclude`,
|
|
9
|
+
**The database schema is the API contract, on purpose.** The exposed shape follows the stored shape *by default*, and every part of that default is overridable — `readExclude`, `writeExclude`, `excludeColumns` (CLI config), `schemaOverrides`, `computedFields`, `afterRead`, `operations`, relation `alias`/`fields`, `extraFilters`. Two consequences for how you work:
|
|
10
10
|
|
|
11
11
|
- Do not propose a DTO or mapping layer, and do not reshape a response by hand in a route wrapper. Reach for the lever that matches the concern (the table is in [ADR 0017](./docs/adr/0017-the-schema-is-the-contract.md)); when none fits, the operation is not CRUD — write a custom route and call `app.sqlApi.*` inside it, which keeps filters, joins, tenant scoping, hooks and validation.
|
|
12
12
|
- The target surface is internal: back-office and admin tools where the schema *is* the domain model. If the user is building a **public or third-party API** — consumers they cannot redeploy with their schema, a versioned contract — say so, and keep those tables off the auto routes via `operations` rather than generating the surface from the schema.
|
package/AGENTS_BACKEND.md
CHANGED
|
@@ -58,13 +58,16 @@ export default {
|
|
|
58
58
|
dialect: 'postgres', // 'postgres' | 'mysql' | 'mariadb' (default: 'postgres')
|
|
59
59
|
// envFile: '../../.env', // path to .env file, relative to cwd (default: '.env')
|
|
60
60
|
// excludeTables: ['knex_*'],// tables invisible to schema generation (see below)
|
|
61
|
+
// excludeColumns: ['*.password_hash'], // columns invisible to schema generation (see below)
|
|
61
62
|
};
|
|
62
63
|
```
|
|
63
64
|
|
|
64
|
-
That's it — only these
|
|
65
|
+
That's it — only these six fields exist. **No connection string, no migration path.** If the file is missing, defaults are used. The `dialect` can also be passed via CLI flag `--dialect mysql`. The `envFile` is useful in monorepo setups where the `.env` lives at the repo root.
|
|
65
66
|
|
|
66
67
|
**`excludeTables`** keeps migration bookkeeping, PostGIS internals and the like out of generation: exact table names or `*` globs, matched against the **DB table name** (not the generated schema name), with everything else literal. An excluded table is invisible to the generator — its schema file is not created, and one generated by an earlier run is removed as an orphan on the next full run, exactly like the schema of a dropped table. See [ADR 0009](./docs/adr/0009-schema-exclusion-config-blacklist.md).
|
|
67
68
|
|
|
69
|
+
**`excludeColumns`** does the same for single columns: `"<table>.<column>"` entries matched against the **DB names** (not the generated field names), with `*` as a wildcard on either side — `'user.password_hash'`, `'*.internal_*'`, `'audit_*.raw_payload'`. An excluded column does not exist for the API at all: it is not in the Schema, so no route reads, writes, filters or orders by it, and the manifest never lists it. This is the lever for *"this column must not exist for the API"*; `readExclude` / `writeExclude` on the table config are for a column that stays in the Schema and loses one side. A primary key column cannot be excluded (every route identifies rows with it), and a pattern that would leave a table with no columns fails the run: that is `excludeTables`' job. Never edit a generated Schema file by hand — it is overwritten on the next run. See [ADR 0019](./docs/adr/0019-column-exclusion-is-generation-time-config.md).
|
|
70
|
+
|
|
68
71
|
### 3. Configure database connection (env vars)
|
|
69
72
|
|
|
70
73
|
The CLI reads DB connection from environment variables (NOT from the config file). `DATABASE_URL` takes priority for all dialects.
|
|
@@ -288,7 +291,18 @@ export const TableCustomer = defineTable({
|
|
|
288
291
|
|
|
289
292
|
`exportTableInfo(Schema)` returns `{ Schema, filters, extraFilters }`. The `filters` function auto-builds WHERE conditions from any schema field present in the request.
|
|
290
293
|
|
|
291
|
-
**Important**: there is no `tableName` key in `defineTable()`. The SQL table name comes from `Schema.tableName` (set by the CLI). The route URL name is the key you use in the `dbTables` record (e.g. `{ customer: TableCustomer }` → `/search/customer`).
|
|
294
|
+
**Important**: there is no `tableName` key in `defineTable()`. The SQL table name comes from `Schema.tableName` (set by the CLI). The route URL name is the key you use in the `dbTables` record (e.g. `{ customer: TableCustomer }` → `/search/customer`). The key also names the table in search responses (`table`) and in the agent manifest.
|
|
295
|
+
|
|
296
|
+
Because routes follow the key, the same physical table can be exposed by several table configs, each with its own `operations`, `onRequests`, `tenantScope`, `readExclude` and joins:
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
export const dbTables: DbTables = {
|
|
300
|
+
conversation: TableConversation, // owner: list, rename, delete
|
|
301
|
+
sharedConversation: TableSharedConversation, // anyone with the link: search only, shared rows only
|
|
302
|
+
conversationHistory: TableConversationHistory, // server-side only: operations: []
|
|
303
|
+
};
|
|
304
|
+
// → /search/conversation, /rest/conversation, /search/sharedConversation — all SQL on "conversation"
|
|
305
|
+
```
|
|
292
306
|
|
|
293
307
|
### Composite primary key (link/junction tables)
|
|
294
308
|
|
|
@@ -866,7 +880,7 @@ const TableCustomer = defineTable({
|
|
|
866
880
|
|
|
867
881
|
Behavior:
|
|
868
882
|
- **Read** (search, get, delete, bulk-delete): adds `AND organization_id IN ($N)` to WHERE
|
|
869
|
-
- **Insert**: auto-injects `organization_id` if single tenant; validates if already present (403 if mismatch); 400 if multi-tenant without explicit value
|
|
883
|
+
- **Insert**: auto-injects `organization_id` if single tenant; validates if already present (403 if mismatch); 400 if multi-tenant without explicit value. The insert body (main and secondaries items) offers the column as **optional**, whatever the generated schema says: a single-tenant client omits it, a multi-tenant client names it. An admin (`getTenantId` → `null`) who omits it reaches the database's own `NOT NULL`, like any other column.
|
|
870
884
|
- **Update**: strips `organization_id` from SET (can't change tenant), adds to WHERE condition
|
|
871
885
|
- **Bulk upsert**: auto-injects on all records
|
|
872
886
|
- **Upsert conflict guard** (`upsertMap` set): before the `ON CONFLICT DO UPDATE`, a probe rejects (403) any incoming conflict key that matches a row owned by another tenant — an upsert cannot overwrite or re-assign a foreign tenant's row.
|
|
@@ -919,7 +933,8 @@ Behavior:
|
|
|
919
933
|
- **Insert**: no auto-injection. The payload must **anchor** the row: at least one listed column
|
|
920
934
|
present and holding a tenant id → `400` if none is present, `403` if present but all foreign.
|
|
921
935
|
The other parties are stored exactly as sent. A multi-id caller is never ambiguous here,
|
|
922
|
-
because nothing is being guessed.
|
|
936
|
+
because nothing is being guessed. Every listed column is **optional** in the insert body, so
|
|
937
|
+
the anchor rule is the runtime's to enforce, not the schema's.
|
|
923
938
|
- **Update**: strips **every** listed column from the `SET` — neither party can be re-assigned —
|
|
924
939
|
and adds the OR predicate to the `WHERE`. A row outside the scope answers `404`.
|
|
925
940
|
- **Bulk upsert**: the anchor rule applies to every item (so an upsert must name a party even
|
|
@@ -945,9 +960,14 @@ getTenantId: (request) => {
|
|
|
945
960
|
|
|
946
961
|
Uses `IN ($N, $M, ...)` for all WHERE clauses (same as single tenant, just with more values).
|
|
947
962
|
|
|
963
|
+
An **empty array** is a caller who belongs to no tenant, not an admin: every request that
|
|
964
|
+
reaches a scoped table (directly or through a relation) is refused with `403` before any SQL
|
|
965
|
+
runs. `0` and `''` are ordinary tenant ids and filter like any other value.
|
|
966
|
+
|
|
948
967
|
### Admin bypass
|
|
949
968
|
|
|
950
|
-
When `getTenantId` returns `null` or `undefined`, no filtering is applied (full access).
|
|
969
|
+
When `getTenantId` returns `null` or `undefined`, no filtering is applied (full access). Only
|
|
970
|
+
these two values bypass; `[]`, `0` and `''` do not.
|
|
951
971
|
|
|
952
972
|
### Tables without tenant
|
|
953
973
|
|
|
@@ -955,7 +975,8 @@ Tables without `tenantScope` are unaffected — no filtering regardless of `getT
|
|
|
955
975
|
|
|
956
976
|
### Error codes
|
|
957
977
|
|
|
958
|
-
- **403** — Record doesn't belong to tenant,
|
|
978
|
+
- **403** — Record doesn't belong to tenant, explicit tenant value doesn't match, or
|
|
979
|
+
`getTenantId` returned an empty array (a caller with no tenant at all)
|
|
959
980
|
- **400** — Multi-tenant user on insert without explicit tenant value (ambiguous), or an
|
|
960
981
|
`anyOf` write naming no owner column at all
|
|
961
982
|
- **404** — Update with indirect tenant, record not found for this tenant; get/update/delete of
|
|
@@ -1014,8 +1035,8 @@ output, it is the part that does not depend on anyone opening the generated file
|
|
|
1014
1035
|
- **All fields Optional in response**: `RETURNING *` may return any subset. Response schemas use `Type.Partial`.
|
|
1015
1036
|
- **Nullable columns use `Nullable(T)` (type-array form)**: the generator emits `Type.Optional(Nullable(T))` for nullable columns — `Nullable` (exported by the package) produces `{ type: ['integer', 'null'] }`. Do NOT replace it with `Type.Union([T, Type.Null()])`: under Fastify's default Ajv `coerceTypes` a union corrupts values through its branches (`null` → `0` with the Null branch last, `0`/`""` → `null` with it first), while the type array validates and serializes NULL correctly. A bare `Type.Optional(T)` is also wrong: NULL serializes as `0`/`""`. In `filters`, an explicit `null` on a schema field builds `WHERE col IS NULL`.
|
|
1016
1037
|
- **`excludeFromCreation`**: **IMPORTANT** — auto-increment PKs (e.g. `id` serial/auto_increment) MUST be listed here, otherwise INSERT will try to send them and fail. The CLI auto-detects this and adds it by default. Also useful for `createdAt`/`updatedAt` columns managed by DB defaults or hooks. It strips **client-supplied** values only: the payload is sanitized before `beforeInsert` runs, so a value the hook assigns to an excluded field (e.g. a server-generated TEXT id) DOES reach the INSERT. Same for the engine's FK auto-fill on secondaries — an excluded FK column does not suppress it. It is an **ergonomics tool for creation, NOT a field-level security mechanism**: it does not apply to updates — every Schema field is updatable by default, by design. Field-level update rules (`isAdmin`, roles, owner/tenant columns, state fields) are product logic: enforce them in `beforeUpdate` (silent strip) or `validate` (loud 400), or move the privileged transition to a dedicated endpoint and keep it off the auto routes via `operations`.
|
|
1017
|
-
- **`readExclude`**: fields hidden from every read — not projected by search/get, omitted from read response schemas and from this table's default (`*`) join selection when it is a join target. Referencing one from `filters`, `conditions`, `orderBy`, `joinGroup` aggregations or an explicit join `selection` returns 400: hiding a field from output while allowing it to be filtered would leak its value by bisection. **Writes are unaffected** — insert/update/bulk still accept the field, so a column can be writable but never readable (password hash, access token). Primary keys cannot be excluded. Complementary to
|
|
1018
|
-
- **`writeExclude`**: the write-side counterpart of `readExclude` — fields removed from the insert, update and bulk-upsert bodies (main *and* secondaries) and dropped again inside the engines, which `sqlApi.*` reaches without those schemas. Reads are untouched: the field is still projected, filterable and orderable. It exists for the columns the database refuses to be told about. **A `GENERATED ALWAYS AS (<expr>)` column is already covered without any config**: the CLI records it in the Schema's `generatedFields`, and both engines reject a statement that so much as names such a column, so offering it as writable could only turn a client mistake into a `500`. The `writeExclude` key is for the rest — a column a trigger owns, one a migration is about to drop. It is **static and applies to everyone**, admins included; a rule that depends on *who* is asking is product logic and belongs in `beforeUpdate` (silent strip) or `validate` (loud 400), per [ADR 0004](./docs/adr/0004-updates-always-open.md). Not to be confused with `excludeFromCreation`, which whitelists **client input on insert only** and deliberately lets a hook assign the field afterwards — `writeExclude` runs **after** the hooks, because the column refuses the value whoever assigned it. `defineTable` rejects a field that is not in the schema, the primary key (the update body identifies the row with it) and a field that is also in `readExclude` (neither readable nor writable means:
|
|
1038
|
+
- **`readExclude`**: fields hidden from every read — not projected by search/get, omitted from read response schemas and from this table's default (`*`) join selection when it is a join target. Referencing one from `filters`, `conditions`, `orderBy`, `joinGroup` aggregations or an explicit join `selection` returns 400: hiding a field from output while allowing it to be filtered would leak its value by bisection. **Writes are unaffected** — insert/update/bulk still accept the field, so a column can be writable but never readable (password hash, access token). Primary keys cannot be excluded. Complementary to `excludeColumns` in `sqlapi.config.ts`, which removes the column from the Schema, hence from reads *and* writes. It is **per table and static**: a column hidden here is hidden from every caller, including admins. To keep a column readable on the table's own routes but not through a relation, trim the Schema the *relation* is declared with instead — see the read-grant warning under [buildRelation](#buildrelation-signature).
|
|
1039
|
+
- **`writeExclude`**: the write-side counterpart of `readExclude` — fields removed from the insert, update and bulk-upsert bodies (main *and* secondaries) and dropped again inside the engines, which `sqlApi.*` reaches without those schemas. Reads are untouched: the field is still projected, filterable and orderable. It exists for the columns the database refuses to be told about. **A `GENERATED ALWAYS AS (<expr>)` column is already covered without any config**: the CLI records it in the Schema's `generatedFields`, and both engines reject a statement that so much as names such a column, so offering it as writable could only turn a client mistake into a `500`. The `writeExclude` key is for the rest — a column a trigger owns, one a migration is about to drop. It is **static and applies to everyone**, admins included; a rule that depends on *who* is asking is product logic and belongs in `beforeUpdate` (silent strip) or `validate` (loud 400), per [ADR 0004](./docs/adr/0004-updates-always-open.md). Not to be confused with `excludeFromCreation`, which whitelists **client input on insert only** and deliberately lets a hook assign the field afterwards — `writeExclude` runs **after** the hooks, because the column refuses the value whoever assigned it. `defineTable` rejects a field that is not in the schema, the primary key (the update body identifies the row with it) and a field that is also in `readExclude` (neither readable nor writable means: `excludeColumns` in `sqlapi.config.ts`). See [ADR 0015](./docs/adr/0015-non-writable-columns.md), which narrows [ADR 0004](./docs/adr/0004-updates-always-open.md).
|
|
1019
1040
|
- **Database-computed and identity columns are detected by the CLI**: `sqlapi-generate-schema` reads `is_generated` (PostgreSQL) / `GENERATION_EXPRESSION` (MySQL, non-empty only for a real `GENERATED ALWAYS AS (...)` column; `EXTRA` is a fallback matched on `VIRTUAL GENERATED` / `STORED GENERATED`, never on the word alone — MySQL writes `DEFAULT_GENERATED` there for any default *expression*, `DEFAULT CURRENT_TIMESTAMP` included, and such a column is writable) and prints the columns it found, per table, before writing anything — read that output. Generated columns land in the Schema's `generatedFields` and are named in a comment in the generated `Table*.ts`. Separately, `GENERATED ... AS IDENTITY` (PostgreSQL) has no `column_default` to reveal that the database fills it in: it is now flagged like MySQL's `AUTO_INCREMENT`, so the field is Optional and the CLI lists it under `excludeFromCreation` instead of emitting a mandatory primary key the database then refuses.
|
|
1020
1041
|
- **`upsertMap`**: when present for a schema, INSERT becomes upsert. PostgreSQL: `ON CONFLICT (...) DO UPDATE`. MySQL/MariaDB: `ON DUPLICATE KEY UPDATE`. Applies to both main and secondary tables.
|
|
1021
1042
|
- **`schemaOverrides`**: narrow an auto-generated field to a stricter TypeBox type (e.g. `{ email: Type.String({ format: 'email' }) }`) where `information_schema` could only report `text`. **Write bodies only** — insert, update, bulk-upsert. An override rules what the API accepts *from now on*, while the rows already stored predate it: a column narrowed to `format: 'email'` today holds whatever was accepted last year, and one made mandatory today is `NULL` on every pre-existing row. Narrowing the response would publish a promise nobody can retroactively make true, so responses and the `filters` map keep the generated type. (Nothing would break at runtime either way — Fastify does not validate responses, and `fast-json-stringify` ignores `format`/`pattern`/`minLength`/`minimum`/`enum` entirely; it acts only on `type`, by coercion.) The override is taken **verbatim**: no `Type.Optional` makes the field mandatory, no `Nullable` rejects an explicit `null`, whatever the column allows — which is how you make a column the DB had to leave nullable (a new column on a populated table) mandatory going forward. The original Schema file is never modified; the primary key is mandatory in the update body (it identifies the row); overrides appear in Swagger.
|
|
@@ -1025,7 +1046,7 @@ output, it is the part that does not depend on anyone opening the generated file
|
|
|
1025
1046
|
- **All hooks and validators receive camelCase records**: `validate` and the whole hook matrix (`beforeInsert`/`afterInsert`, `beforeUpdate`/`afterUpdate`, `beforeDelete`/`afterDelete`, `beforeBulkDelete`/`afterBulkDelete`, `afterRead`) 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.
|
|
1026
1047
|
- **Secondaries run the child table's `beforeInsert`**: a child row written alongside a main record is a write like any other — the child's `excludeFromCreation`, its `tenantScope` and its `beforeInsert` all apply, in that order, before the engine auto-fills the FK to main (which always wins over anything the payload or the hook put there). Its `validate` and `afterInsert` deliberately do **not** run: they change when a rejection or a side effect fires, which belongs to the host's write. See [ADR 0014](./docs/adr/0014-what-follows-a-write-join.md).
|
|
1027
1048
|
- **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 } } })`.
|
|
1028
|
-
- **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.
|
|
1049
|
+
- **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. Exclude a sensitive column at generation time (`excludeColumns`) to keep it out of the Schema, hence unwritable and unreadable.
|
|
1029
1050
|
- **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.
|
|
1030
1051
|
- **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. Two further caps are **fixed, not configurable**, and bound how much work one search may ask for: at most **100 `conditions`** and **20 comma-separated `orderBy` parts** — each dotted condition and each 3-part `orderBy` token becomes its own correlated subquery, which `maxItemsPerPage` does not bound. Over either limit → 400; the request schema also caps `orderBy` at 1024 characters. Those two live in the **search engine**, not in the route, so they apply to programmatic `sqlApi.search()` as well, together with the `paginator` integer guard. What `sqlApi.*` does *not* inherit are the two configurable caps, which belong to the routes: pass `maxRows` to `sqlApi.search()` for the same `LIMIT` protection, and bound your own arrays before a bulk call.
|
|
1031
1052
|
- **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`).
|
|
@@ -1211,6 +1232,48 @@ const TableUser = defineTable({
|
|
|
1211
1232
|
For privileged transitions (promote to admin, move across tenants) prefer a dedicated
|
|
1212
1233
|
endpoint with its own auth/audit, and keep the operation off the auto routes via `operations`.
|
|
1213
1234
|
|
|
1235
|
+
### Conditional update — claim a row atomically
|
|
1236
|
+
|
|
1237
|
+
The `update` route matches the primary key (plus the tenant scope) and nothing else, so it
|
|
1238
|
+
cannot express "update only if the row is still in the state I saw": claiming a row that two
|
|
1239
|
+
clients must not both claim, a guarded state transition, optimistic locking on a `version`
|
|
1240
|
+
column. There is deliberately no auto route for that, nor for updating every row matching a
|
|
1241
|
+
filter ([ADR 0020](./docs/adr/0020-no-conditional-or-mass-update-route.md)). Write it as a
|
|
1242
|
+
custom route: the check and the write must be **one** `UPDATE`, which `db.update()` does with
|
|
1243
|
+
an `extraCondition`, returning how many rows it changed.
|
|
1244
|
+
|
|
1245
|
+
```typescript
|
|
1246
|
+
import { ConditionBuilder, createQueryClient, pgQueryable, Type } from 'fastify-auto-sqlapi';
|
|
1247
|
+
import { SchemaRequest } from './schemas/SchemaRequest';
|
|
1248
|
+
|
|
1249
|
+
const db = createQueryClient(pgQueryable(fastify.pg), 'postgres');
|
|
1250
|
+
|
|
1251
|
+
fastify.post('/requests/:id/claim', {
|
|
1252
|
+
schema: { params: Type.Object({ id: Type.Integer() }) },
|
|
1253
|
+
}, async (req, reply) => {
|
|
1254
|
+
const { id } = req.params as { id: number };
|
|
1255
|
+
const col = (f: string) => SchemaRequest.col(f); // real column names: QueryClient speaks the DB's
|
|
1256
|
+
|
|
1257
|
+
const guard = new ConditionBuilder('AND', db.cbDialect)
|
|
1258
|
+
.isNull(db.qi(col('inProgressSince')), true) // the precondition — second arg enables it
|
|
1259
|
+
.isEqual(db.qi(col('organizationId')), req.user.orgId); // tenant: QueryClient applies none for you
|
|
1260
|
+
// Record/where keys are quoted by db.update(); ConditionBuilder operands are raw SQL, so qi() them.
|
|
1261
|
+
|
|
1262
|
+
const affected = await db.update(
|
|
1263
|
+
SchemaRequest.tableName,
|
|
1264
|
+
{ [col('inProgressSince')]: new Date(), [col('inProgressBy')]: req.user.id },
|
|
1265
|
+
{ [col('id')]: id },
|
|
1266
|
+
guard
|
|
1267
|
+
);
|
|
1268
|
+
if (affected === 0) return reply.code(409).send({ message: 'Already claimed, or not found' });
|
|
1269
|
+
return { claimed: true };
|
|
1270
|
+
});
|
|
1271
|
+
```
|
|
1272
|
+
|
|
1273
|
+
`QueryClient` runs no hooks, no validation and **no tenant scoping**: the route owns all three.
|
|
1274
|
+
Keep the predicate server-chosen, as above. Letting the client name the column it compares on
|
|
1275
|
+
turns `affected` into an oracle on values it cannot read.
|
|
1276
|
+
|
|
1214
1277
|
### Encrypt a column at rest (storage representation ≠ API representation)
|
|
1215
1278
|
|
|
1216
1279
|
Three declarations on the table that owns the column — in on the write hooks, out on the read
|
package/AGENTS_FRONTEND.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
This file is the request GRAMMAR. For this deployment's tables/fields/aliases (the
|
|
4
4
|
vocabulary), fetch `GET {p}/agent/manifest.md` if available, or consult Swagger.
|
|
5
5
|
|
|
6
|
-
Rules: all request/response fields are camelCase schema field names — how the database names its columns is invisible here and irrelevant, the server maps them. `{p}` = deployment prefix (e.g. `/api`). Join keys are backend-declared ALIASES (default = joined table name; list per table in Swagger). Write responses are PK-only. Write bodies reject unknown properties (400) — send only real schema fields. Arrays for bulk are capped (default 1000/request; chunk client-side). Composite-PK tables have no get/delete/bulk-delete (404) — use search / update.
|
|
6
|
+
Rules: all request/response fields are camelCase schema field names — how the database names its columns is invisible here and irrelevant, the server maps them. `{p}` = deployment prefix (e.g. `/api`). `{table}` = the table's key in the manifest, which may differ from the database table name (one table can be exposed under several keys, each with its own rules). Join keys are backend-declared ALIASES (default = joined table name; list per table in Swagger). Write responses are PK-only. Write bodies reject unknown properties (400) — send only real schema fields. Arrays for bulk are capped (default 1000/request; chunk client-side). Composite-PK tables have no get/delete/bulk-delete (404) — use search / update.
|
|
7
7
|
|
|
8
8
|
## Endpoints
|
|
9
9
|
|
package/BREAKING_CHANGES.md
CHANGED
|
@@ -692,3 +692,55 @@ beforeInsert: async (db, req, record) => {
|
|
|
692
692
|
if (req.routeOptions?.url?.startsWith('/rest/')) await notify(record); // side effect: narrow it
|
|
693
693
|
},
|
|
694
694
|
```
|
|
695
|
+
|
|
696
|
+
---
|
|
697
|
+
|
|
698
|
+
# Breaking Change — the route URL is the `DbTables` key
|
|
699
|
+
|
|
700
|
+
*Landed in **0.3.0**.*
|
|
701
|
+
|
|
702
|
+
## Why this changed
|
|
703
|
+
|
|
704
|
+
The documentation always said *"the route URL name is the key you use in the `dbTables`
|
|
705
|
+
record"*, and everything but the URL agreed: the handler, `operations`, `onRequests`, the
|
|
706
|
+
Swagger tags and the agent manifest all resolved the table config by its key. The URL alone was
|
|
707
|
+
built from `Schema.tableName`. Two consequences:
|
|
708
|
+
|
|
709
|
+
- **Several table configs over one physical table could not coexist.** Exposing
|
|
710
|
+
`conversation` once for its owner and once for whoever holds a share link — each key with its
|
|
711
|
+
own `operations`, guards and joins — put both on `/search/conversation`, and Fastify refused
|
|
712
|
+
to start: `Method 'POST' already declared for route '/search/conversation'`.
|
|
713
|
+
- **A renamed key was served under the wrong name.** `{ orders: TableCustomerOrder }` started
|
|
714
|
+
fine, but answered on `/search/customer_order` while the manifest told agent clients to call
|
|
715
|
+
`orders`.
|
|
716
|
+
|
|
717
|
+
## What changed
|
|
718
|
+
|
|
719
|
+
- Every auto route is named after the key: `/search/<key>`, `/rest/<key>`, `/rest/<key>/:id`,
|
|
720
|
+
`/bulk/<key>`, `/bulk/<key>/delete`.
|
|
721
|
+
- The search response's `table` is the key, so it matches the URL that was called and the
|
|
722
|
+
manifest entry.
|
|
723
|
+
- SQL is unchanged: it still targets `Schema.tableName`.
|
|
724
|
+
|
|
725
|
+
## Who is affected
|
|
726
|
+
|
|
727
|
+
- **A `DbTables` key of yours differs from its schema's `tableName`.** Its routes move from the
|
|
728
|
+
table name to the key, and `table` in its search responses changes the same way.
|
|
729
|
+
|
|
730
|
+
Not affected: configs generated by `sqlapi-generate-tables`, whose keys are the table names, and
|
|
731
|
+
any hand-written config that kept that convention.
|
|
732
|
+
|
|
733
|
+
## Migration
|
|
734
|
+
|
|
735
|
+
Either call the new URLs, or rename the key back to the table name to keep the old ones:
|
|
736
|
+
|
|
737
|
+
```typescript
|
|
738
|
+
// before: routes answered on /search/customer_order
|
|
739
|
+
export const dbTables: DbTables = { orders: TableCustomerOrder };
|
|
740
|
+
|
|
741
|
+
// keep the old URLs
|
|
742
|
+
export const dbTables: DbTables = { customer_order: TableCustomerOrder };
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
A client that read `table` from a search response to recover the physical table name should
|
|
746
|
+
use the key it called instead — that is now what `table` returns.
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,96 @@ All notable changes to this project are documented here. This project follows
|
|
|
7
7
|
|
|
8
8
|
Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BREAKING_CHANGES.md)**.
|
|
9
9
|
|
|
10
|
+
## [Unreleased]
|
|
11
|
+
|
|
12
|
+
## [0.3.0]
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **BREAKING: the route URL is the `DbTables` key, as documented.** Every auto route built its
|
|
17
|
+
URL from `Schema.tableName` while the handler, `operations`, the Swagger tags and the agent
|
|
18
|
+
manifest all used the key. Two table configs over the same physical table (an owner view and
|
|
19
|
+
a shared-link view of `conversation`) collided on `/search/conversation` and the app refused
|
|
20
|
+
to start; a single renamed key started fine but was served under the table name, so the
|
|
21
|
+
manifest advertised a URL that did not exist. Routes are now `/search/<key>`,
|
|
22
|
+
`/rest/<key>`, `/bulk/<key>`, and the search response's `table` names the key too. Configs
|
|
23
|
+
generated by `sqlapi-generate-tables` use the table name as key and are unaffected. See
|
|
24
|
+
[BREAKING_CHANGES.md](./BREAKING_CHANGES.md#breaking-change--the-route-url-is-the-dbtables-key).
|
|
25
|
+
- **`fastify-plugin` 5 → 6.** The only runtime dependency bump: v6 renames its entry files and
|
|
26
|
+
drops the long-deprecated `PluginOptions` type (now `PluginMetadata`), neither of which this
|
|
27
|
+
library uses. No change for consumers; still Fastify 5.
|
|
28
|
+
- **Development dependencies updated** — `@fastify/swagger-ui` 6 (still covered by the
|
|
29
|
+
`>=5.0.0` peer range), `fastify` pinned to 5.12.4, and the latest minors of the lint and
|
|
30
|
+
gate tooling. TypeScript stays on 5.9: `typescript-eslint` does not support TypeScript 7 yet.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **An empty tenant list is refused, not executed.** `getTenantId` returning `[]` (a caller
|
|
35
|
+
who belongs to no tenant) used to reach the database as `organization_id IN ()` — a syntax
|
|
36
|
+
error on both dialects, so a `500` that failed closed only by accident, and one step away
|
|
37
|
+
from a leak had any builder dropped the empty predicate. `resolveTenant` now answers `403`
|
|
38
|
+
before any SQL exists, on every operation and through relations from an unscoped host.
|
|
39
|
+
`null`/`undefined` remain the only admin values; `0` and `''` remain ordinary tenant ids.
|
|
40
|
+
- **The tenant column is optional in the insert body.** The engine injects it for a
|
|
41
|
+
single-tenant caller and validates it for a multi-tenant or `anyOf` one, yet the body schema
|
|
42
|
+
still required it whenever the generated schema did — so the documented injection answered
|
|
43
|
+
`400 must have required property` over HTTP, and the only way out was a `schemaOverrides`
|
|
44
|
+
workaround (or `excludeFromCreation`, which silently broke multi-tenant callers by stripping
|
|
45
|
+
the value they must send). The own column of a direct scope and every column of an `anyOf`
|
|
46
|
+
scope are now `Optional` on insert `main` and on secondaries items of every write body — the
|
|
47
|
+
same rule that already made a secondary's FK optional. Indirect scopes are untouched: their
|
|
48
|
+
FK is an ordinary field.
|
|
49
|
+
|
|
50
|
+
### Documentation
|
|
51
|
+
|
|
52
|
+
- **[ADR 0020](./docs/adr/0020-no-conditional-or-mass-update-route.md): no conditional or mass
|
|
53
|
+
update route.** A conditional `updateWhere` operation was designed and rejected: it would be
|
|
54
|
+
the first route writing N rows chosen by the client, and it cuts across `QueryClient.update`,
|
|
55
|
+
the search condition builder, the tenant guards and the hook contract. Compare-and-set
|
|
56
|
+
(claiming a row two clients must not both claim) is a custom route on
|
|
57
|
+
`db.update(table, fields, where, extraCondition)`; `AGENTS_BACKEND.md` carries the pattern
|
|
58
|
+
("Conditional update — claim a row atomically").
|
|
59
|
+
|
|
60
|
+
### Internal
|
|
61
|
+
|
|
62
|
+
- **Tenant isolation test audit.** New specs pin what was enforced but unproven: the tenant
|
|
63
|
+
reaches every HTTP route and every `SqlApi` method; `deletions` bind the child to the main
|
|
64
|
+
row FK (an unscoped child under a scoped parent included) and follow indirect and `anyOf`
|
|
65
|
+
child scopes; a secondary cannot be re-parented by a payload FK; upserted secondaries are
|
|
66
|
+
conflict-guarded on update and bulk upsert; a multi-tenant caller cannot write an unanchored
|
|
67
|
+
secondary; direct-scope update and bulk upsert; indirect-scope delete and bulk delete; request
|
|
68
|
+
predicates on the tenant column never widen the scope; an indirect join-table scope through a
|
|
69
|
+
third table is enforced inside `joinMustExist`, `joinGroup` and aggregation subqueries.
|
|
70
|
+
- **`swagger: true` is now tested.** `setupSwagger` swallows a failed import of
|
|
71
|
+
`@fastify/swagger`/`@fastify/swagger-ui` and only logs a warning, so a dependency bump that
|
|
72
|
+
broke the documentation would have passed every test. Unit tests now assert the served
|
|
73
|
+
OpenAPI spec and UI — default path, custom `SwaggerOptions`, and under the plugin `prefix`.
|
|
74
|
+
|
|
75
|
+
## [0.2.4]
|
|
76
|
+
|
|
77
|
+
### Added
|
|
78
|
+
|
|
79
|
+
- **`excludeColumns` config option** — the lever for *"this column must not exist for the API at
|
|
80
|
+
all"*. [ADR 0017](./docs/adr/0017-the-schema-is-the-contract.md), the README and `AGENTS.md`
|
|
81
|
+
all pointed at "trim it out of the Schema" for that case, and no such lever existed: Schema
|
|
82
|
+
files are generated and overwritten on every run, so the only way to drop a column was to
|
|
83
|
+
re-edit the file after each `sqlapi-generate-schema` — or to pair `readExclude` with
|
|
84
|
+
`writeExclude`, which `defineTable` refuses. `excludeColumns` in `sqlapi.config.ts` takes
|
|
85
|
+
`"<table>.<column>"` entries matched against the DB names, with `*` as a wildcard on either
|
|
86
|
+
side (`'user.password_hash'`, `'*.internal_*'`), and drops the column from the generated Schema
|
|
87
|
+
— its fields, `colMap` and `generatedFields` — so no route, filter, order or manifest ever
|
|
88
|
+
sees it. A primary key column is refused (every route identifies rows with it), and so is a
|
|
89
|
+
pattern that would leave a table with no columns: that is `excludeTables`' job. Applied after
|
|
90
|
+
`excludeTables`, so a column pattern is never checked against a table the generator does not
|
|
91
|
+
see. **Regenerate your schemas** after adding entries. See
|
|
92
|
+
[ADR 0019](./docs/adr/0019-column-exclusion-is-generation-time-config.md).
|
|
93
|
+
|
|
94
|
+
### Changed
|
|
95
|
+
|
|
96
|
+
- **The docs no longer say "trim the Schema".** The README lever table, `AGENTS.md`,
|
|
97
|
+
`AGENTS_BACKEND.md` and the `defineTable` error for a field in both `readExclude` and
|
|
98
|
+
`writeExclude` now point at `excludeColumns`.
|
|
99
|
+
|
|
10
100
|
## [0.2.3]
|
|
11
101
|
|
|
12
102
|
### Fixed
|
|
@@ -595,6 +685,18 @@ the join API redesign (documented in [BREAKING_CHANGES.md](./BREAKING_CHANGES.md
|
|
|
595
685
|
primary key support in updates, transactional insert/update with secondaries, the hook matrix,
|
|
596
686
|
and bulk operations.
|
|
597
687
|
|
|
688
|
+
[Unreleased]: https://github.com/LucaRainone/fastify-auto-sqlapi/compare/v0.3.0...HEAD
|
|
689
|
+
[0.3.0]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.3.0
|
|
690
|
+
[0.2.4]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.2.4
|
|
691
|
+
[0.2.3]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.2.3
|
|
692
|
+
[0.2.2]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.2.2
|
|
693
|
+
[0.2.1]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.2.1
|
|
694
|
+
[0.2.0]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.2.0
|
|
695
|
+
[0.1.14]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.14
|
|
696
|
+
[0.1.13]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.13
|
|
697
|
+
[0.1.12]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.12
|
|
698
|
+
[0.1.11]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.11
|
|
699
|
+
[0.1.10]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.10
|
|
598
700
|
[0.1.7]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.7
|
|
599
701
|
[0.1.6]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.6
|
|
600
702
|
[0.1.5]: https://github.com/LucaRainone/fastify-auto-sqlapi/releases/tag/v0.1.5
|
package/README.md
CHANGED
|
@@ -215,7 +215,7 @@ each way they need to differ:
|
|
|
215
215
|
|---|---|
|
|
216
216
|
| a column must never be read | `readExclude` |
|
|
217
217
|
| a column must never be written | `writeExclude` |
|
|
218
|
-
| a column must not exist for the API at all |
|
|
218
|
+
| a column must not exist for the API at all | `excludeColumns` in `sqlapi.config.ts` |
|
|
219
219
|
| the API type must be stricter than the column | `schemaOverrides` |
|
|
220
220
|
| the value is derived, not stored | `computedFields` |
|
|
221
221
|
| the stored representation is not the API one | `afterRead` (decrypt, unpack, rescale) |
|
|
@@ -4,7 +4,7 @@ import * as path from 'node:path';
|
|
|
4
4
|
import { loadConfig } from '../lib/cli/config.js';
|
|
5
5
|
import { buildConnectionString, introspectTables } from '../lib/cli/pg-introspect.js';
|
|
6
6
|
import { buildMysqlConnectionConfig, introspectMysqlTables } from '../lib/cli/mysql-introspect.js';
|
|
7
|
-
import { applyExcludeTables, buildTableMap, generateSchemaFile } from '../lib/cli/schema-codegen.js';
|
|
7
|
+
import { applyExcludeColumns, applyExcludeTables, buildTableMap, generateSchemaFile, } from '../lib/cli/schema-codegen.js';
|
|
8
8
|
import { loadEnvFile, CONSOLE_COLORS, display, displayAsTableRow, parseArgs, runCli } from './utils.js';
|
|
9
9
|
const STATUS_COLOR = {
|
|
10
10
|
created: CONSOLE_COLORS.green,
|
|
@@ -157,6 +157,12 @@ await runCli('fastify-auto-sqlapi: generating schemas', async () => {
|
|
|
157
157
|
if (excluded.length) {
|
|
158
158
|
display(`Excluded by config (excludeTables): ${excluded.join(', ')}`, CONSOLE_COLORS.gray);
|
|
159
159
|
}
|
|
160
|
+
// Runs after the table blacklist so a column pattern is never checked against a table the
|
|
161
|
+
// generator will not see anyway (its primary key included).
|
|
162
|
+
const excludedColumns = applyExcludeColumns(tableMap, config.excludeColumns);
|
|
163
|
+
if (excludedColumns.length) {
|
|
164
|
+
display(`Excluded by config (excludeColumns): ${excludedColumns.join(', ')}`, CONSOLE_COLORS.gray);
|
|
165
|
+
}
|
|
160
166
|
const tableNames = cliArgs.tables;
|
|
161
167
|
if (!keepOnlyRequestedTables(tableMap, tableNames)) {
|
|
162
168
|
display(`No matching tables found for: ${tableNames.join(', ')}`, CONSOLE_COLORS.magenta);
|
|
@@ -16,5 +16,19 @@ export declare function buildTableMap(rows: ColumnInfo[]): TableMap;
|
|
|
16
16
|
* Returns the names of the excluded tables, in map order.
|
|
17
17
|
*/
|
|
18
18
|
export declare function applyExcludeTables(tableMap: TableMap, excludeTables?: string[]): string[];
|
|
19
|
+
/**
|
|
20
|
+
* Remove from every table in `tableMap` the columns matching any of the `excludeColumns`
|
|
21
|
+
* config patterns. A pattern is `<table>.<column>`, both parts matched against the DB names
|
|
22
|
+
* (not the generated field names) with `*` as the only wildcard on either side.
|
|
23
|
+
*
|
|
24
|
+
* An excluded column does not exist for the API at all: it leaves the Schema's fields, its
|
|
25
|
+
* `colMap` and its `generatedFields`, so every read, write, filter and manifest built from the
|
|
26
|
+
* Schema is unaware of it. That is also why the primary key is refused — every engine
|
|
27
|
+
* identifies rows with it — and why a table that would end up empty is refused too: that
|
|
28
|
+
* is `excludeTables`' job.
|
|
29
|
+
*
|
|
30
|
+
* Returns the excluded columns as `table.column`, in map order.
|
|
31
|
+
*/
|
|
32
|
+
export declare function applyExcludeColumns(tableMap: TableMap, excludeColumns?: string[]): string[];
|
|
19
33
|
export declare function generateSchemaFile(schemaName: string, tableName: string, fields: Record<string, string>, colMap: Record<string, string>, primary?: string[], generated?: string[], isView?: boolean): string;
|
|
20
34
|
//# sourceMappingURL=schema-codegen.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema-codegen.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/schema-codegen.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE3D;;GAEG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,UAAU,EAAE;IACV,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB,GACA,MAAM,CA6DR;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,QAAQ,CA0C1D;
|
|
1
|
+
{"version":3,"file":"schema-codegen.d.ts","sourceRoot":"","sources":["../../../src/lib/cli/schema-codegen.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE3D;;GAEG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,UAAU,EAAE;IACV,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB,GACA,MAAM,CA6DR;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,QAAQ,CA0C1D;AAaD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,EAAE,aAAa,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAgBzF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,cAAc,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAiD3F;AAED,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,OAAO,CAAC,EAAE,MAAM,EAAE,EAClB,SAAS,CAAC,EAAE,MAAM,EAAE,EACpB,MAAM,CAAC,EAAE,OAAO,GACf,MAAM,CAoDR"}
|
|
@@ -95,6 +95,15 @@ export function buildTableMap(rows) {
|
|
|
95
95
|
return acc;
|
|
96
96
|
}, {});
|
|
97
97
|
}
|
|
98
|
+
/** Compile a config glob — `*` is the only wildcard, everything else is literal. */
|
|
99
|
+
function globToRegExp(pattern) {
|
|
100
|
+
return new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replaceAll('\\*', '.*')}$`);
|
|
101
|
+
}
|
|
102
|
+
function assertPatternList(option, value) {
|
|
103
|
+
if (!Array.isArray(value) || value.some((p) => typeof p !== 'string')) {
|
|
104
|
+
throw new Error(`Invalid config: "${option}" must be an array of strings.`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
98
107
|
/**
|
|
99
108
|
* Remove from `tableMap` the tables matching any of the `excludeTables` config patterns.
|
|
100
109
|
* Patterns are matched against the DB table name (not the generated schema name);
|
|
@@ -104,12 +113,10 @@ export function buildTableMap(rows) {
|
|
|
104
113
|
export function applyExcludeTables(tableMap, excludeTables) {
|
|
105
114
|
if (excludeTables === undefined)
|
|
106
115
|
return [];
|
|
107
|
-
|
|
108
|
-
throw new Error('Invalid config: "excludeTables" must be an array of strings.');
|
|
109
|
-
}
|
|
116
|
+
assertPatternList('excludeTables', excludeTables);
|
|
110
117
|
if (excludeTables.length === 0)
|
|
111
118
|
return [];
|
|
112
|
-
const regexes = excludeTables.map(
|
|
119
|
+
const regexes = excludeTables.map(globToRegExp);
|
|
113
120
|
const excluded = [];
|
|
114
121
|
for (const schemaName of Object.keys(tableMap)) {
|
|
115
122
|
const tableName = tableMap[schemaName].name;
|
|
@@ -120,6 +127,62 @@ export function applyExcludeTables(tableMap, excludeTables) {
|
|
|
120
127
|
}
|
|
121
128
|
return excluded;
|
|
122
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Remove from every table in `tableMap` the columns matching any of the `excludeColumns`
|
|
132
|
+
* config patterns. A pattern is `<table>.<column>`, both parts matched against the DB names
|
|
133
|
+
* (not the generated field names) with `*` as the only wildcard on either side.
|
|
134
|
+
*
|
|
135
|
+
* An excluded column does not exist for the API at all: it leaves the Schema's fields, its
|
|
136
|
+
* `colMap` and its `generatedFields`, so every read, write, filter and manifest built from the
|
|
137
|
+
* Schema is unaware of it. That is also why the primary key is refused — every engine
|
|
138
|
+
* identifies rows with it — and why a table that would end up empty is refused too: that
|
|
139
|
+
* is `excludeTables`' job.
|
|
140
|
+
*
|
|
141
|
+
* Returns the excluded columns as `table.column`, in map order.
|
|
142
|
+
*/
|
|
143
|
+
export function applyExcludeColumns(tableMap, excludeColumns) {
|
|
144
|
+
if (excludeColumns === undefined)
|
|
145
|
+
return [];
|
|
146
|
+
assertPatternList('excludeColumns', excludeColumns);
|
|
147
|
+
if (excludeColumns.length === 0)
|
|
148
|
+
return [];
|
|
149
|
+
const matchers = excludeColumns.map((pattern) => {
|
|
150
|
+
const dot = pattern.indexOf('.');
|
|
151
|
+
if (dot <= 0 || dot === pattern.length - 1) {
|
|
152
|
+
throw new Error(`Invalid config: excludeColumns entry "${pattern}" must be in the form "table.column" ` +
|
|
153
|
+
`(a "*" wildcard is allowed on either side, e.g. "*.password_hash").`);
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
table: globToRegExp(pattern.slice(0, dot)),
|
|
157
|
+
column: globToRegExp(pattern.slice(dot + 1)),
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
const excluded = [];
|
|
161
|
+
for (const schemaName of Object.keys(tableMap)) {
|
|
162
|
+
const table = tableMap[schemaName];
|
|
163
|
+
const tableMatchers = matchers.filter((m) => m.table.test(table.name));
|
|
164
|
+
if (tableMatchers.length === 0)
|
|
165
|
+
continue;
|
|
166
|
+
for (const field of Object.keys(table.fields)) {
|
|
167
|
+
const columnName = table.colMap[field];
|
|
168
|
+
if (!tableMatchers.some((m) => m.column.test(columnName)))
|
|
169
|
+
continue;
|
|
170
|
+
if (table.primary.includes(field)) {
|
|
171
|
+
throw new Error(`Invalid config: excludeColumns cannot exclude the primary key column ` +
|
|
172
|
+
`"${table.name}.${columnName}" — every route identifies rows with it.`);
|
|
173
|
+
}
|
|
174
|
+
delete table.fields[field];
|
|
175
|
+
delete table.colMap[field];
|
|
176
|
+
table.generated = table.generated.filter((f) => f !== field);
|
|
177
|
+
excluded.push(`${table.name}.${columnName}`);
|
|
178
|
+
}
|
|
179
|
+
if (Object.keys(table.fields).length === 0) {
|
|
180
|
+
throw new Error(`Invalid config: excludeColumns leaves table "${table.name}" with no columns. ` +
|
|
181
|
+
`To hide a whole table use excludeTables instead.`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return excluded;
|
|
185
|
+
}
|
|
123
186
|
export function generateSchemaFile(schemaName, tableName, fields, colMap, primary, generated, isView) {
|
|
124
187
|
const fieldsStr = JSON.stringify(fields, null, 2).replace(/"(.*?)"\s*:\s*"Type(.*?)"/g, '$1: Type$2');
|
|
125
188
|
const colMapStr = JSON.stringify(colMap, null, 2);
|
|
@@ -5,6 +5,13 @@ import type { ITable, SchemaDefinition, DbTables, JoinDefinition } from '../../t
|
|
|
5
5
|
* Falls back to `fallback` field if tableConf is undefined.
|
|
6
6
|
*/
|
|
7
7
|
export declare function pkSchema(tableConf: ITable | undefined, schema: SchemaDefinition, fallback: string): Record<string, TSchema>;
|
|
8
|
+
/**
|
|
9
|
+
* Make the tenant column(s) of a scoped table Optional in a write body. The engine injects the
|
|
10
|
+
* value for a single-tenant caller and validates the one a multi-tenant or `anyOf` caller sends,
|
|
11
|
+
* so the body must offer the field without demanding it — the same reason a secondary's FK is
|
|
12
|
+
* Optional. `tenantScope` names real DB columns; the schema field is found through `col()`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function relaxTenantColumns(fields: Record<string, TSchema>, tableConf: ITable | undefined, schema: SchemaDefinition): void;
|
|
8
15
|
/** The same schema without the Optional modifier, i.e. as a required property. */
|
|
9
16
|
export declare function asRequired(schema: TSchema): TSchema;
|
|
10
17
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAMzF;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQ3H;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,gBAAgB,GACvB,IAAI,CAMN;AAoCD,kFAAkF;AAClF,wBAAgB,UAAU,CAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CAGnD;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,SAAS,GAC5B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASzB;AAED,KAAK,iBAAiB,GAAG,CACvB,IAAI,EAAE,cAAc,EACpB,aAAa,EAAE,MAAM,GAAG,SAAS,KAC9B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAwB7B;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE;IAAE,aAAa,EAAE,OAAO,CAAC;IAAC,eAAe,EAAE,iBAAiB,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,CAAA;CAAE,GAC7F,IAAI,CA8BN;AAED,qGAAqG;AACrG,eAAO,MAAM,mBAAmB,EAAE,iBACmB,CAAC;AAEtD,uDAAuD;AACvD,eAAO,MAAM,uBAAuB,EAAE,iBACG,CAAC;AAE1C,4DAA4D;AAC5D,wBAAgB,cAAc,CAC5B,MAAM,EAAE,gBAAgB,EACxB,SAAS,EAAE,MAAM,GAAG,SAAS,GAC5B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAOzB"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Type, OptionalKind } from '@sinclair/typebox';
|
|
2
2
|
import { readableFieldNames } from '../read-access.js';
|
|
3
3
|
import { writableFields } from '../write-access.js';
|
|
4
|
+
import { tenantOwnColumns } from '../tenant.js';
|
|
4
5
|
import { findSecondaryTableConf } from '../engine/write-helpers.js';
|
|
5
6
|
/**
|
|
6
7
|
* Build a PK-only schema for response shapes (single or composite PK).
|
|
@@ -15,10 +16,26 @@ export function pkSchema(tableConf, schema, fallback) {
|
|
|
15
16
|
}
|
|
16
17
|
return result;
|
|
17
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Make the tenant column(s) of a scoped table Optional in a write body. The engine injects the
|
|
21
|
+
* value for a single-tenant caller and validates the one a multi-tenant or `anyOf` caller sends,
|
|
22
|
+
* so the body must offer the field without demanding it — the same reason a secondary's FK is
|
|
23
|
+
* Optional. `tenantScope` names real DB columns; the schema field is found through `col()`.
|
|
24
|
+
*/
|
|
25
|
+
export function relaxTenantColumns(fields, tableConf, schema) {
|
|
26
|
+
const columns = tenantOwnColumns(tableConf?.tenantScope);
|
|
27
|
+
if (columns.length === 0)
|
|
28
|
+
return;
|
|
29
|
+
for (const field of Object.keys(fields)) {
|
|
30
|
+
if (columns.includes(schema.col(field)))
|
|
31
|
+
fields[field] = Type.Optional(fields[field]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
18
34
|
/**
|
|
19
35
|
* Build secondary table fields for body schemas:
|
|
20
36
|
* - FK field becomes Optional (auto-filled from main)
|
|
21
37
|
* - excludeFromCreation fields become Optional
|
|
38
|
+
* - tenant column(s) become Optional (injected or validated by the engine)
|
|
22
39
|
*/
|
|
23
40
|
function buildSecondaryFields(joinSchema, joinField, secondaryTableConf) {
|
|
24
41
|
const fields = writableFields({ ...joinSchema.fields }, secondaryTableConf, joinSchema);
|
|
@@ -32,6 +49,7 @@ function buildSecondaryFields(joinSchema, joinField, secondaryTableConf) {
|
|
|
32
49
|
}
|
|
33
50
|
}
|
|
34
51
|
}
|
|
52
|
+
relaxTenantColumns(fields, secondaryTableConf, joinSchema);
|
|
35
53
|
return fields;
|
|
36
54
|
}
|
|
37
55
|
/** The same schema without the Optional modifier, i.e. as a required property. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"insert.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/insert.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAErE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"insert.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/insert.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAErE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAW/C,wBAAgB,eAAe,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAiC9E;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAalF"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Type } from '@sinclair/typebox';
|
|
2
2
|
import { primaryAsString } from '../../types.js';
|
|
3
3
|
import { writableFields } from '../write-access.js';
|
|
4
|
-
import { pkSchema, applySchemaOverrides, attachWriteJoinSections, writeJoinBodyFields, writeJoinResponseFields, } from './helpers.js';
|
|
4
|
+
import { pkSchema, applySchemaOverrides, attachWriteJoinSections, relaxTenantColumns, writeJoinBodyFields, writeJoinResponseFields, } from './helpers.js';
|
|
5
5
|
export function InsertTableBody(dbTables, tableName) {
|
|
6
6
|
const tableConf = dbTables[tableName];
|
|
7
7
|
const schema = tableConf.Schema;
|
|
@@ -15,6 +15,8 @@ export function InsertTableBody(dbTables, tableName) {
|
|
|
15
15
|
mainFields[field] = Type.Optional(mainFields[field]);
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
+
// The tenant column is the engine's to fill or check, never the client's to be forced to send.
|
|
19
|
+
relaxTenantColumns(mainFields, tableConf, schema);
|
|
18
20
|
const bodyProperties = {
|
|
19
21
|
// additionalProperties:false makes the schema the real write whitelist — unknown keys are
|
|
20
22
|
// rejected (400) instead of silently reaching the INSERT as columns (mass assignment).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"table-helpers.d.ts","sourceRoot":"","sources":["../../src/lib/table-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,yBAAyB,EAAiC,MAAM,wBAAwB,CAAC;AACzH,OAAO,EAAQ,KAAK,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,MAAM,EAEP,MAAM,aAAa,CAAC;AAcrB,KAAK,UAAU,CAAC,EAAE,IAAI,EAAE,SAAS,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAExH,wBAAgB,eAAe,CAC7B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,EAAE,SAAS,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,EAErE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,YAAY,GAAE,EAAa,EAC3B,iBAAiB,CAAC,EAAE,CAClB,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,yBAAyB;CAAE,KAC3E,IAAI,GACR;IAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAAC,OAAO,EAAE,aAAa,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAsChG;AAED,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAChB,MAAM,CAAC,CAAC,CAAC,CAWX;
|
|
1
|
+
{"version":3,"file":"table-helpers.d.ts","sourceRoot":"","sources":["../../src/lib/table-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,yBAAyB,EAAiC,MAAM,wBAAwB,CAAC;AACzH,OAAO,EAAQ,KAAK,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,MAAM,EAEP,MAAM,aAAa,CAAC;AAcrB,KAAK,UAAU,CAAC,EAAE,IAAI,EAAE,SAAS,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAExH,wBAAgB,eAAe,CAC7B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,EAAE,SAAS,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,EAErE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,YAAY,GAAE,EAAa,EAC3B,iBAAiB,CAAC,EAAE,CAClB,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,yBAAyB;CAAE,KAC3E,IAAI,GACR;IAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAAC,OAAO,EAAE,aAAa,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAsChG;AAED,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAChB,MAAM,CAAC,CAAC,CAAC,CAWX;AAiSD,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AAgDD,wBAAgB,aAAa,CAC3B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAEjC,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,EAClD,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,EAC3B,OAAO,CAAC,EAAE,oBAAoB,GAC7B,cAAc,CAWhB;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,OAAO,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,GAC5B,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAE9B;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,KAAK,EAAE,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,EAAE,GACvC,GAAG,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAEjC"}
|
|
@@ -250,7 +250,8 @@ function validateWriteExclude(config) {
|
|
|
250
250
|
}
|
|
251
251
|
if (config.readExclude?.includes(field)) {
|
|
252
252
|
throw new Error(`defineTable: field '${field}' on table '${table}' is in both readExclude and ` +
|
|
253
|
-
`writeExclude, leaving it neither readable nor writable.
|
|
253
|
+
`writeExclude, leaving it neither readable nor writable. Exclude it at generation time ` +
|
|
254
|
+
`with excludeColumns in sqlapi.config.ts instead.`);
|
|
254
255
|
}
|
|
255
256
|
}
|
|
256
257
|
}
|
package/dist/lib/tenant.d.ts
CHANGED
|
@@ -2,6 +2,12 @@ import type { ApiRequest } from '../types/request.js';
|
|
|
2
2
|
import type { QueryClient } from './db.js';
|
|
3
3
|
import { ConditionBuilder, type ConditionValue, type ConditionValueOrUndefined } from 'node-condition-builder';
|
|
4
4
|
import type { ITable, SqlApiPluginOptions, TenantId, TenantScope, TenantScopeDirect, TenantScopeIndirect, TenantScopeAnyOf, TenantContext } from '../types.js';
|
|
5
|
+
/**
|
|
6
|
+
* The columns of a scoped table that the engine fills or validates on insert, i.e. the ones a
|
|
7
|
+
* write body must offer without requiring: the own column of a direct scope, every column of an
|
|
8
|
+
* `anyOf` scope. An indirect scope names no column of its own — its FK is an ordinary field.
|
|
9
|
+
*/
|
|
10
|
+
export declare function tenantOwnColumns(scope: TenantScope | undefined): string[];
|
|
5
11
|
export declare function resolveTenant(options: SqlApiPluginOptions, tableConf: ITable, request: ApiRequest): Promise<TenantContext | undefined>;
|
|
6
12
|
/**
|
|
7
13
|
* The read predicate for a scope, as a fragment every caller appends to its own WHERE.
|
package/dist/lib/tenant.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tenant.d.ts","sourceRoot":"","sources":["../../src/lib/tenant.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC3C,OAAO,EAAE,gBAAgB,EAAE,KAAK,cAAc,EAAE,KAAK,yBAAyB,EAAE,MAAM,wBAAwB,CAAC;AAE/G,OAAO,KAAK,EAEV,MAAM,EACN,mBAAmB,EACnB,QAAQ,EACR,WAAW,EACX,iBAAiB,EACjB,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,EACd,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"tenant.d.ts","sourceRoot":"","sources":["../../src/lib/tenant.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC3C,OAAO,EAAE,gBAAgB,EAAE,KAAK,cAAc,EAAE,KAAK,yBAAyB,EAAE,MAAM,wBAAwB,CAAC;AAE/G,OAAO,KAAK,EAEV,MAAM,EACN,mBAAmB,EACnB,QAAQ,EACR,WAAW,EACX,iBAAiB,EACjB,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,EACd,MAAM,aAAa,CAAC;AAmBrB;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,WAAW,GAAG,SAAS,GAAG,MAAM,EAAE,CAGzE;AAuBD,wBAAsB,aAAa,CACjC,OAAO,EAAE,mBAAmB,EAC5B,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,UAAU,GAClB,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC,CAiBpC;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAClC,EAAE,EAAE,WAAW,EACf,KAAK,EAAE,WAAW,EAClB,SAAS,EAAE,QAAQ,EAAE,EACrB,QAAQ,EAAE,MAAM,GACf,gBAAgB,CAgBlB;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,aAAa,GAAG,SAAS,EACjC,SAAS,EAAE,MAAM,GAAG,SAAS,GAC5B,aAAa,GAAG,SAAS,CAI3B;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CACjC,EAAE,EAAE,WAAW,EACf,MAAM,EAAE,aAAa,GAAG,SAAS,EACjC,QAAQ,EAAE,MAAM,GACf,gBAAgB,GAAG,SAAS,CAoB9B;AAED,wBAAgB,eAAe,CAC7B,EAAE,EAAE,WAAW,EACf,KAAK,EAAE,mBAAmB,EAC1B,aAAa,EAAE,MAAM,GACpB,MAAM,CAMR;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,KAAK,EAAE,gBAAgB,EACvB,SAAS,EAAE,QAAQ,EAAE,GACpB,IAAI,CAYN;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,KAAK,EAAE,iBAAiB,GAAG,mBAAmB,EAC9C,SAAS,EAAE,QAAQ,EAAE,GACpB,IAAI,CAiBN;AAED;;GAEG;AACH,wBAAsB,gBAAgB,CACpC,EAAE,EAAE,WAAW,EACf,KAAK,EAAE,mBAAmB,EAC1B,SAAS,EAAE,QAAQ,EAAE,EACrB,QAAQ,EAAE,OAAO,EAAE,GAClB,OAAO,CAAC,IAAI,CAAC,CAyBf;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,EAAE,EAAE,WAAW,EACf,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,yBAAyB,GAAG,cAAc,EAAE,EACrD,MAAM,EAAE,aAAa,GACpB;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,EAAE,CAAA;CAAE,CAwBtC;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,yBAAyB,CAC7C,EAAE,EAAE,WAAW,EACf,MAAM,EAAE,aAAa,GAAG,SAAS,EACjC,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,MAAM,EAAE,EACtB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GACjC,OAAO,CAAC,IAAI,CAAC,CAkFf;AAED;;;;;;;;GAQG;AACH,wBAAsB,uBAAuB,CAC3C,EAAE,EAAE,WAAW,EACf,MAAM,EAAE,aAAa,GAAG,SAAS,EACjC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACpC,OAAO,CAAC,IAAI,CAAC,CAQf;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,KAAK,EAAE,WAAW,GACjB,IAAI,CAGN;AAED;;;;;GAKG;AACH,wBAAsB,qBAAqB,CACzC,EAAE,EAAE,WAAW,EACf,MAAM,EAAE,aAAa,GAAG,SAAS,EACjC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GACjC,OAAO,CAAC,IAAI,CAAC,CAaf;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,aAAa,GAAG,SAAS,GAAG,MAAM,EAAE,CAGlF;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,EAAE,EAAE,WAAW,EACf,MAAM,EAAE,aAAa,GAAG,SAAS,GAChC,gBAAgB,GAAG,SAAS,CAU9B;AAED;;;;GAIG;AACH,wBAAsB,qBAAqB,CACzC,EAAE,EAAE,WAAW,EACf,MAAM,EAAE,aAAa,GAAG,SAAS,EACjC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,EACxB,OAAO,EAAE,cAAc,GAAG,cAAc,EAAE,GACzC,OAAO,CAAC,IAAI,CAAC,CAaf;AAED;;;;;GAKG;AACH,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,WAAW,EACf,MAAM,EAAE,aAAa,GAAG,SAAS,EACjC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,cAAc,EAAE,GACpB,OAAO,CAAC,IAAI,CAAC,CAiBf"}
|
package/dist/lib/tenant.js
CHANGED
|
@@ -14,6 +14,16 @@ function isAnyOf(scope) {
|
|
|
14
14
|
function ownColumns(scope) {
|
|
15
15
|
return isAnyOf(scope) ? scope.anyOf : [scope.column];
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* The columns of a scoped table that the engine fills or validates on insert, i.e. the ones a
|
|
19
|
+
* write body must offer without requiring: the own column of a direct scope, every column of an
|
|
20
|
+
* `anyOf` scope. An indirect scope names no column of its own — its FK is an ordinary field.
|
|
21
|
+
*/
|
|
22
|
+
export function tenantOwnColumns(scope) {
|
|
23
|
+
if (!scope || isIndirect(scope))
|
|
24
|
+
return [];
|
|
25
|
+
return ownColumns(scope);
|
|
26
|
+
}
|
|
17
27
|
/**
|
|
18
28
|
* Whether this request could touch a scoped row at all: the addressed table is scoped, or one of
|
|
19
29
|
* the tables it can reach through a declared relation is.
|
|
@@ -44,6 +54,12 @@ export async function resolveTenant(options, tableConf, request) {
|
|
|
44
54
|
if (raw == null)
|
|
45
55
|
return undefined; // admin
|
|
46
56
|
const ids = Array.isArray(raw) ? raw : [raw];
|
|
57
|
+
// An empty list is a caller with no tenant at all — not an admin. Every consumer of `ids`
|
|
58
|
+
// would emit `IN ()` (a syntax error on both dialects) or, worse, a builder could drop the
|
|
59
|
+
// empty predicate and open the table. Refuse here, once, before any SQL exists.
|
|
60
|
+
if (ids.length === 0) {
|
|
61
|
+
throw httpError(403, 'Tenant required: the caller belongs to no tenant');
|
|
62
|
+
}
|
|
47
63
|
// `scope` stays undefined for an unscoped host: the ids still travel, so the tables it reaches
|
|
48
64
|
// enforce their own scopes, while the host itself keeps no filter.
|
|
49
65
|
return { ids, scope: tableConf.tenantScope };
|
|
@@ -7,7 +7,7 @@ export default async function bulkDeleteRoutes(fastify, options) {
|
|
|
7
7
|
operation: 'bulkDelete',
|
|
8
8
|
singlePkOnly: true,
|
|
9
9
|
method: 'POST',
|
|
10
|
-
url: (
|
|
10
|
+
url: (tableName) => `/bulk/${tableName}/delete`,
|
|
11
11
|
successStatus: 200,
|
|
12
12
|
schemas: (db, table) => ({
|
|
13
13
|
body: BulkDeleteTableBody(db, table, options.maxBulkItems ?? DEFAULT_MAX_BULK_ITEMS),
|
|
@@ -5,7 +5,7 @@ export default async function bulkUpsertRoutes(fastify, options) {
|
|
|
5
5
|
await registerForAllTables(fastify, options, {
|
|
6
6
|
operation: 'bulkUpsert',
|
|
7
7
|
method: 'PUT',
|
|
8
|
-
url: (
|
|
8
|
+
url: (tableName) => `/bulk/${tableName}`,
|
|
9
9
|
successStatus: 200,
|
|
10
10
|
schemas: (db, table) => ({
|
|
11
11
|
body: BulkUpsertTableBody(db, table, options.maxBulkItems ?? DEFAULT_MAX_BULK_ITEMS),
|
|
@@ -6,7 +6,7 @@ export default async function deleteRoutes(fastify, options) {
|
|
|
6
6
|
operation: 'delete',
|
|
7
7
|
singlePkOnly: true,
|
|
8
8
|
method: 'DELETE',
|
|
9
|
-
url: (
|
|
9
|
+
url: (tableName) => `/rest/${tableName}/:id`,
|
|
10
10
|
successStatus: 200,
|
|
11
11
|
schemas: (_db, _table, tc) => {
|
|
12
12
|
const pkField = primaryAsString(tc.primary);
|
|
@@ -6,7 +6,7 @@ export default async function getRoutes(fastify, options) {
|
|
|
6
6
|
operation: 'get',
|
|
7
7
|
singlePkOnly: true,
|
|
8
8
|
method: 'GET',
|
|
9
|
-
url: (
|
|
9
|
+
url: (tableName) => `/rest/${tableName}/:id`,
|
|
10
10
|
successStatus: 200,
|
|
11
11
|
schemas: (db, table) => ({
|
|
12
12
|
params: Type.Object({ id: Type.String() }),
|
|
@@ -4,7 +4,7 @@ export default async function insertRoutes(fastify, options) {
|
|
|
4
4
|
await registerForAllTables(fastify, options, {
|
|
5
5
|
operation: 'insert',
|
|
6
6
|
method: 'POST',
|
|
7
|
-
url: (
|
|
7
|
+
url: (tableName) => `/rest/${tableName}`,
|
|
8
8
|
successStatus: 201,
|
|
9
9
|
schemas: (db, table) => ({
|
|
10
10
|
body: InsertTableBody(db, table),
|
|
@@ -18,8 +18,11 @@ export interface AutoRouteSpec {
|
|
|
18
18
|
*/
|
|
19
19
|
singlePkOnly?: boolean;
|
|
20
20
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
21
|
-
/**
|
|
22
|
-
|
|
21
|
+
/**
|
|
22
|
+
* URL builder receiving the `DbTables` key — never `Schema.tableName`: several table
|
|
23
|
+
* configs may expose the same physical table, and each key needs its own route.
|
|
24
|
+
*/
|
|
25
|
+
url: (tableName: string) => string;
|
|
23
26
|
successStatus: 200 | 201;
|
|
24
27
|
/** Build per-table schemas (body, params, querystring, response). Response is wrapped with `{ [successStatus]: ... }` automatically. */
|
|
25
28
|
schemas: (dbTables: DbTables, tableName: string, tableConf: ITable) => RouteSchema;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-helpers.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/route-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAI7E,OAAO,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAQ5F,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAQlG;AAED,UAAU,WAAW;IAEnB,IAAI,CAAC,EAAE,GAAG,CAAC;IAEX,MAAM,CAAC,EAAE,GAAG,CAAC;IAEb,WAAW,CAAC,EAAE,GAAG,CAAC;IAElB,QAAQ,CAAC,EAAE,GAAG,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,iGAAiG;IACjG,SAAS,EAAE,cAAc,CAAC;IAC1B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC1C
|
|
1
|
+
{"version":3,"file":"route-helpers.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/route-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAI7E,OAAO,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAQ5F,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAQlG;AAED,UAAU,WAAW;IAEnB,IAAI,CAAC,EAAE,GAAG,CAAC;IAEX,MAAM,CAAC,EAAE,GAAG,CAAC;IAEb,WAAW,CAAC,EAAE,GAAG,CAAC;IAElB,QAAQ,CAAC,EAAE,GAAG,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,iGAAiG;IACjG,SAAS,EAAE,cAAc,CAAC;IAC1B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC1C;;;OAGG;IACH,GAAG,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,CAAC;IACnC,aAAa,EAAE,GAAG,GAAG,GAAG,CAAC;IACzB,wIAAwI;IACxI,OAAO,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,WAAW,CAAC;IACnF,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,KAAK,MAAM,CAAC;IACpF;2EACuE;IACvE,MAAM,EAAE,CACN,OAAO,EAAE,eAAe,EACxB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,cAAc,EACvB,KAAK,EAAE,YAAY,KAChB,OAAO,CAAC,OAAO,CAAC,CAAC;CACvB;AA6DD,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,EAC5B,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,IAAI,CAAC,CA6Bf"}
|
|
@@ -72,7 +72,7 @@ export async function registerForAllTables(fastify, options, spec) {
|
|
|
72
72
|
const schemas = spec.schemas(DbTables, tableName, tableConf);
|
|
73
73
|
fastify.route({
|
|
74
74
|
method: spec.method,
|
|
75
|
-
url: spec.url(
|
|
75
|
+
url: spec.url(tableName),
|
|
76
76
|
schema: buildRouteSchema(tableName, tableConf, spec, schemas),
|
|
77
77
|
onRequest: mergeOnRequests(options, tableConf),
|
|
78
78
|
handler: async (request, reply) => {
|
|
@@ -6,7 +6,7 @@ export default async function searchRoutes(fastify, options) {
|
|
|
6
6
|
await registerForAllTables(fastify, options, {
|
|
7
7
|
operation: 'search',
|
|
8
8
|
method: 'POST',
|
|
9
|
-
url: (
|
|
9
|
+
url: (tableName) => `/search/${tableName}`,
|
|
10
10
|
successStatus: 200,
|
|
11
11
|
schemas: (db, table) => ({
|
|
12
12
|
body: SearchTableBodyPost(db, table),
|
|
@@ -24,7 +24,7 @@ export default async function searchRoutes(fastify, options) {
|
|
|
24
24
|
leftAliases.length && `Available joinLeft aliases: ${leftAliases.join(', ')}`,
|
|
25
25
|
].filter(Boolean).join('. ');
|
|
26
26
|
},
|
|
27
|
-
handle: async (fastify, tableName,
|
|
27
|
+
handle: async (fastify, tableName, _tc, request) => {
|
|
28
28
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
29
|
const body = request.body;
|
|
30
30
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -52,7 +52,7 @@ export default async function searchRoutes(fastify, options) {
|
|
|
52
52
|
computeAvg: query.computeAvg,
|
|
53
53
|
maxRows,
|
|
54
54
|
}, request);
|
|
55
|
-
return { table:
|
|
55
|
+
return { table: tableName, ...result };
|
|
56
56
|
},
|
|
57
57
|
});
|
|
58
58
|
}
|
|
@@ -4,7 +4,7 @@ export default async function updateRoutes(fastify, options) {
|
|
|
4
4
|
await registerForAllTables(fastify, options, {
|
|
5
5
|
operation: 'update',
|
|
6
6
|
method: 'PUT',
|
|
7
|
-
url: (
|
|
7
|
+
url: (tableName) => `/rest/${tableName}`,
|
|
8
8
|
successStatus: 200,
|
|
9
9
|
schemas: (db, table) => ({
|
|
10
10
|
body: UpdateTableBody(db, table),
|
package/dist/types/cli.d.ts
CHANGED
|
@@ -10,6 +10,16 @@ export interface SqlApiConfig {
|
|
|
10
10
|
* Excluded schemas already on disk are removed as orphans on the next full run.
|
|
11
11
|
*/
|
|
12
12
|
excludeTables?: string[];
|
|
13
|
+
/**
|
|
14
|
+
* Columns to leave out of the generated Schemas (`sqlapi-generate-schema`), as
|
|
15
|
+
* `"<table>.<column>"` entries matched against the DB names; `*` is a wildcard on either
|
|
16
|
+
* side (e.g. "user.password_hash", "*.internal_*"). An excluded column does not exist for
|
|
17
|
+
* the API at all — no read, no write, no filter — unlike `readExclude` / `writeExclude`
|
|
18
|
+
* on the table config, which hide one side of a column that stays in the Schema. A primary
|
|
19
|
+
* key column cannot be excluded, and a table left with no columns is refused: use
|
|
20
|
+
* `excludeTables` for that.
|
|
21
|
+
*/
|
|
22
|
+
excludeColumns?: string[];
|
|
13
23
|
}
|
|
14
24
|
export interface ColumnInfo {
|
|
15
25
|
table_name: string;
|
package/dist/types/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/types/cli.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/types/cli.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,yFAAyF;IACzF,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;;;;OAQG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,QAAQ;IACvB,CAAC,UAAU,EAAE,MAAM,GAAG;QACpB,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC/B,iFAAiF;QACjF,OAAO,EAAE,MAAM,EAAE,CAAC;QAClB,mFAAmF;QACnF,SAAS,EAAE,MAAM,EAAE,CAAC;QACpB,oFAAoF;QACpF,MAAM,EAAE,OAAO,CAAC;KACjB,CAAC;CACH"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# 0017. The database schema is the API contract — by default, and only by default
|
|
2
2
|
|
|
3
|
-
- **Status**: accepted
|
|
3
|
+
- **Status**: accepted (the "trim it out of the Schema" lever below is `excludeColumns` since
|
|
4
|
+
[0019](./0019-column-exclusion-is-generation-time-config.md) — the Schema file is never edited)
|
|
4
5
|
- **Date**: 2026-08-28
|
|
5
6
|
|
|
6
7
|
## Context
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# 0019. Removing a column from the API is generation-time config — `excludeColumns`, never a Schema edit
|
|
2
|
+
|
|
3
|
+
- **Status**: accepted
|
|
4
|
+
- **Date**: 2026-09-13
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
[ADR 0017](./0017-the-schema-is-the-contract.md) lists one lever per way the exposed shape may
|
|
9
|
+
differ from the stored one. For *"a column must not exist for the API at all"* it said: trim it
|
|
10
|
+
out of the Schema. That lever did not exist. Schema files are disposable mirrors regenerated on
|
|
11
|
+
every run ([ADR 0007](./0007-explicit-table-generation.md), [ADR 0009](./0009-schema-exclusion-config-blacklist.md)),
|
|
12
|
+
so a trimmed Schema survived exactly until the next `sqlapi-generate-schema`. The two runtime
|
|
13
|
+
levers do not cover the case either: `readExclude` hides one side, `writeExclude` the other, and
|
|
14
|
+
`defineTable` refuses a field in both — because a column that is neither readable nor writable
|
|
15
|
+
still sits in the validation schema, the Swagger output and the manifest, where it is a lie.
|
|
16
|
+
|
|
17
|
+
## Decision
|
|
18
|
+
|
|
19
|
+
Column exclusion is a generation-time config, next to `excludeTables`: `excludeColumns:
|
|
20
|
+
string[]` in `sqlapi.config.ts`, entries of the form `"<table>.<column>"`, both parts matched
|
|
21
|
+
against the DB names with `*` as the only wildcard. A matched column is removed from the
|
|
22
|
+
generated Schema — its fields, its `colMap`, its `generatedFields` — so everything built from
|
|
23
|
+
the Schema (routes, filters, ordering, joins, Swagger, manifest, TypeScript types) is unaware of
|
|
24
|
+
it. There is nothing to hide at runtime because there is nothing there.
|
|
25
|
+
|
|
26
|
+
Two refusals, both loud at generation time:
|
|
27
|
+
|
|
28
|
+
- **A primary key column cannot be excluded.** Every route identifies rows with it; the table
|
|
29
|
+
generator would either invent a key or write a placeholder, and either is silent damage.
|
|
30
|
+
- **A table cannot be left with no columns.** That is what `excludeTables` is for, and one
|
|
31
|
+
mechanism per intent keeps the config readable.
|
|
32
|
+
|
|
33
|
+
`excludeColumns` runs after `excludeTables`, so a column pattern is never checked against a
|
|
34
|
+
table the generator will not see — a `*.id` glob does not trip on the primary key of a table
|
|
35
|
+
that is excluded anyway.
|
|
36
|
+
|
|
37
|
+
## Alternatives considered
|
|
38
|
+
|
|
39
|
+
- **Editing the generated Schema file** — the documented answer until now, rejected: the file
|
|
40
|
+
is overwritten on every run, so the edit is a hand-maintained exception under `schemas/`,
|
|
41
|
+
exactly what ADR 0009 refused to support for whole tables.
|
|
42
|
+
- **A runtime `exclude` key on `defineTable`** — rejected: the Schema is imported by the
|
|
43
|
+
consumer's code before `defineTable` runs, so its TypeScript type, its `validation` object and
|
|
44
|
+
the `$id`-registered JSON Schema would still carry the column. A runtime lever can only hide;
|
|
45
|
+
the column would remain visible in Swagger, in the manifest and in the `TypeSchema` type, and
|
|
46
|
+
every reader of those would have to know about the second layer.
|
|
47
|
+
- **Allowing `readExclude` + `writeExclude` on the same field** — rejected for the same reason:
|
|
48
|
+
it is the runtime lever again, with the added cost of a rule that means "does not exist" but
|
|
49
|
+
reads as two independent hidings.
|
|
50
|
+
- **A per-column option inside the Schema file** — rejected: the Schema file carries no config,
|
|
51
|
+
by design; `sqlapi.config.ts` is where the generator's inputs already live.
|
|
52
|
+
|
|
53
|
+
## Consequences
|
|
54
|
+
|
|
55
|
+
- Adding an entry to `excludeColumns` changes the generated Schema on the next run and, for
|
|
56
|
+
TypeScript consumers, fails compilation wherever the column was referenced. Intended: that is
|
|
57
|
+
ADR 0017's "checked, not implied" at work.
|
|
58
|
+
- The relation-level narrowing described in [ADR 0010](./0010-joins-do-not-run-route-guards.md)
|
|
59
|
+
— a relation declared with a narrower Schema object — is a different tool for a different
|
|
60
|
+
scope (per relation, not per API) and is unaffected.
|
|
61
|
+
- An `excludeColumns` entry that is not `table.column`, that reaches a primary key, or that
|
|
62
|
+
empties a table fails the run with an explicit error rather than being ignored.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# 0020. No conditional or mass update route — a predicate write is a custom route
|
|
2
|
+
|
|
3
|
+
- **Status**: accepted
|
|
4
|
+
- **Date**: 2026-09-24
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
The `update` route addresses exactly one row: its `WHERE` is every primary key column plus
|
|
9
|
+
the tenant scope, and nothing the client sends can add to it. Two real needs fall outside
|
|
10
|
+
that shape:
|
|
11
|
+
|
|
12
|
+
- **Compare-and-set.** Claiming a row so that two clients cannot both claim it
|
|
13
|
+
(`UPDATE … SET in_progress_since = now() WHERE id = ? AND in_progress_since IS NULL`),
|
|
14
|
+
guarded state transitions (`… AND status = 'draft'`), optimistic locking on a `version`
|
|
15
|
+
column. Correctness depends on the check and the write being one statement; a
|
|
16
|
+
`beforeUpdate` that reads first and writes after is exactly the race it is meant to
|
|
17
|
+
prevent.
|
|
18
|
+
- **Mass update by predicate.** Setting a value on every row matching a filter.
|
|
19
|
+
|
|
20
|
+
A route was designed to cover both: a new `updateWhere` operation, opt-in (registered only
|
|
21
|
+
when listed in `operations`), `PUT {p}/update/{key}` with `{ where: { filters, conditions },
|
|
22
|
+
fields }` reusing the search grammar, the tenant guard injected, `validate`/`beforeUpdate`
|
|
23
|
+
run on `fields`, `{ affected }` in response. Compare-and-set is then a `where` that includes
|
|
24
|
+
the primary key.
|
|
25
|
+
|
|
26
|
+
## Decision
|
|
27
|
+
|
|
28
|
+
The plugin does not expose a conditional or mass update route, and the single-row `update`
|
|
29
|
+
does not accept a precondition. A write whose target is a predicate is built as a custom
|
|
30
|
+
route on `QueryClient.update(table, fields, where, extraCondition)`, which already takes a
|
|
31
|
+
`ConditionBuilder` and returns the number of rows it changed.
|
|
32
|
+
|
|
33
|
+
The design was sound; the cost was not worth it. It is the first route that writes N rows
|
|
34
|
+
chosen by the client, so a defect in it is destructive and tenant-crossing rather than a
|
|
35
|
+
wrong answer. Building it touches several security-sensitive pieces at once:
|
|
36
|
+
|
|
37
|
+
- `QueryClient.update`, which today assumes an equality `where` and would emit `WHERE AND …`
|
|
38
|
+
for a condition-only one;
|
|
39
|
+
- the search condition builder, shared with every read;
|
|
40
|
+
- the tenant guards, which on update are keyed on a primary key (`assertTenantOwnership`)
|
|
41
|
+
and would need the row-guard form for indirect scopes instead;
|
|
42
|
+
- the meaning of `validate`, `beforeUpdate` and `afterUpdate`, whose `record` would no longer
|
|
43
|
+
carry a primary key — breaking any existing hook that reads one.
|
|
44
|
+
|
|
45
|
+
Against that, the need is narrow and the custom route is a few lines, owned by the consumer
|
|
46
|
+
who knows the predicate it must enforce.
|
|
47
|
+
|
|
48
|
+
## Alternatives considered
|
|
49
|
+
|
|
50
|
+
- **`updateWhere` operation, opt-in** — the design above. Rejected for the risk and blast
|
|
51
|
+
radius described in the decision, not for a flaw in its shape.
|
|
52
|
+
- **`expect` on the single-row `update`** (a precondition next to the primary key, `409` when
|
|
53
|
+
it fails) — rejected: it narrows the write instead of widening it, so the blast radius is
|
|
54
|
+
small, but it still reopens the update `WHERE` to client-supplied predicates, and it needs
|
|
55
|
+
a readability rule to avoid an oracle (see Consequences). One custom route covers it.
|
|
56
|
+
- **Read-then-write in `beforeUpdate`** — not an alternative: two concurrent requests both
|
|
57
|
+
pass the read.
|
|
58
|
+
|
|
59
|
+
## Consequences
|
|
60
|
+
|
|
61
|
+
- Compare-and-set and predicate updates are custom routes. [AGENTS_BACKEND.md](../../AGENTS_BACKEND.md)
|
|
62
|
+
carries the pattern ("Conditional update — claim a row atomically").
|
|
63
|
+
- Everything the auto routes do for free is the custom route's job there: `QueryClient` runs
|
|
64
|
+
no hooks, no validation and **no tenant scoping**, so the route must add its own tenant
|
|
65
|
+
predicate and must not let a client-chosen predicate reach a column the caller cannot read —
|
|
66
|
+
otherwise `affected: 0 | 1` becomes an oracle on its value.
|
|
67
|
+
- Revisit only with a concrete, repeated need that a custom route serves badly; the design
|
|
68
|
+
notes above are the starting point.
|
package/docs/adr/README.md
CHANGED
|
@@ -33,6 +33,8 @@ not by editing history.
|
|
|
33
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
34
|
| [0017](./0017-the-schema-is-the-contract.md) | The database schema is the API contract — by default, and only by default |
|
|
35
35
|
| [0018](./0018-one-seam-to-the-http-framework.md) | The HTTP framework is reachable through one seam — `ApiRequest` |
|
|
36
|
+
| [0019](./0019-column-exclusion-is-generation-time-config.md) | Removing a column from the API is generation-time config — `excludeColumns`, never a Schema edit |
|
|
37
|
+
| [0020](./0020-no-conditional-or-mass-update-route.md) | No conditional or mass update route — a predicate write is a custom route |
|
|
36
38
|
|
|
37
39
|
## When to write one
|
|
38
40
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fastify-auto-sqlapi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Fastify plugin for automatic CRUD API generation from PostgreSQL, MySQL and MariaDB tables",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -83,20 +83,20 @@
|
|
|
83
83
|
"homepage": "https://github.com/LucaRainone/fastify-auto-sqlapi#readme",
|
|
84
84
|
"dependencies": {
|
|
85
85
|
"@sinclair/typebox": "^0.34.14",
|
|
86
|
-
"fastify-plugin": "^
|
|
86
|
+
"fastify-plugin": "^6.0.0",
|
|
87
87
|
"node-condition-builder": "^1.2.0"
|
|
88
88
|
},
|
|
89
89
|
"devDependencies": {
|
|
90
90
|
"@eslint/js": "^10.0.1",
|
|
91
91
|
"@fastify/postgres": "^6.0.2",
|
|
92
92
|
"@fastify/swagger": "^9.0.0",
|
|
93
|
-
"@fastify/swagger-ui": "^
|
|
93
|
+
"@fastify/swagger-ui": "^6.1.1",
|
|
94
94
|
"@types/node": "^22.13.4",
|
|
95
95
|
"@types/pg": "^8.11.11",
|
|
96
96
|
"dependency-cruiser": "^18.1.0",
|
|
97
97
|
"eslint": "^10.8.0",
|
|
98
98
|
"eslint-plugin-sonarjs": "^4.2.0",
|
|
99
|
-
"fastify": "5.
|
|
99
|
+
"fastify": "5.12.4",
|
|
100
100
|
"fuse.js": "^7.5.0",
|
|
101
101
|
"jscpd": "^5.0.14",
|
|
102
102
|
"knip": "^6.31.0",
|