fastify-auto-sqlapi 0.1.13 → 0.2.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_BACKEND.md +32 -5
- package/BREAKING_CHANGES.md +80 -5
- package/CHANGELOG.md +45 -0
- package/README.md +49 -7
- package/dist/bin/generate-schema.js +89 -77
- package/dist/bin/generate-tables.js +72 -62
- package/dist/bin/utils.d.ts.map +1 -1
- package/dist/bin/utils.js +47 -33
- package/dist/lib/agent/manifest.d.ts +3 -2
- package/dist/lib/agent/manifest.d.ts.map +1 -1
- package/dist/lib/agent/manifest.js +104 -80
- package/dist/lib/cli/mysql-introspect.d.ts +4 -9
- package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
- package/dist/lib/cli/mysql-introspect.js +8 -7
- package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
- package/dist/lib/cli/pg-introspect.js +0 -1
- package/dist/lib/cli/schema-codegen.d.ts +3 -0
- package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
- package/dist/lib/cli/schema-codegen.js +3 -0
- package/dist/lib/cli/tables-codegen.d.ts +8 -1
- package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
- package/dist/lib/cli/tables-codegen.js +100 -118
- package/dist/lib/condition-methods.d.ts +14 -4
- package/dist/lib/condition-methods.d.ts.map +1 -1
- package/dist/lib/condition-methods.js +18 -4
- package/dist/lib/db.d.ts +1 -1
- package/dist/lib/db.d.ts.map +1 -1
- package/dist/lib/db.js +16 -4
- package/dist/lib/dialect.d.ts +2 -1
- package/dist/lib/dialect.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-upsert.js +42 -35
- package/dist/lib/engine/rest/delete.d.ts.map +1 -1
- package/dist/lib/engine/rest/get.d.ts.map +1 -1
- package/dist/lib/engine/rest/insert.d.ts.map +1 -1
- package/dist/lib/engine/rest/insert.js +1 -1
- package/dist/lib/engine/rest/update.d.ts.map +1 -1
- package/dist/lib/engine/rest/update.js +25 -18
- package/dist/lib/engine/search/aggregations.d.ts +33 -0
- package/dist/lib/engine/search/aggregations.d.ts.map +1 -0
- package/dist/lib/engine/search/aggregations.js +109 -0
- package/dist/lib/engine/search/conditions.d.ts +31 -0
- package/dist/lib/engine/search/conditions.d.ts.map +1 -0
- package/dist/lib/engine/search/conditions.js +131 -0
- package/dist/lib/engine/search/fields.d.ts +49 -0
- package/dist/lib/engine/search/fields.d.ts.map +1 -0
- package/dist/lib/engine/search/fields.js +142 -0
- package/dist/lib/engine/search/join-families.d.ts +35 -0
- package/dist/lib/engine/search/join-families.d.ts.map +1 -0
- package/dist/lib/engine/search/join-families.js +345 -0
- package/dist/lib/engine/search/joins.d.ts +55 -0
- package/dist/lib/engine/search/joins.d.ts.map +1 -0
- package/dist/lib/engine/search/joins.js +81 -0
- package/dist/lib/engine/search/order-by.d.ts +35 -0
- package/dist/lib/engine/search/order-by.d.ts.map +1 -0
- package/dist/lib/engine/search/order-by.js +104 -0
- package/dist/lib/engine/search/search.d.ts.map +1 -1
- package/dist/lib/engine/search/search.js +106 -749
- package/dist/lib/engine/validate.d.ts +0 -5
- package/dist/lib/engine/validate.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.d.ts +25 -17
- package/dist/lib/engine/write-helpers.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.js +86 -44
- package/dist/lib/read-access.d.ts +37 -1
- package/dist/lib/read-access.d.ts.map +1 -1
- package/dist/lib/read-access.js +64 -1
- package/dist/lib/schema/helpers.d.ts +0 -15
- package/dist/lib/schema/helpers.d.ts.map +1 -1
- package/dist/lib/schema/helpers.js +7 -4
- package/dist/lib/schema/search.d.ts.map +1 -1
- package/dist/lib/schema/search.js +120 -121
- package/dist/lib/sql-api.d.ts.map +1 -1
- package/dist/lib/table-helpers.d.ts +16 -1
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +55 -1
- package/dist/lib/tenant.d.ts +26 -0
- package/dist/lib/tenant.d.ts.map +1 -1
- package/dist/lib/tenant.js +46 -0
- package/dist/routes/auto/route-helpers.d.ts +0 -7
- package/dist/routes/auto/route-helpers.d.ts.map +1 -1
- package/dist/routes/auto/route-helpers.js +46 -30
- package/dist/routes/auto/search.routes.js +0 -1
- package/dist/types/join.d.ts +9 -0
- package/dist/types/join.d.ts.map +1 -1
- package/dist/types/table.d.ts +1 -1
- package/docs/GLOSSARY.md +28 -0
- package/docs/adr/0010-joins-do-not-run-route-guards.md +100 -0
- package/docs/adr/0011-join-fields-allowlist.md +95 -0
- package/docs/adr/0012-anti-duplication-tooling.md +59 -0
- package/docs/adr/README.md +5 -1
- package/package.json +29 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ensureSqlApiDecorator } from '../../lib/sql-api-decorator.js';
|
|
2
2
|
import { isCompositePrimary } from '../../types.js';
|
|
3
|
-
|
|
3
|
+
function mergeOnRequests(options, tableConf) {
|
|
4
4
|
return [...(options.onRequests || []), ...(tableConf.onRequests || [])];
|
|
5
5
|
}
|
|
6
6
|
export function buildWriteDescription(action, tableName, tableConf) {
|
|
@@ -17,46 +17,62 @@ export function buildWriteDescription(action, tableName, tableConf) {
|
|
|
17
17
|
* following `spec`. Centralizes onRequest hook merging, tags, schema response wrapping,
|
|
18
18
|
* default reply when handler returns a value.
|
|
19
19
|
*/
|
|
20
|
+
/**
|
|
21
|
+
* Whether this table is addressable by the spec's route shape.
|
|
22
|
+
*
|
|
23
|
+
* A single-PK route cannot address a composite-PK table. Skipping is the default, but when
|
|
24
|
+
* the consumer explicitly listed the operation it is a configuration error: failing at
|
|
25
|
+
* startup beats a route that silently never exists.
|
|
26
|
+
*/
|
|
27
|
+
function isTableRoutable(tableName, tableConf, spec) {
|
|
28
|
+
// operations acts as a whitelist; when omitted every operation is exposed.
|
|
29
|
+
if (tableConf.operations && !tableConf.operations.includes(spec.operation))
|
|
30
|
+
return false;
|
|
31
|
+
if (!spec.singlePkOnly || !isCompositePrimary(tableConf.primary))
|
|
32
|
+
return true;
|
|
33
|
+
if (tableConf.operations?.includes(spec.operation)) {
|
|
34
|
+
throw new Error(`Table "${tableName}" has a composite primary key (${tableConf.primary.join(', ')}): ` +
|
|
35
|
+
`operation "${spec.operation}" addresses records by a single PK value and cannot be exposed. ` +
|
|
36
|
+
`Remove it from "operations", or handle the table via search/update or a custom route.`);
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Fastify route schema for one table.
|
|
42
|
+
*
|
|
43
|
+
* Only keys that are actually defined are included: Fastify checks for key *presence*
|
|
44
|
+
* (not an undefined value) to decide whether to emit FSTWRN001.
|
|
45
|
+
*/
|
|
46
|
+
function buildRouteSchema(tableName, tableConf, spec, schemas) {
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
48
|
+
const schema = {
|
|
49
|
+
tags: [`SqlAPI-${tableName}`],
|
|
50
|
+
summary: `${spec.summary} ${tableName}`,
|
|
51
|
+
description: spec.description(tableName, tableConf, schemas),
|
|
52
|
+
};
|
|
53
|
+
if (schemas.body !== undefined)
|
|
54
|
+
schema.body = schemas.body;
|
|
55
|
+
if (schemas.params !== undefined)
|
|
56
|
+
schema.params = schemas.params;
|
|
57
|
+
if (schemas.querystring !== undefined)
|
|
58
|
+
schema.querystring = schemas.querystring;
|
|
59
|
+
if (schemas.response !== undefined)
|
|
60
|
+
schema.response = { [spec.successStatus]: schemas.response };
|
|
61
|
+
return schema;
|
|
62
|
+
}
|
|
20
63
|
export async function registerForAllTables(fastify, options, spec) {
|
|
21
64
|
// Granular composition support: when a route plugin is registered without the
|
|
22
65
|
// main plugin, no ancestor has decorated `sqlApi` — create it in this scope.
|
|
23
66
|
ensureSqlApiDecorator(fastify, options);
|
|
24
67
|
const { DbTables } = options;
|
|
25
68
|
for (const [tableName, tableConf] of Object.entries(DbTables)) {
|
|
26
|
-
|
|
27
|
-
if (tableConf.operations && !tableConf.operations.includes(spec.operation))
|
|
28
|
-
continue;
|
|
29
|
-
// Single-PK routes cannot address composite-PK tables — skip them (default) or,
|
|
30
|
-
// when the consumer explicitly asked for the operation, fail loudly at startup.
|
|
31
|
-
if (spec.singlePkOnly && isCompositePrimary(tableConf.primary)) {
|
|
32
|
-
if (tableConf.operations?.includes(spec.operation)) {
|
|
33
|
-
throw new Error(`Table "${tableName}" has a composite primary key (${tableConf.primary.join(', ')}): ` +
|
|
34
|
-
`operation "${spec.operation}" addresses records by a single PK value and cannot be exposed. ` +
|
|
35
|
-
`Remove it from "operations", or handle the table via search/update or a custom route.`);
|
|
36
|
-
}
|
|
69
|
+
if (!isTableRoutable(tableName, tableConf, spec))
|
|
37
70
|
continue;
|
|
38
|
-
}
|
|
39
71
|
const schemas = spec.schemas(DbTables, tableName, tableConf);
|
|
40
|
-
// Only include schema keys that are actually defined: Fastify checks for key
|
|
41
|
-
// *presence* (not undefined value) to decide whether to emit FSTWRN001.
|
|
42
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
43
|
-
const schema = {
|
|
44
|
-
tags: [`SqlAPI-${tableName}`],
|
|
45
|
-
summary: `${spec.summary} ${tableName}`,
|
|
46
|
-
description: spec.description(tableName, tableConf, schemas),
|
|
47
|
-
};
|
|
48
|
-
if (schemas.body !== undefined)
|
|
49
|
-
schema.body = schemas.body;
|
|
50
|
-
if (schemas.params !== undefined)
|
|
51
|
-
schema.params = schemas.params;
|
|
52
|
-
if (schemas.querystring !== undefined)
|
|
53
|
-
schema.querystring = schemas.querystring;
|
|
54
|
-
if (schemas.response !== undefined)
|
|
55
|
-
schema.response = { [spec.successStatus]: schemas.response };
|
|
56
72
|
fastify.route({
|
|
57
73
|
method: spec.method,
|
|
58
74
|
url: spec.url(tableConf),
|
|
59
|
-
schema,
|
|
75
|
+
schema: buildRouteSchema(tableName, tableConf, spec, schemas),
|
|
60
76
|
onRequest: mergeOnRequests(options, tableConf),
|
|
61
77
|
handler: async (request, reply) => {
|
|
62
78
|
const result = await spec.handle(fastify, tableName, tableConf, request, reply);
|
|
@@ -15,7 +15,6 @@ export default async function searchRoutes(fastify, options) {
|
|
|
15
15
|
}),
|
|
16
16
|
summary: 'Search',
|
|
17
17
|
description: (name, _tc, schemas) => {
|
|
18
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
19
18
|
const body = schemas.body;
|
|
20
19
|
const multiAliases = Object.keys(body?.properties?.joinMultiple?.properties || {});
|
|
21
20
|
const leftAliases = Object.keys(body?.properties?.joinLeft?.properties || {});
|
package/dist/types/join.d.ts
CHANGED
|
@@ -8,6 +8,15 @@ export interface JoinDefinition {
|
|
|
8
8
|
alias: string;
|
|
9
9
|
selection: string;
|
|
10
10
|
unique: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Allowlist of the target table's fields reachable through this relation, when
|
|
13
|
+
* `buildRelation` was given one. `joinSchema` is already narrowed to it — this is kept
|
|
14
|
+
* so the engine can tell a restricted relation from a full one, which is what stops the
|
|
15
|
+
* default `'*'` selection from falling back to a real `SELECT *`.
|
|
16
|
+
*
|
|
17
|
+
* Read joins only: a write join declaring it is rejected by `defineTable`.
|
|
18
|
+
*/
|
|
19
|
+
fields?: string[];
|
|
11
20
|
}
|
|
12
21
|
export interface AggregationRequest {
|
|
13
22
|
/**
|
package/dist/types/join.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"join.d.ts","sourceRoot":"","sources":["../../src/types/join.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEvD,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,gBAAgB,CAAC;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"join.d.ts","sourceRoot":"","sources":["../../src/types/join.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEvD,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,gBAAgB,CAAC;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,OAAO,CAAC;IAChB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,gBAAgB;IAC/B,YAAY,EAAE,kBAAkB,CAAC;IACjC,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,UAAU,CAAC,EAAE,eAAe,EAAE,CAAC;CAChC;AAED;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC5B,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,UAAU,CAAC,EAAE,eAAe,EAAE,CAAC;CAChC;AAED,kEAAkE;AAClE,MAAM,WAAW,gBAAiB,SAAQ,aAAa;IACrD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB"}
|
package/dist/types/table.d.ts
CHANGED
|
@@ -30,7 +30,7 @@ export interface ITable<F extends Record<string, TSchema> = Record<string, TSche
|
|
|
30
30
|
extraFilters: Record<string, TSchema>;
|
|
31
31
|
/**
|
|
32
32
|
* Virtual fields produced by a SQL expression. Usable like schema fields in
|
|
33
|
-
* `filters`, `conditions` (non-dotted), `orderBy` (1-
|
|
33
|
+
* `filters`, `conditions` (non-dotted), `orderBy` (1-part),
|
|
34
34
|
* `computeMin/Max/Sum/Avg`, and (opt-in) in `selectComputed` for the main
|
|
35
35
|
* response. Each entry MUST declare `type` for Swagger and validation.
|
|
36
36
|
*
|
package/docs/GLOSSARY.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Glossary
|
|
2
|
+
|
|
3
|
+
One name per concept. Use these words in code, tests and docs — a second name for an
|
|
4
|
+
existing concept makes search fail and the concept get built twice.
|
|
5
|
+
|
|
6
|
+
| Term | Meaning |
|
|
7
|
+
|---|---|
|
|
8
|
+
| Table config | `defineTable()` result describing one table's routes, guards and joins. (not: model, entity) |
|
|
9
|
+
| Schema | TypeBox definition of a table's columns, generated from the DB. (not: model) |
|
|
10
|
+
| Relation | `buildRelation()` link between two schemas, identified by an alias. (not: association) |
|
|
11
|
+
| Alias | Request/response key naming one relation; defaults to the joined table name. |
|
|
12
|
+
| `joinMustExist` | Child→main 1:N filter via EXISTS; never returns child rows. |
|
|
13
|
+
| `joinMultiple` | Child→main 1:N side query; child rows land in `result.joinMultiple.<alias>`. |
|
|
14
|
+
| `joinGroup` | Child→main 1:N aggregations in `result.joinGroup.<alias>`. |
|
|
15
|
+
| `joinLeft` | Parent→main N:1 real LEFT JOIN; needs `unique: true` on the relation. |
|
|
16
|
+
| Secondaries | Child rows written in the same request as the main row. (not: nested, children) |
|
|
17
|
+
| Deletions | Child rows removed in the same request as the main row update. |
|
|
18
|
+
| Computed field | Server-side derived value resolved during search. (not: virtual, derived) |
|
|
19
|
+
| Extra filter | Non-column search filter declared per table. (not: custom filter) |
|
|
20
|
+
| Extended condition | Consumer-supplied SQL fragment merged into a search WHERE. |
|
|
21
|
+
| Dotted notation | `orderBy`/condition key counted in parts: 1-part `field`, 2-part `alias.field`, 3-part `alias.fn.field`. (not: parti) |
|
|
22
|
+
| Tenant scope | Row-level isolation: `direct` (own column) or `indirect` (via FK). |
|
|
23
|
+
| Engine | Dialect-agnostic function performing one operation. (not: service, handler) |
|
|
24
|
+
| Route | Fastify plugin exposing an engine over HTTP. (not: controller) |
|
|
25
|
+
| Dialect | Per-DB SQL differences (`postgres` \| `mysql`). |
|
|
26
|
+
| QueryClient | Connection wrapper the engines run SQL through. (not: db, connection) |
|
|
27
|
+
| Paginator | Request object carrying page and items-per-page. (not: pagination, pager) |
|
|
28
|
+
| Manifest | Runtime description of a deployment's tables, for agent clients. |
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# 0010. A declared join is a read grant — joins do not run the target table's route guards
|
|
2
|
+
|
|
3
|
+
- **Status**: accepted
|
|
4
|
+
- **Date**: 2026-07-30
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
`onRequests` and `operations` are configured per table, which reads as "this table's
|
|
9
|
+
protection". They are not. Both are properties of the *routes generated for* a table:
|
|
10
|
+
`mergeOnRequests` composes the global and per-table hooks into `fastify.route({ onRequest })`,
|
|
11
|
+
and `operations` decides which routes get registered at all.
|
|
12
|
+
|
|
13
|
+
A join is resolved inside the query of the **host** table's route. `searchEngine` validates a
|
|
14
|
+
join alias against the host's `allowedReadJoins` and nothing else — it never looks up the
|
|
15
|
+
target's `operations`, and no Fastify hook fires mid-query. So:
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
// agent: readable by every employee
|
|
19
|
+
allowedReadJoins: [buildRelation(SchemaAgent, 'userId', SchemaUser, 'id', { unique: true })],
|
|
20
|
+
|
|
21
|
+
// user: admins only... except through the join above
|
|
22
|
+
const TableUser = defineTable({
|
|
23
|
+
...exportTableInfo(SchemaUser),
|
|
24
|
+
onRequests: [requireAdmin],
|
|
25
|
+
operations: [], // no HTTP routes at all
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`POST /search/agent` with `joinLeft: { user: { selection: 'id,email' } }` returns the emails.
|
|
30
|
+
`requireAdmin` never ran, and `operations: []` did not matter: the caller was authorized as a
|
|
31
|
+
reader of `agent`, and the join is part of what `agent` exposes.
|
|
32
|
+
|
|
33
|
+
Reviewers reach this and read it as a hole to plug. It is a boundary, and the boundary needs
|
|
34
|
+
to be stated rather than discovered.
|
|
35
|
+
|
|
36
|
+
## Decision
|
|
37
|
+
|
|
38
|
+
**Declaring a relation in `allowedReadJoins` grants read access to the target table, under
|
|
39
|
+
the authorization of the host table.** The target's own route guards do not apply and are not
|
|
40
|
+
made to apply.
|
|
41
|
+
|
|
42
|
+
Route-level controls (`onRequests`, `operations`) do not follow joins. Configuration-level
|
|
43
|
+
controls do, and are the ones to use on a join target:
|
|
44
|
+
|
|
45
|
+
| Control | Follows a join | Granularity |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `onRequests` | no | per route |
|
|
48
|
+
| `operations` | no | per route |
|
|
49
|
+
| `readExclude` | yes | per table, static |
|
|
50
|
+
| the relation's `Schema` + `selection` | yes | per relation, static |
|
|
51
|
+
| `tenantScope` + `getTenantId` | yes | per request |
|
|
52
|
+
|
|
53
|
+
To expose a table broadly while keeping some of its columns narrow, declare the relation
|
|
54
|
+
against a **trimmed copy of the Schema** and give it an explicit `selection`:
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
const SchemaUserPublic = { ...SchemaUser, fields: { id: SchemaUser.fields.id, name: SchemaUser.fields.name } };
|
|
58
|
+
|
|
59
|
+
buildRelation(SchemaAgent, 'userId', SchemaUserPublic, 'id', { unique: true, selection: 'id, name' })
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Every read surface validates against the schema the relation was declared with — `selection`,
|
|
63
|
+
`filters`, `conditions`, `orderBy`, aggregations — so a field outside it is `400 Unknown
|
|
64
|
+
field`, not a hidden column. The explicit `selection` is not redundant: a relation left at the
|
|
65
|
+
default `'*'` emits a literal `SELECT *` when the target declares no `readExclude`, and the
|
|
66
|
+
extra columns are dropped only by the response serializer — they are still present on the
|
|
67
|
+
programmatic `sqlApi.search()` path.
|
|
68
|
+
|
|
69
|
+
## Alternatives considered
|
|
70
|
+
|
|
71
|
+
- **Run the target's `onRequests` when it is joined** — rejected: a Fastify request hook may
|
|
72
|
+
reply and terminate; there is no coherent meaning for that halfway through building one
|
|
73
|
+
table's query. It would also fire N times per request with no request lifecycle to attach to.
|
|
74
|
+
- **`canBeJoined?: (request) => Promise<boolean>` on `ITable`** — rejected: wrong granularity.
|
|
75
|
+
The real cases are "join yes, this column no", which a boolean cannot express, and the
|
|
76
|
+
refusal has no good answer (a `400` leaks the relation's existence; omitting the join
|
|
77
|
+
silently is indistinguishable from "no related rows"). It also adds a per-join await and
|
|
78
|
+
cannot be enforced for programmatic callers.
|
|
79
|
+
- **Make the relation's `selection` a ceiling instead of a default** — rejected: breaking, and
|
|
80
|
+
it does not solve the case it looks like it solves. A ceiling is still static, and it caps
|
|
81
|
+
only the projection: the excluded field stays filterable through `conditions`, which is the
|
|
82
|
+
bisection leak `readExclude` exists to prevent. Trimming the schema closes every surface at
|
|
83
|
+
once.
|
|
84
|
+
- **Refusing to register a table that is a join target of a less-protected table** — rejected:
|
|
85
|
+
the plugin has no way to compare two `onRequests` arrays for strictness, and ADR 0002 says
|
|
86
|
+
auth is the consumer's to model.
|
|
87
|
+
|
|
88
|
+
## Consequences
|
|
89
|
+
|
|
90
|
+
- `allowedReadJoins` must be read as a security decision, not just an ergonomics one. Adding a
|
|
91
|
+
relation widens the reachable data set for everyone authorized on the host table.
|
|
92
|
+
- Column-level protection that depends on the caller's role has no mechanism today.
|
|
93
|
+
`readExclude` is per table and static; the trimmed schema is per relation and static. The
|
|
94
|
+
known gap is a request-resolved `readExclude`, which would work without new plumbing at the
|
|
95
|
+
join sites — every path already resolves it through `dbTables[joinSchema.tableName]` — but
|
|
96
|
+
requires the generated response and filter schemas to stay the widest set and the runtime to
|
|
97
|
+
narrow. Not decided here.
|
|
98
|
+
- `tenantScope` remains the one request-derived protection that crosses joins, and is the
|
|
99
|
+
precedent for the shape any future one should take: table configuration plus a plugin-level
|
|
100
|
+
resolver, not a route hook.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# 0011. Per-relation field restriction is an allowlist, not a blocklist
|
|
2
|
+
|
|
3
|
+
- **Status**: accepted
|
|
4
|
+
- **Date**: 2026-07-31
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
[ADR 0010](./0010-joins-do-not-run-route-guards.md) establishes that a declared join is a read
|
|
9
|
+
grant, and that the only thing narrowing what a relation exposes is the schema it was declared
|
|
10
|
+
with. The pattern it documented was to hand-write a trimmed copy:
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
const SchemaUserPublic = { ...SchemaUser, fields: { id: SchemaUser.fields.id, name: SchemaUser.fields.name } };
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
That works, but the copy is derived state with nothing keeping it in sync. Schemas are
|
|
17
|
+
regenerated from the database as disposable mirrors (ADR 0007), so the copy silently drifts:
|
|
18
|
+
adding a column to `user` leaves `SchemaUserPublic` correct, dropping or renaming one leaves it
|
|
19
|
+
referencing a field that no longer exists, and neither shows up until a request fails.
|
|
20
|
+
|
|
21
|
+
The restriction belongs in the relation, where someone reading the table configuration is
|
|
22
|
+
already looking. The open question was its polarity.
|
|
23
|
+
|
|
24
|
+
## Decision
|
|
25
|
+
|
|
26
|
+
`buildRelation` takes `fields: string[]` — an **allowlist** of the target's fields reachable
|
|
27
|
+
through that relation. The relation is declared against a schema narrowed to that list, so every
|
|
28
|
+
existing check (`field in schema.fields`) enforces it without a parallel code path: `selection`,
|
|
29
|
+
`filters`, `conditions`, `orderBy`, aggregations, `aggregations.by`, and the generated request
|
|
30
|
+
and response schemas.
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
buildRelation(SchemaAgent, 'userId', SchemaUser, 'id', { unique: true, fields: ['id', 'name'] })
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Allowlist, not a `excludeFields` blocklist, because the failure modes are not symmetric. A
|
|
37
|
+
column added to the table later is not reachable through the relation until someone adds it —
|
|
38
|
+
the new column is invisible by default, and the cost of forgetting is a `400`. Under a blocklist
|
|
39
|
+
the same event silently widens every relation that did not anticipate it, and the cost of
|
|
40
|
+
forgetting is disclosure. On a join — a grant handed out under *another* table's authorization —
|
|
41
|
+
fail-closed is the only defensible default. It also reads the same way as `selection`, which is
|
|
42
|
+
already an allowlist.
|
|
43
|
+
|
|
44
|
+
Three rules follow from making it real rather than cosmetic:
|
|
45
|
+
|
|
46
|
+
- **It must include the join field.** That column is what correlates the fetched rows with the
|
|
47
|
+
main ones; omitting it hands the caller rows it cannot match to anything. Rejected at
|
|
48
|
+
declaration, like `readExclude` refusing to hide a primary key.
|
|
49
|
+
- **It is rejected on `allowedWriteJoins`.** Write paths resolve a secondary's upsert rule by
|
|
50
|
+
looking its schema up in `upsertMap` **by identity**; a narrowed relation carries a copy, so
|
|
51
|
+
the lookup would miss and the upsert would quietly degrade to a plain insert. The narrowed
|
|
52
|
+
schema would also drop columns the caller legitimately sent.
|
|
53
|
+
- **Computed fields on the target resolve against the narrowed schema.** A computed field reads
|
|
54
|
+
whatever columns it likes; resolving it against the full table schema would leave a documented
|
|
55
|
+
way around the allowlist (`salaryBand` returning what `salary` says). One that stays inside the
|
|
56
|
+
list keeps working; one that reaches outside is a `400`.
|
|
57
|
+
|
|
58
|
+
The default `selection: '*'` is spelled out into the allowed columns on a restricted relation.
|
|
59
|
+
It used to emit a real `SELECT *`, with the extra columns removed only by the response
|
|
60
|
+
serializer — which does not run for a caller of `sqlApi.search()`. An allowlist that leaks on its
|
|
61
|
+
default is not an allowlist.
|
|
62
|
+
|
|
63
|
+
## Alternatives considered
|
|
64
|
+
|
|
65
|
+
- **`excludeFields: string[]` (blocklist)** — rejected on the fail-open argument above. It is
|
|
66
|
+
shorter to write when hiding one column out of twenty, and it would have read like
|
|
67
|
+
`readExclude`; neither outweighs a new column defaulting to exposed.
|
|
68
|
+
- **`lockSelection: true`, making the declared `selection` a ceiling** — rejected: it caps the
|
|
69
|
+
projection only. The excluded column stays reachable from `filters`, `conditions` and
|
|
70
|
+
`orderBy`, which is the bisection leak `readExclude` exists to prevent — a guard that hides a
|
|
71
|
+
value from the output while leaving it interrogable is worse than none, because it reads as
|
|
72
|
+
protection.
|
|
73
|
+
- **Making the relation's `selection` a ceiling instead of adding a field** — rejected in
|
|
74
|
+
ADR 0010: breaking, and still static.
|
|
75
|
+
- **Threading the allowlist through every validation site instead of narrowing the schema** —
|
|
76
|
+
rejected: it would leave each new read surface to remember the check. Narrowing the schema is
|
|
77
|
+
closed by construction, which is the property that made the hand-written trimmed copy work in
|
|
78
|
+
the first place.
|
|
79
|
+
- **Keeping the hand-written trimmed copy as the documented pattern** — rejected: it duplicates
|
|
80
|
+
regenerated state. It still works, and is the way out if a relation needs a shape `fields`
|
|
81
|
+
cannot express.
|
|
82
|
+
|
|
83
|
+
## Consequences
|
|
84
|
+
|
|
85
|
+
- `fields` restricts **reading** only. It is not a substitute for `readExclude`, which hides a
|
|
86
|
+
column from the owning table's own routes too, nor for `onRequests`, which is what decides who
|
|
87
|
+
may call them.
|
|
88
|
+
- It is static, like everything else that crosses a join. Role-dependent column visibility —
|
|
89
|
+
admins seeing `email`, nobody else — still has no mechanism; the candidate remains a
|
|
90
|
+
request-resolved `readExclude`, unaffected by this ADR.
|
|
91
|
+
- A relation and its write counterpart can no longer be the same `buildRelation` call when the
|
|
92
|
+
read side is restricted. Declaring two is the intended answer, not a workaround.
|
|
93
|
+
- The generated agent manifest still describes join targets by table name only, so an LLM client
|
|
94
|
+
reading it may ask for a field the relation does not expose and get a `400`. Listing a
|
|
95
|
+
restricted relation's fields in the manifest is a follow-up.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# 0012. Repository rules are enforced by executable gates, not by documentation
|
|
2
|
+
|
|
3
|
+
- **Status**: accepted
|
|
4
|
+
- **Date**: 2026-08-01
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Every session starts with an empty context window. An agent that cannot find existing code
|
|
9
|
+
rewrites it, invents a second name for an existing concept, and scatters logic across the
|
|
10
|
+
tree. Documentation does not prevent this: it is only read when someone remembers to read
|
|
11
|
+
it, and `CLAUDE.md` is weighted context, not configuration.
|
|
12
|
+
|
|
13
|
+
The repository had no ESLint, no Prettier, no git hooks and no CI beyond publish-on-tag.
|
|
14
|
+
Measured starting point: 2.50% duplicated lines (3.27% of tokens, 18 clones), naming drift
|
|
15
|
+
effectively zero, layering already clean, and `fastify-plugin` imported but absent from
|
|
16
|
+
`package.json` — a real packaging bug that no human review had caught.
|
|
17
|
+
|
|
18
|
+
## Decision
|
|
19
|
+
|
|
20
|
+
Anything that must not happen is a hook or a lint rule. Prose is reserved for what cannot
|
|
21
|
+
be executed.
|
|
22
|
+
|
|
23
|
+
Gates are **silent and zero-tolerance by default**; pre-existing debt is switched off and
|
|
24
|
+
surfaced by `npm run debt` (`lint:strict`, `dup:strict`, `depcruise:strict`,
|
|
25
|
+
`knip:strict`). npm scripts are the source of truth — git hooks only call them — so any
|
|
26
|
+
gate can be run without touching git history.
|
|
27
|
+
|
|
28
|
+
Rejected the ratchet-on-a-warning-count approach that was tried first: a run that always
|
|
29
|
+
prints 241 warnings trains everyone to ignore its output, and the number drifts upward
|
|
30
|
+
unnoticed. A rule is either enforced or explicitly deferred, never "warned about".
|
|
31
|
+
|
|
32
|
+
## Alternatives considered
|
|
33
|
+
|
|
34
|
+
- **Baseline thresholds with warnings** (`--max-warnings 241`) — rejected after
|
|
35
|
+
implementing it: noisy output is ignored output, and the boundary is invisible.
|
|
36
|
+
- **Barrel enforcement via `no-restricted-imports`** — rejected: the repo has one barrel,
|
|
37
|
+
`src/index.ts`, which is the npm surface. Blocking deep imports would break every
|
|
38
|
+
internal import and require restructuring into per-module barrels. `dependency-cruiser`
|
|
39
|
+
encodes the same boundaries without moving a single file.
|
|
40
|
+
- **Intent tags with a closed vocabulary, and `cspell`** — rejected: 64 files, coherent
|
|
41
|
+
naming, one maintainer. A half-maintained tag system is worse than none, and spellchecking
|
|
42
|
+
a 951-line README full of SQL identifiers is pure noise.
|
|
43
|
+
- **Session worklog** — rejected: without a compaction ritual nobody will run, an
|
|
44
|
+
append-only log becomes context noise within weeks.
|
|
45
|
+
|
|
46
|
+
## Consequences
|
|
47
|
+
|
|
48
|
+
- New violations fail immediately; the deferred list in `eslint.config.js` is the debt
|
|
49
|
+
ledger and may only shrink.
|
|
50
|
+
- `docs/INDEX.md` is generated and CI fails when it is stale, like a lockfile. It is
|
|
51
|
+
excluded from the npm package: publishing 204 internal declarations would invite
|
|
52
|
+
consumers to import what is not public API.
|
|
53
|
+
- Type-only import cycles through `src/types.ts` are deferred, not fixed. Verified they
|
|
54
|
+
are erased on emit — the compiled `dist/` graph has zero cycles.
|
|
55
|
+
- `knip` checks exports and types with no exclusions. The 16 symbols that were exported but
|
|
56
|
+
used only inside their own file lost the keyword; the 5 that only unit tests use are
|
|
57
|
+
tagged `@testonly`, which documents why they stay exported instead of hiding them.
|
|
58
|
+
- Maintainer scripts require Node ≥22.18 (native TypeScript type stripping). The published
|
|
59
|
+
package still supports Node ≥18.
|
package/docs/adr/README.md
CHANGED
|
@@ -24,11 +24,15 @@ not by editing history.
|
|
|
24
24
|
| [0007](./0007-explicit-table-generation.md) | Table generation is explicit — `--all` is a migration helper, not the default |
|
|
25
25
|
| [0008](./0008-agent-client-interface.md) | Agent client interface: static grammar + runtime manifest |
|
|
26
26
|
| [0009](./0009-schema-exclusion-config-blacklist.md) | Schema exclusion is a config blacklist — excluded schemas are removed as orphans |
|
|
27
|
+
| [0010](./0010-joins-do-not-run-route-guards.md) | A declared join is a read grant — joins do not run the target table's route guards |
|
|
28
|
+
| [0011](./0011-join-fields-allowlist.md) | Per-relation field restriction is an allowlist, not a blocklist |
|
|
29
|
+
| [0012](./0012-anti-duplication-tooling.md) | Repository rules are enforced by executable gates, not by documentation |
|
|
27
30
|
|
|
28
31
|
## When to write one
|
|
29
32
|
|
|
30
33
|
Only when a decision was genuinely debated, or is counter-intuitive enough that a reasonable
|
|
31
|
-
outsider would mistake it for a bug. Plain conventions belong in [AGENTS.md](../../AGENTS.md)
|
|
34
|
+
outsider would mistake it for a bug. Plain conventions belong in [AGENTS.md](../../AGENTS.md)
|
|
35
|
+
for consumers, or in `CLAUDE.md` at the repository root for contributors.
|
|
32
36
|
|
|
33
37
|
## Format
|
|
34
38
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fastify-auto-sqlapi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"dist",
|
|
21
21
|
"docs",
|
|
22
|
+
"!docs/INDEX.md",
|
|
22
23
|
"AGENTS.md",
|
|
23
24
|
"AGENTS_BACKEND.md",
|
|
24
25
|
"AGENTS_FRONTEND.md",
|
|
@@ -28,6 +29,21 @@
|
|
|
28
29
|
"scripts": {
|
|
29
30
|
"clean": "rm -rf dist",
|
|
30
31
|
"build": "rm -rf dist && tsc",
|
|
32
|
+
"lint": "eslint . --max-warnings 0",
|
|
33
|
+
"lint:fix": "eslint . --fix",
|
|
34
|
+
"lint:strict": "LINT_STRICT=1 eslint .",
|
|
35
|
+
"typecheck": "tsc --noEmit && tsc -p scripts/tsconfig.json",
|
|
36
|
+
"index": "node scripts/generate-index.ts",
|
|
37
|
+
"index:check": "node scripts/generate-index.ts --check",
|
|
38
|
+
"find-similar": "node scripts/find-similar.ts",
|
|
39
|
+
"dup": "jscpd src",
|
|
40
|
+
"dup:strict": "jscpd src --reporters console",
|
|
41
|
+
"knip": "knip --max-issues 0 --treat-config-hints-as-errors --treat-tag-hints-as-errors",
|
|
42
|
+
"depcruise": "depcruise src --config .dependency-cruiser.mjs --output-type err",
|
|
43
|
+
"depcruise:strict": "DEPCRUISE_STRICT=1 depcruise src --config .dependency-cruiser.mjs --output-type err",
|
|
44
|
+
"debt": "npm run lint:strict; npm run dup:strict; npm run depcruise:strict",
|
|
45
|
+
"check": "npm run lint && npm run typecheck",
|
|
46
|
+
"verify": "npm run check && npm run build && npm run index:check && npm run dup && npm run depcruise && npm run knip && npm run test:unit",
|
|
31
47
|
"prepublishOnly": "npm run build && npm run test:unit",
|
|
32
48
|
"test": "npm run test:unit && npm run test:integration:pg && npm run test:integration:mysql",
|
|
33
49
|
"test:unit": "node --test test/unit/*.test.js",
|
|
@@ -67,18 +83,29 @@
|
|
|
67
83
|
"homepage": "https://github.com/LucaRainone/fastify-auto-sqlapi#readme",
|
|
68
84
|
"dependencies": {
|
|
69
85
|
"@sinclair/typebox": "^0.34.14",
|
|
86
|
+
"fastify-plugin": "^5.1.0",
|
|
70
87
|
"node-condition-builder": "^1.2.0"
|
|
71
88
|
},
|
|
72
89
|
"devDependencies": {
|
|
90
|
+
"@eslint/js": "^10.0.1",
|
|
73
91
|
"@fastify/postgres": "^6.0.2",
|
|
74
92
|
"@fastify/swagger": "^9.0.0",
|
|
75
93
|
"@fastify/swagger-ui": "^5.0.0",
|
|
76
94
|
"@types/node": "^22.13.4",
|
|
77
95
|
"@types/pg": "^8.11.11",
|
|
96
|
+
"dependency-cruiser": "^18.1.0",
|
|
97
|
+
"eslint": "^10.8.0",
|
|
98
|
+
"eslint-plugin-sonarjs": "^4.2.0",
|
|
78
99
|
"fastify": "5.8.5",
|
|
100
|
+
"fuse.js": "^7.5.0",
|
|
101
|
+
"jscpd": "^5.0.14",
|
|
102
|
+
"knip": "^6.31.0",
|
|
103
|
+
"lefthook": "^2.1.10",
|
|
79
104
|
"mysql2": "^3.22.0",
|
|
80
105
|
"pg": "^8.13.1",
|
|
81
|
-
"
|
|
106
|
+
"ts-morph": "^28.0.0",
|
|
107
|
+
"typescript": "^5.7.3",
|
|
108
|
+
"typescript-eslint": "^8.65.0"
|
|
82
109
|
},
|
|
83
110
|
"peerDependencies": {
|
|
84
111
|
"@fastify/mysql": ">=4.0.0",
|