fastify-auto-sqlapi 0.1.14 → 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 (83) hide show
  1. package/AGENTS_BACKEND.md +4 -4
  2. package/BREAKING_CHANGES.md +5 -5
  3. package/README.md +1 -1
  4. package/dist/bin/generate-schema.js +89 -77
  5. package/dist/bin/generate-tables.js +72 -62
  6. package/dist/bin/utils.d.ts.map +1 -1
  7. package/dist/bin/utils.js +47 -33
  8. package/dist/lib/agent/manifest.d.ts +3 -2
  9. package/dist/lib/agent/manifest.d.ts.map +1 -1
  10. package/dist/lib/agent/manifest.js +102 -80
  11. package/dist/lib/cli/mysql-introspect.d.ts +4 -9
  12. package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
  13. package/dist/lib/cli/mysql-introspect.js +8 -7
  14. package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
  15. package/dist/lib/cli/pg-introspect.js +0 -1
  16. package/dist/lib/cli/schema-codegen.d.ts +3 -0
  17. package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
  18. package/dist/lib/cli/schema-codegen.js +3 -0
  19. package/dist/lib/cli/tables-codegen.d.ts +8 -1
  20. package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
  21. package/dist/lib/cli/tables-codegen.js +100 -118
  22. package/dist/lib/condition-methods.d.ts +14 -4
  23. package/dist/lib/condition-methods.d.ts.map +1 -1
  24. package/dist/lib/condition-methods.js +18 -4
  25. package/dist/lib/db.d.ts +1 -1
  26. package/dist/lib/db.d.ts.map +1 -1
  27. package/dist/lib/db.js +16 -4
  28. package/dist/lib/dialect.d.ts +2 -1
  29. package/dist/lib/dialect.d.ts.map +1 -1
  30. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  31. package/dist/lib/engine/bulk/bulk-upsert.js +42 -35
  32. package/dist/lib/engine/rest/delete.d.ts.map +1 -1
  33. package/dist/lib/engine/rest/get.d.ts.map +1 -1
  34. package/dist/lib/engine/rest/insert.d.ts.map +1 -1
  35. package/dist/lib/engine/rest/insert.js +1 -1
  36. package/dist/lib/engine/rest/update.d.ts.map +1 -1
  37. package/dist/lib/engine/rest/update.js +25 -18
  38. package/dist/lib/engine/search/aggregations.d.ts +33 -0
  39. package/dist/lib/engine/search/aggregations.d.ts.map +1 -0
  40. package/dist/lib/engine/search/aggregations.js +109 -0
  41. package/dist/lib/engine/search/conditions.d.ts +31 -0
  42. package/dist/lib/engine/search/conditions.d.ts.map +1 -0
  43. package/dist/lib/engine/search/conditions.js +131 -0
  44. package/dist/lib/engine/search/fields.d.ts +49 -0
  45. package/dist/lib/engine/search/fields.d.ts.map +1 -0
  46. package/dist/lib/engine/search/fields.js +142 -0
  47. package/dist/lib/engine/search/join-families.d.ts +35 -0
  48. package/dist/lib/engine/search/join-families.d.ts.map +1 -0
  49. package/dist/lib/engine/search/join-families.js +345 -0
  50. package/dist/lib/engine/search/joins.d.ts +55 -0
  51. package/dist/lib/engine/search/joins.d.ts.map +1 -0
  52. package/dist/lib/engine/search/joins.js +81 -0
  53. package/dist/lib/engine/search/order-by.d.ts +35 -0
  54. package/dist/lib/engine/search/order-by.d.ts.map +1 -0
  55. package/dist/lib/engine/search/order-by.js +104 -0
  56. package/dist/lib/engine/search/search.d.ts.map +1 -1
  57. package/dist/lib/engine/search/search.js +101 -787
  58. package/dist/lib/engine/validate.d.ts +0 -5
  59. package/dist/lib/engine/validate.d.ts.map +1 -1
  60. package/dist/lib/engine/write-helpers.d.ts +25 -17
  61. package/dist/lib/engine/write-helpers.d.ts.map +1 -1
  62. package/dist/lib/engine/write-helpers.js +86 -44
  63. package/dist/lib/read-access.d.ts +12 -1
  64. package/dist/lib/read-access.d.ts.map +1 -1
  65. package/dist/lib/read-access.js +20 -4
  66. package/dist/lib/schema/helpers.d.ts +0 -15
  67. package/dist/lib/schema/helpers.d.ts.map +1 -1
  68. package/dist/lib/schema/helpers.js +7 -4
  69. package/dist/lib/schema/search.d.ts.map +1 -1
  70. package/dist/lib/schema/search.js +107 -128
  71. package/dist/lib/sql-api.d.ts.map +1 -1
  72. package/dist/lib/tenant.d.ts +26 -0
  73. package/dist/lib/tenant.d.ts.map +1 -1
  74. package/dist/lib/tenant.js +46 -0
  75. package/dist/routes/auto/route-helpers.d.ts +0 -7
  76. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  77. package/dist/routes/auto/route-helpers.js +46 -30
  78. package/dist/routes/auto/search.routes.js +0 -1
  79. package/dist/types/table.d.ts +1 -1
  80. package/docs/GLOSSARY.md +28 -0
  81. package/docs/adr/0012-anti-duplication-tooling.md +59 -0
  82. package/docs/adr/README.md +3 -1
  83. package/package.json +29 -2
@@ -1,5 +1,5 @@
1
1
  import { Type } from '@sinclair/typebox';
2
- import { ALLOWED_METHODS } from '../condition-methods.js';
2
+ import { ALLOWED_METHODS, MAX_CONDITIONS, MAX_ORDER_BY_LENGTH } from '../condition-methods.js';
3
3
  import { readableFieldNames } from '../read-access.js';
4
4
  import { DEFAULT_MAX_ITEMS_PER_PAGE } from '../../types.js';
5
5
  /** Schema fields minus the ones hidden by `readExclude`. */
@@ -24,6 +24,100 @@ function readableFields(schema, tableConf) {
24
24
  function filtersObject(fields) {
25
25
  return Type.Partial(Type.Object(fields));
26
26
  }
27
+ /**
28
+ * TypeBox types declared by a table's computed fields.
29
+ *
30
+ * Each computed is dry-run with a stub context purely to read its declared `type`; the
31
+ * functions are pure with respect to that context. Best-effort by design — a computed that
32
+ * throws is skipped, because this only feeds the Swagger/body schema and the engine
33
+ * validates for real at request time.
34
+ */
35
+ function computedFieldTypes(tableConf) {
36
+ const out = {};
37
+ for (const [name, fn] of Object.entries(tableConf?.computedFields ?? {})) {
38
+ try {
39
+ const stub = fn({
40
+ db: { qi: (s) => s, dialectName: 'postgres' },
41
+ qiCol: () => '""',
42
+ });
43
+ if (stub.type)
44
+ out[name] = stub.type;
45
+ }
46
+ catch {
47
+ // ignore — schema generation is best-effort; runtime validates.
48
+ }
49
+ }
50
+ return out;
51
+ }
52
+ function buildJoinBodyProps(dbTables, tableConf, conditionItemSchema) {
53
+ const props = {
54
+ joinMustExist: {},
55
+ joinMultiple: {},
56
+ joinGroup: {},
57
+ joinLeft: {},
58
+ };
59
+ for (const joinDef of tableConf.allowedReadJoins ?? []) {
60
+ const { joinSchema, alias, unique } = joinDef;
61
+ const joinTableConf = dbTables[joinSchema.tableName];
62
+ const joinSchemaFields = joinTableConf
63
+ ? { ...readableFields(joinSchema, joinTableConf), ...computedFieldTypes(joinTableConf) }
64
+ : { ...joinSchema.fields };
65
+ // `extraFilters` are only honoured where the engine delegates to the join table's
66
+ // own `filters()` — buildJoinRefCondition, i.e. the unique:false paths. joinLeft
67
+ // builds its condition inline (buildLeftJoinClauses) and handles schema and computed
68
+ // fields only, so advertising extraFilters there would promise a silent no-op.
69
+ const joinFilterFields = { ...joinSchemaFields, ...(joinTableConf?.extraFilters ?? {}) };
70
+ const joinRefShape = {
71
+ filters: Type.Optional(filtersObject(joinFilterFields)),
72
+ conditions: Type.Optional(Type.Array(conditionItemSchema, { maxItems: MAX_CONDITIONS })),
73
+ };
74
+ if (unique) {
75
+ props.joinLeft[alias] = Type.Object({
76
+ filters: Type.Optional(filtersObject(joinSchemaFields)),
77
+ conditions: Type.Optional(Type.Array(conditionItemSchema, { maxItems: MAX_CONDITIONS })),
78
+ selection: Type.Optional(Type.String()),
79
+ });
80
+ continue;
81
+ }
82
+ props.joinMustExist[alias] = Type.Object(joinRefShape);
83
+ props.joinMultiple[alias] = Type.Object({
84
+ ...joinRefShape,
85
+ selection: Type.Optional(Type.String()),
86
+ });
87
+ props.joinGroup[alias] = Type.Object({
88
+ aggregations: Type.Object({
89
+ by: Type.Optional(Type.String()),
90
+ distinctCount: Type.Optional(Type.Array(Type.String())),
91
+ min: Type.Optional(Type.Array(Type.String())),
92
+ max: Type.Optional(Type.Array(Type.String())),
93
+ sum: Type.Optional(Type.Array(Type.String())),
94
+ avg: Type.Optional(Type.Array(Type.String())),
95
+ count: Type.Optional(Type.Array(Type.String())),
96
+ }),
97
+ filters: Type.Optional(filtersObject(joinFilterFields)),
98
+ conditions: Type.Optional(Type.Array(conditionItemSchema, { maxItems: MAX_CONDITIONS })),
99
+ });
100
+ }
101
+ return props;
102
+ }
103
+ /** Per-alias *response* shapes: the rows each join family contributes to the result. */
104
+ function buildJoinResponseProps(dbTables, tableConf) {
105
+ const joinMultipleProps = {};
106
+ const joinLeftProps = {};
107
+ const joinGroupProps = {};
108
+ for (const joinDef of tableConf.allowedReadJoins ?? []) {
109
+ const { joinSchema, alias, unique } = joinDef;
110
+ const itemArray = Type.Array(Type.Partial(Type.Object(readableFields(joinSchema, dbTables[joinSchema.tableName]))));
111
+ if (unique) {
112
+ joinLeftProps[alias] = itemArray;
113
+ }
114
+ else {
115
+ joinMultipleProps[alias] = itemArray;
116
+ joinGroupProps[alias] = JoinGroupResultItem;
117
+ }
118
+ }
119
+ return { joinMultipleProps, joinLeftProps, joinGroupProps };
120
+ }
27
121
  const JoinGroupResultItem = Type.Object({
28
122
  sum: Type.Optional(Type.Record(Type.String(), Type.Any())),
29
123
  min: Type.Optional(Type.Record(Type.String(), Type.Any())),
@@ -42,113 +136,28 @@ export function SearchTableBodyPost(dbTables, tableName) {
42
136
  method: methodEnum,
43
137
  params: Type.Optional(Type.Array(Type.Any())),
44
138
  });
45
- // Build a TypeBox map of computed-field types (declared in defineTable).
46
- const computedTypes = {};
47
- if (tableConf.computedFields) {
48
- for (const [name, fn] of Object.entries(tableConf.computedFields)) {
49
- // Cheap dry-run with a stub context to extract the declared `type`.
50
- // The fn is pure-functional w.r.t. ctx — only `type` is read here.
51
- try {
52
- const stub = fn({
53
- db: { qi: (s) => s, dialectName: 'postgres' },
54
- qiCol: () => '""',
55
- });
56
- if (stub.type)
57
- computedTypes[name] = stub.type;
58
- }
59
- catch {
60
- // ignore — Swagger schema generation is best-effort; runtime validates.
61
- }
62
- }
63
- }
139
+ const computedTypes = computedFieldTypes(tableConf);
64
140
  const filterFields = {
65
141
  ...readableFields(schema, tableConf),
66
142
  ...tableConf.extraFilters,
67
143
  ...computedTypes,
68
144
  };
69
145
  const filtersSchema = Type.Optional(filtersObject(filterFields));
70
- // Per-alias entries split by unique flag:
71
- // - unique:false → joinMustExist / joinMultiple / joinGroup
72
- // - unique:true → joinLeft
73
- const joinMustExistProps = {};
74
- const joinMultipleProps = {};
75
- const joinGroupProps = {};
76
- const joinLeftProps = {};
77
- if (tableConf.allowedReadJoins) {
78
- for (const joinDef of tableConf.allowedReadJoins) {
79
- const { joinSchema, alias, unique } = joinDef;
80
- const joinTableConf = dbTables[joinSchema.tableName];
81
- const joinComputedTypes = {};
82
- if (joinTableConf?.computedFields) {
83
- for (const [name, fn] of Object.entries(joinTableConf.computedFields)) {
84
- try {
85
- const stub = fn({
86
- db: { qi: (s) => s, dialectName: 'postgres' },
87
- qiCol: () => '""',
88
- });
89
- if (stub.type)
90
- joinComputedTypes[name] = stub.type;
91
- }
92
- catch {
93
- // best-effort
94
- }
95
- }
96
- }
97
- const joinSchemaFields = joinTableConf
98
- ? { ...readableFields(joinSchema, joinTableConf), ...joinComputedTypes }
99
- : { ...joinSchema.fields };
100
- // `extraFilters` are only honoured where the engine delegates to the join table's
101
- // own `filters()` — buildJoinRefCondition, i.e. the unique:false paths. joinLeft
102
- // builds its condition inline (buildLeftJoinClauses) and handles schema and computed
103
- // fields only, so advertising extraFilters there would promise a silent no-op.
104
- const joinFilterFields = { ...joinSchemaFields, ...(joinTableConf?.extraFilters ?? {}) };
105
- const joinRefShape = {
106
- filters: Type.Optional(filtersObject(joinFilterFields)),
107
- conditions: Type.Optional(Type.Array(conditionItemSchema)),
108
- };
109
- if (unique) {
110
- joinLeftProps[alias] = Type.Object({
111
- filters: Type.Optional(filtersObject(joinSchemaFields)),
112
- conditions: Type.Optional(Type.Array(conditionItemSchema)),
113
- selection: Type.Optional(Type.String()),
114
- });
115
- }
116
- else {
117
- joinMustExistProps[alias] = Type.Object(joinRefShape);
118
- joinMultipleProps[alias] = Type.Object({
119
- ...joinRefShape,
120
- selection: Type.Optional(Type.String()),
121
- });
122
- joinGroupProps[alias] = Type.Object({
123
- aggregations: Type.Object({
124
- by: Type.Optional(Type.String()),
125
- distinctCount: Type.Optional(Type.Array(Type.String())),
126
- min: Type.Optional(Type.Array(Type.String())),
127
- max: Type.Optional(Type.Array(Type.String())),
128
- sum: Type.Optional(Type.Array(Type.String())),
129
- avg: Type.Optional(Type.Array(Type.String())),
130
- count: Type.Optional(Type.Array(Type.String())),
131
- }),
132
- filters: Type.Optional(filtersObject(joinFilterFields)),
133
- conditions: Type.Optional(Type.Array(conditionItemSchema)),
134
- });
135
- }
136
- }
137
- }
146
+ const joins = buildJoinBodyProps(dbTables, tableConf, conditionItemSchema);
138
147
  const bodyProperties = {
139
148
  filters: filtersSchema,
140
- conditions: Type.Optional(Type.Array(conditionItemSchema)),
149
+ conditions: Type.Optional(Type.Array(conditionItemSchema, { maxItems: MAX_CONDITIONS })),
141
150
  };
142
151
  if (Object.keys(computedTypes).length > 0) {
143
152
  bodyProperties.selectComputed = Type.Optional(Type.Array(Type.String()));
144
153
  }
145
- if (Object.keys(joinMustExistProps).length > 0) {
146
- bodyProperties.joinMustExist = Type.Optional(Type.Partial(Type.Object(joinMustExistProps)));
147
- bodyProperties.joinMultiple = Type.Optional(Type.Partial(Type.Object(joinMultipleProps)));
148
- bodyProperties.joinGroup = Type.Optional(Type.Partial(Type.Object(joinGroupProps)));
154
+ if (Object.keys(joins.joinMustExist).length > 0) {
155
+ bodyProperties.joinMustExist = Type.Optional(Type.Partial(Type.Object(joins.joinMustExist)));
156
+ bodyProperties.joinMultiple = Type.Optional(Type.Partial(Type.Object(joins.joinMultiple)));
157
+ bodyProperties.joinGroup = Type.Optional(Type.Partial(Type.Object(joins.joinGroup)));
149
158
  }
150
- if (Object.keys(joinLeftProps).length > 0) {
151
- bodyProperties.joinLeft = Type.Optional(Type.Partial(Type.Object(joinLeftProps)));
159
+ if (Object.keys(joins.joinLeft).length > 0) {
160
+ bodyProperties.joinLeft = Type.Optional(Type.Partial(Type.Object(joins.joinLeft)));
152
161
  }
153
162
  return Type.Object(bodyProperties);
154
163
  }
@@ -162,7 +171,7 @@ export function SearchTableBodyPost(dbTables, tableName) {
162
171
  */
163
172
  export function SearchTableQuery(maxItemsPerPage = DEFAULT_MAX_ITEMS_PER_PAGE) {
164
173
  return Type.Object({
165
- orderBy: Type.Optional(Type.String()),
174
+ orderBy: Type.Optional(Type.String({ maxLength: MAX_ORDER_BY_LENGTH })),
166
175
  page: Type.Optional(Type.Integer({ minimum: 1 })),
167
176
  itemsPerPage: Type.Optional(Type.Integer({
168
177
  minimum: 1,
@@ -182,42 +191,12 @@ export function SearchTableResponse(dbTables, tableName) {
182
191
  const tableConf = dbTables[tableName];
183
192
  // Computed fields are present in main rows only when explicitly listed in
184
193
  // request body's selectComputed, so they're optional/Partial here.
185
- const computedTypes = {};
186
- if (tableConf.computedFields) {
187
- for (const [name, fn] of Object.entries(tableConf.computedFields)) {
188
- try {
189
- const stub = fn({
190
- db: { qi: (s) => s, dialectName: 'postgres' },
191
- qiCol: () => '""',
192
- });
193
- if (stub.type)
194
- computedTypes[name] = stub.type;
195
- }
196
- catch {
197
- // best-effort
198
- }
199
- }
200
- }
194
+ const computedTypes = computedFieldTypes(tableConf);
201
195
  const mainReadable = readableFields(tableConf.Schema, tableConf);
202
196
  const mainItem = Object.keys(computedTypes).length > 0
203
197
  ? Type.Partial(Type.Object({ ...mainReadable, ...computedTypes }))
204
198
  : Type.Partial(Type.Object(mainReadable));
205
- const joinMultipleProps = {};
206
- const joinLeftProps = {};
207
- const joinGroupProps = {};
208
- if (tableConf.allowedReadJoins) {
209
- for (const joinDef of tableConf.allowedReadJoins) {
210
- const { joinSchema, alias, unique } = joinDef;
211
- const itemArray = Type.Array(Type.Partial(Type.Object(readableFields(joinSchema, dbTables[joinSchema.tableName]))));
212
- if (unique) {
213
- joinLeftProps[alias] = itemArray;
214
- }
215
- else {
216
- joinMultipleProps[alias] = itemArray;
217
- joinGroupProps[alias] = JoinGroupResultItem;
218
- }
219
- }
220
- }
199
+ const { joinMultipleProps, joinLeftProps, joinGroupProps } = buildJoinResponseProps(dbTables, tableConf);
221
200
  const responseProps = {
222
201
  table: Type.String(),
223
202
  main: Type.Array(mainItem),
@@ -1 +1 @@
1
- {"version":3,"file":"sql-api.d.ts","sourceRoot":"","sources":["../../src/lib/sql-api.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"sql-api.d.ts","sourceRoot":"","sources":["../../src/lib/sql-api.ts"],"names":[],"mappings":"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"}
@@ -4,8 +4,34 @@ import { ConditionBuilder, type ConditionValue, type ConditionValueOrUndefined }
4
4
  import type { ITable, SqlApiPluginOptions, TenantId, TenantScope, TenantScopeIndirect, TenantContext } from '../types.js';
5
5
  export declare function resolveTenant(options: SqlApiPluginOptions, tableConf: ITable, request: FastifyRequest): Promise<TenantContext | undefined>;
6
6
  export declare function buildTenantCondition(db: QueryClient, scope: TenantScope, tenantIds: TenantId[], ownTable: string): ConditionBuilder;
7
+ /**
8
+ * Re-target a tenant context onto a related table: the caller's tenant ids paired with that
9
+ * table's OWN scope. A child table declares its own `tenantScope`, which may differ in shape
10
+ * from the main table's, so a write to it must be checked against its own column/through-FK.
11
+ *
12
+ * Returns undefined for the two cases that must stay unscoped — no tenant context (admin) and
13
+ * a table that declares no scope — mirroring `appendJoinTenantScope` on the read side.
14
+ */
15
+ export declare function tenantForTable(tenant: TenantContext | undefined, tableConf: ITable | undefined): TenantContext | undefined;
16
+ /**
17
+ * Tenant guard as a self-contained boolean fragment, for the positions where the scope cannot
18
+ * be expressed as an extra INNER JOIN: a child DELETE, a LEFT JOIN's ON clause, and the
19
+ * correlated subquery of an aggregation. A direct scope compares the table's own column; an
20
+ * indirect one tests the through table with a correlated EXISTS rather than a join.
21
+ *
22
+ * `tableRef` is the name the scoped rows go by in that statement — the table name in a DELETE,
23
+ * the join alias in an ON clause, the subquery alias inside a correlated SELECT. Returns
24
+ * undefined without a tenant context (admin).
25
+ */
26
+ export declare function buildTenantRowGuard(db: QueryClient, tenant: TenantContext | undefined, tableRef: string): ConditionBuilder | undefined;
7
27
  export declare function buildTenantJoin(db: QueryClient, scope: TenantScopeIndirect, mainTableName: string): string;
28
+ /** Writes the tenant value into a record according to its scope.
29
+ * @testonly Exported only so unit tests can exercise it directly.
30
+ */
8
31
  export declare function injectTenantValue(record: Record<string, unknown>, scope: TenantScope, tenantIds: TenantId[]): void;
32
+ /** Checks that an indirect-scope FK really belongs to the tenant.
33
+ * @testonly Exported only so unit tests can exercise it directly.
34
+ */
9
35
  export declare function validateTenantFK(db: QueryClient, scope: TenantScopeIndirect, tenantIds: TenantId[], fkValues: unknown[]): Promise<void>;
10
36
  /**
11
37
  * Build WHERE clause for DELETE with tenant filtering.
@@ -1 +1 @@
1
- {"version":3,"file":"tenant.d.ts","sourceRoot":"","sources":["../../src/lib/tenant.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"tenant.d.ts","sourceRoot":"","sources":["../../src/lib/tenant.ts"],"names":[],"mappings":"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"}
@@ -30,12 +30,55 @@ export function buildTenantCondition(db, scope, tenantIds, ownTable) {
30
30
  cb.isIn(qualifier, tenantIds);
31
31
  return cb;
32
32
  }
33
+ /**
34
+ * Re-target a tenant context onto a related table: the caller's tenant ids paired with that
35
+ * table's OWN scope. A child table declares its own `tenantScope`, which may differ in shape
36
+ * from the main table's, so a write to it must be checked against its own column/through-FK.
37
+ *
38
+ * Returns undefined for the two cases that must stay unscoped — no tenant context (admin) and
39
+ * a table that declares no scope — mirroring `appendJoinTenantScope` on the read side.
40
+ */
41
+ export function tenantForTable(tenant, tableConf) {
42
+ const scope = tableConf?.tenantScope;
43
+ if (!tenant || !scope)
44
+ return undefined;
45
+ return { ids: tenant.ids, scope };
46
+ }
47
+ /**
48
+ * Tenant guard as a self-contained boolean fragment, for the positions where the scope cannot
49
+ * be expressed as an extra INNER JOIN: a child DELETE, a LEFT JOIN's ON clause, and the
50
+ * correlated subquery of an aggregation. A direct scope compares the table's own column; an
51
+ * indirect one tests the through table with a correlated EXISTS rather than a join.
52
+ *
53
+ * `tableRef` is the name the scoped rows go by in that statement — the table name in a DELETE,
54
+ * the join alias in an ON clause, the subquery alias inside a correlated SELECT. Returns
55
+ * undefined without a tenant context (admin).
56
+ */
57
+ export function buildTenantRowGuard(db, tenant, tableRef) {
58
+ if (!tenant)
59
+ return undefined;
60
+ const cb = new ConditionBuilder('AND', db.cbDialect);
61
+ if (!isIndirect(tenant.scope)) {
62
+ cb.append(buildTenantCondition(db, tenant.scope, tenant.ids, tableRef));
63
+ return cb;
64
+ }
65
+ const { through, column } = tenant.scope;
66
+ const throughRef = db.qi(through.schema.tableName);
67
+ const markers = tenant.ids.map(() => '?').join(', ');
68
+ cb.raw(`EXISTS (SELECT 1 FROM ${throughRef} WHERE ` +
69
+ `${throughRef}.${db.qi(through.foreignField)} = ${db.qi(tableRef)}.${db.qi(through.localField)} AND ` +
70
+ `${throughRef}.${db.qi(column)} IN (${markers}))`, tenant.ids);
71
+ return cb;
72
+ }
33
73
  export function buildTenantJoin(db, scope, mainTableName) {
34
74
  const throughTable = scope.through.schema.tableName;
35
75
  const localField = scope.through.localField;
36
76
  const foreignField = scope.through.foreignField;
37
77
  return `INNER JOIN ${db.qi(throughTable)} ON ${db.qi(mainTableName)}.${db.qi(localField)} = ${db.qi(throughTable)}.${db.qi(foreignField)}`;
38
78
  }
79
+ /** Writes the tenant value into a record according to its scope.
80
+ * @testonly Exported only so unit tests can exercise it directly.
81
+ */
39
82
  export function injectTenantValue(record, scope, tenantIds) {
40
83
  if (isIndirect(scope))
41
84
  return;
@@ -52,6 +95,9 @@ export function injectTenantValue(record, scope, tenantIds) {
52
95
  }
53
96
  throw httpError(400, 'Ambiguous tenant: specify the tenant value');
54
97
  }
98
+ /** Checks that an indirect-scope FK really belongs to the tenant.
99
+ * @testonly Exported only so unit tests can exercise it directly.
100
+ */
55
101
  export async function validateTenantFK(db, scope, tenantIds, fkValues) {
56
102
  if (!fkValues.length)
57
103
  return;
@@ -1,7 +1,5 @@
1
1
  import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2
2
  import type { ITable, SqlApiPluginOptions, DbTables, TableOperation } from '../../types.js';
3
- type RequestHook = (request: FastifyRequest, reply: FastifyReply) => Promise<void | FastifyReply>;
4
- export declare function mergeOnRequests(options: SqlApiPluginOptions, tableConf: ITable): RequestHook[];
5
3
  export declare function buildWriteDescription(action: string, tableName: string, tableConf: ITable): string;
6
4
  interface RouteSchema {
7
5
  body?: any;
@@ -31,11 +29,6 @@ export interface AutoRouteSpec {
31
29
  * the registrar will reply.status(successStatus).send(returnValue). */
32
30
  handle: (fastify: FastifyInstance, tableName: string, tableConf: ITable, request: FastifyRequest, reply: FastifyReply) => Promise<unknown>;
33
31
  }
34
- /**
35
- * Iterates over every table in `options.DbTables` and registers one Fastify route for it
36
- * following `spec`. Centralizes onRequest hook merging, tags, schema response wrapping,
37
- * default reply when handler returns a value.
38
- */
39
32
  export declare function registerForAllTables(fastify: FastifyInstance, options: SqlApiPluginOptions, spec: AutoRouteSpec): Promise<void>;
40
33
  export {};
41
34
  //# sourceMappingURL=route-helpers.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"route-helpers.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/route-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAG7E,OAAO,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAE5F,KAAK,WAAW,GAAG,CAAC,OAAO,EAAE,cAAc,EAAE,KAAK,EAAE,YAAY,KAAK,OAAO,CAAC,IAAI,GAAG,YAAY,CAAC,CAAC;AAElG,wBAAgB,eAAe,CAAC,OAAO,EAAE,mBAAmB,EAAE,SAAS,EAAE,MAAM,GAAG,WAAW,EAAE,CAE9F;AAED,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAQlG;AAED,UAAU,WAAW;IAEnB,IAAI,CAAC,EAAE,GAAG,CAAC;IAEX,MAAM,CAAC,EAAE,GAAG,CAAC;IAEb,WAAW,CAAC,EAAE,GAAG,CAAC;IAElB,QAAQ,CAAC,EAAE,GAAG,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,iGAAiG;IACjG,SAAS,EAAE,cAAc,CAAC;IAC1B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC1C,gFAAgF;IAChF,GAAG,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,CAAC;IACnC,aAAa,EAAE,GAAG,GAAG,GAAG,CAAC;IACzB,wIAAwI;IACxI,OAAO,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,WAAW,CAAC;IACnF,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,KAAK,MAAM,CAAC;IACpF;2EACuE;IACvE,MAAM,EAAE,CACN,OAAO,EAAE,eAAe,EACxB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,cAAc,EACvB,KAAK,EAAE,YAAY,KAChB,OAAO,CAAC,OAAO,CAAC,CAAC;CACvB;AAED;;;;GAIG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,EAC5B,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,IAAI,CAAC,CAkDf"}
1
+ {"version":3,"file":"route-helpers.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/route-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAG7E,OAAO,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAQ5F,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAQlG;AAED,UAAU,WAAW;IAEnB,IAAI,CAAC,EAAE,GAAG,CAAC;IAEX,MAAM,CAAC,EAAE,GAAG,CAAC;IAEb,WAAW,CAAC,EAAE,GAAG,CAAC;IAElB,QAAQ,CAAC,EAAE,GAAG,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,iGAAiG;IACjG,SAAS,EAAE,cAAc,CAAC;IAC1B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC1C,gFAAgF;IAChF,GAAG,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,CAAC;IACnC,aAAa,EAAE,GAAG,GAAG,GAAG,CAAC;IACzB,wIAAwI;IACxI,OAAO,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,WAAW,CAAC;IACnF,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,KAAK,MAAM,CAAC;IACpF;2EACuE;IACvE,MAAM,EAAE,CACN,OAAO,EAAE,eAAe,EACxB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,cAAc,EACvB,KAAK,EAAE,YAAY,KAChB,OAAO,CAAC,OAAO,CAAC,CAAC;CACvB;AA6DD,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,EAC5B,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,IAAI,CAAC,CAuBf"}
@@ -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 || {});
@@ -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,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.