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.
Files changed (91) hide show
  1. package/AGENTS_BACKEND.md +32 -5
  2. package/BREAKING_CHANGES.md +80 -5
  3. package/CHANGELOG.md +45 -0
  4. package/README.md +49 -7
  5. package/dist/bin/generate-schema.js +89 -77
  6. package/dist/bin/generate-tables.js +72 -62
  7. package/dist/bin/utils.d.ts.map +1 -1
  8. package/dist/bin/utils.js +47 -33
  9. package/dist/lib/agent/manifest.d.ts +3 -2
  10. package/dist/lib/agent/manifest.d.ts.map +1 -1
  11. package/dist/lib/agent/manifest.js +104 -80
  12. package/dist/lib/cli/mysql-introspect.d.ts +4 -9
  13. package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
  14. package/dist/lib/cli/mysql-introspect.js +8 -7
  15. package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
  16. package/dist/lib/cli/pg-introspect.js +0 -1
  17. package/dist/lib/cli/schema-codegen.d.ts +3 -0
  18. package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
  19. package/dist/lib/cli/schema-codegen.js +3 -0
  20. package/dist/lib/cli/tables-codegen.d.ts +8 -1
  21. package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
  22. package/dist/lib/cli/tables-codegen.js +100 -118
  23. package/dist/lib/condition-methods.d.ts +14 -4
  24. package/dist/lib/condition-methods.d.ts.map +1 -1
  25. package/dist/lib/condition-methods.js +18 -4
  26. package/dist/lib/db.d.ts +1 -1
  27. package/dist/lib/db.d.ts.map +1 -1
  28. package/dist/lib/db.js +16 -4
  29. package/dist/lib/dialect.d.ts +2 -1
  30. package/dist/lib/dialect.d.ts.map +1 -1
  31. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  32. package/dist/lib/engine/bulk/bulk-upsert.js +42 -35
  33. package/dist/lib/engine/rest/delete.d.ts.map +1 -1
  34. package/dist/lib/engine/rest/get.d.ts.map +1 -1
  35. package/dist/lib/engine/rest/insert.d.ts.map +1 -1
  36. package/dist/lib/engine/rest/insert.js +1 -1
  37. package/dist/lib/engine/rest/update.d.ts.map +1 -1
  38. package/dist/lib/engine/rest/update.js +25 -18
  39. package/dist/lib/engine/search/aggregations.d.ts +33 -0
  40. package/dist/lib/engine/search/aggregations.d.ts.map +1 -0
  41. package/dist/lib/engine/search/aggregations.js +109 -0
  42. package/dist/lib/engine/search/conditions.d.ts +31 -0
  43. package/dist/lib/engine/search/conditions.d.ts.map +1 -0
  44. package/dist/lib/engine/search/conditions.js +131 -0
  45. package/dist/lib/engine/search/fields.d.ts +49 -0
  46. package/dist/lib/engine/search/fields.d.ts.map +1 -0
  47. package/dist/lib/engine/search/fields.js +142 -0
  48. package/dist/lib/engine/search/join-families.d.ts +35 -0
  49. package/dist/lib/engine/search/join-families.d.ts.map +1 -0
  50. package/dist/lib/engine/search/join-families.js +345 -0
  51. package/dist/lib/engine/search/joins.d.ts +55 -0
  52. package/dist/lib/engine/search/joins.d.ts.map +1 -0
  53. package/dist/lib/engine/search/joins.js +81 -0
  54. package/dist/lib/engine/search/order-by.d.ts +35 -0
  55. package/dist/lib/engine/search/order-by.d.ts.map +1 -0
  56. package/dist/lib/engine/search/order-by.js +104 -0
  57. package/dist/lib/engine/search/search.d.ts.map +1 -1
  58. package/dist/lib/engine/search/search.js +106 -749
  59. package/dist/lib/engine/validate.d.ts +0 -5
  60. package/dist/lib/engine/validate.d.ts.map +1 -1
  61. package/dist/lib/engine/write-helpers.d.ts +25 -17
  62. package/dist/lib/engine/write-helpers.d.ts.map +1 -1
  63. package/dist/lib/engine/write-helpers.js +86 -44
  64. package/dist/lib/read-access.d.ts +37 -1
  65. package/dist/lib/read-access.d.ts.map +1 -1
  66. package/dist/lib/read-access.js +64 -1
  67. package/dist/lib/schema/helpers.d.ts +0 -15
  68. package/dist/lib/schema/helpers.d.ts.map +1 -1
  69. package/dist/lib/schema/helpers.js +7 -4
  70. package/dist/lib/schema/search.d.ts.map +1 -1
  71. package/dist/lib/schema/search.js +120 -121
  72. package/dist/lib/sql-api.d.ts.map +1 -1
  73. package/dist/lib/table-helpers.d.ts +16 -1
  74. package/dist/lib/table-helpers.d.ts.map +1 -1
  75. package/dist/lib/table-helpers.js +55 -1
  76. package/dist/lib/tenant.d.ts +26 -0
  77. package/dist/lib/tenant.d.ts.map +1 -1
  78. package/dist/lib/tenant.js +46 -0
  79. package/dist/routes/auto/route-helpers.d.ts +0 -7
  80. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  81. package/dist/routes/auto/route-helpers.js +46 -30
  82. package/dist/routes/auto/search.routes.js +0 -1
  83. package/dist/types/join.d.ts +9 -0
  84. package/dist/types/join.d.ts.map +1 -1
  85. package/dist/types/table.d.ts +1 -1
  86. package/docs/GLOSSARY.md +28 -0
  87. package/docs/adr/0010-joins-do-not-run-route-guards.md +100 -0
  88. package/docs/adr/0011-join-fields-allowlist.md +95 -0
  89. package/docs/adr/0012-anti-duplication-tooling.md +59 -0
  90. package/docs/adr/README.md +5 -1
  91. 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
- export function mergeOnRequests(options, tableConf) {
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
- // operations acts as a whitelist; when omitted every operation is exposed.
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 || {});
@@ -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
  /**
@@ -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;CACjB;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"}
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"}
@@ -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-parte),
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
  *
@@ -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.
@@ -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.1.13",
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
- "typescript": "^5.7.3"
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",