fastify-auto-sqlapi 0.2.3 → 0.2.4
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 +7 -4
- package/CHANGELOG.md +25 -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/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +2 -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/README.md +1 -0
- package/package.json +1 -1
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.
|
|
@@ -1014,8 +1017,8 @@ output, it is the part that does not depend on anyone opening the generated file
|
|
|
1014
1017
|
- **All fields Optional in response**: `RETURNING *` may return any subset. Response schemas use `Type.Partial`.
|
|
1015
1018
|
- **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
1019
|
- **`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:
|
|
1020
|
+
- **`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).
|
|
1021
|
+
- **`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
1022
|
- **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
1023
|
- **`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
1024
|
- **`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 +1028,7 @@ output, it is the part that does not depend on anyone opening the generated file
|
|
|
1025
1028
|
- **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
1029
|
- **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
1030
|
- **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.
|
|
1031
|
+
- **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
1032
|
- **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
1033
|
- **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
1034
|
- **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`).
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,31 @@ 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
|
+
## [0.2.4]
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`excludeColumns` config option** — the lever for *"this column must not exist for the API at
|
|
15
|
+
all"*. [ADR 0017](./docs/adr/0017-the-schema-is-the-contract.md), the README and `AGENTS.md`
|
|
16
|
+
all pointed at "trim it out of the Schema" for that case, and no such lever existed: Schema
|
|
17
|
+
files are generated and overwritten on every run, so the only way to drop a column was to
|
|
18
|
+
re-edit the file after each `sqlapi-generate-schema` — or to pair `readExclude` with
|
|
19
|
+
`writeExclude`, which `defineTable` refuses. `excludeColumns` in `sqlapi.config.ts` takes
|
|
20
|
+
`"<table>.<column>"` entries matched against the DB names, with `*` as a wildcard on either
|
|
21
|
+
side (`'user.password_hash'`, `'*.internal_*'`), and drops the column from the generated Schema
|
|
22
|
+
— its fields, `colMap` and `generatedFields` — so no route, filter, order or manifest ever
|
|
23
|
+
sees it. A primary key column is refused (every route identifies rows with it), and so is a
|
|
24
|
+
pattern that would leave a table with no columns: that is `excludeTables`' job. Applied after
|
|
25
|
+
`excludeTables`, so a column pattern is never checked against a table the generator does not
|
|
26
|
+
see. **Regenerate your schemas** after adding entries. See
|
|
27
|
+
[ADR 0019](./docs/adr/0019-column-exclusion-is-generation-time-config.md).
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- **The docs no longer say "trim the Schema".** The README lever table, `AGENTS.md`,
|
|
32
|
+
`AGENTS_BACKEND.md` and the `defineTable` error for a field in both `readExclude` and
|
|
33
|
+
`writeExclude` now point at `excludeColumns`.
|
|
34
|
+
|
|
10
35
|
## [0.2.3]
|
|
11
36
|
|
|
12
37
|
### Fixed
|
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);
|
|
@@ -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/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.
|
package/docs/adr/README.md
CHANGED
|
@@ -33,6 +33,7 @@ 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 |
|
|
36
37
|
|
|
37
38
|
## When to write one
|
|
38
39
|
|