fastify-auto-sqlapi 0.1.12 → 0.1.14

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.
@@ -2,7 +2,7 @@ import { ConditionBuilder, Expression } from 'node-condition-builder';
2
2
  import { camelcaseObject } from '../../naming.js';
3
3
  import { QueryParams } from '../query-params.js';
4
4
  import { buildTenantCondition, buildTenantJoin } from '../../tenant.js';
5
- import { assertReadable, assertFiltersReadable, readableSelectColumns } from '../../read-access.js';
5
+ import { assertReadable, assertFiltersReadable, assertKnownFilterKeys, readableSelectColumns, explicitSelectColumns, } from '../../read-access.js';
6
6
  import { primaryAsString } from '../../../types.js';
7
7
  // ─── Schema field validation ────────────────────────────────
8
8
  function validateSchemaField(field, schema, tableConf) {
@@ -87,6 +87,28 @@ function subqueryAlias(alias, tableConf) {
87
87
  function findJoinByAlias(tableConf, alias) {
88
88
  return tableConf.allowedReadJoins?.find((j) => j.alias === alias);
89
89
  }
90
+ /**
91
+ * Validate the filter keys of every join reference in a request map.
92
+ *
93
+ * Runs up front, before any query: the executors skip their side-query entirely when the
94
+ * main result set is empty (`ids.length === 0`), so validating there alone would make the
95
+ * same request answer 400 or 200 depending on the data it happens to match.
96
+ *
97
+ * Unknown aliases are left to `requireJoin`, which reports them with its own message.
98
+ */
99
+ function assertJoinFilterKeys(dbTables, tableConf, refs, allowExtraFilters) {
100
+ if (!refs)
101
+ return;
102
+ for (const [alias, ref] of Object.entries(refs)) {
103
+ const joinDef = findJoinByAlias(tableConf, alias);
104
+ if (!joinDef)
105
+ continue;
106
+ const joinTableConf = dbTables[joinDef.joinSchema.tableName];
107
+ const filters = ref?.filters;
108
+ assertFiltersReadable(filters, joinTableConf);
109
+ assertKnownFilterKeys(filters, joinDef.joinSchema, joinTableConf, allowExtraFilters);
110
+ }
111
+ }
90
112
  function requireJoin(tableConf, alias, requireUnique) {
91
113
  const joinDef = findJoinByAlias(tableConf, alias);
92
114
  if (!joinDef) {
@@ -176,9 +198,15 @@ function mapRowsToCamelCase(rows, schema, computedFieldNames) {
176
198
  * Build the SELECT columns list for a join fetch: '*' or comma-separated quoted columns.
177
199
  * A default '*' selection narrows to the join table's readable columns when it declares
178
200
  * `readExclude`; an explicit selection naming an excluded field is rejected with 400.
201
+ *
202
+ * On a relation narrowed by a `fields` allowlist the '*' is always spelled out. Emitting a
203
+ * real `SELECT *` there would fetch the excluded columns and leave them to be dropped by
204
+ * the response serializer — which does not run for a caller of `sqlApi.search()`.
179
205
  */
180
- function buildSelectionColumns(selection, joinSchema, db, joinTableConf) {
206
+ function buildSelectionColumns(selection, joinSchema, db, joinTableConf, restricted = false) {
181
207
  if (selection === '*') {
208
+ if (restricted)
209
+ return explicitSelectColumns(joinTableConf, joinSchema, db);
182
210
  return readableSelectColumns(joinTableConf, joinSchema, db) ?? '*';
183
211
  }
184
212
  const table = db.qi(joinSchema.tableName);
@@ -331,7 +359,9 @@ function buildLeftJoinClauses(db, dbTables, tableConf, aliasesNeedingJoin, joinL
331
359
  // (so SQL references the LEFT JOIN'd table, not a bare table name).
332
360
  // Note: extraFilters declared on joinTableConf are not supported here for
333
361
  // joinLeft (they would require alias-aware handlers); only schema fields
334
- // and computed fields apply.
362
+ // and computed fields apply. `assertJoinFilterKeys` rejects them up front rather
363
+ // than letting them through as a silent no-op, and the generated body schema does
364
+ // not advertise them for unique:true relations.
335
365
  const cb = new ConditionBuilder('AND', db.cbDialect);
336
366
  const joinTableConf = dbTables[joinSchema.tableName];
337
367
  const computed = joinTableConf?.computedFields;
@@ -487,6 +517,11 @@ async function executeJoinMultiple(db, dbTables, tableConf, mainResults, joinMul
487
517
  const joinDef = requireJoin(tableConf, alias, false);
488
518
  const { joinSchema, joinField, mainField, selection: defaultSelection } = joinDef;
489
519
  const joinTableConf = dbTables[joinSchema.tableName];
520
+ // The selection is resolved before the empty-result bail-out: it is validated against
521
+ // the join schema, and that rejection must not depend on whether the main query
522
+ // matched rows.
523
+ const selection = ref?.selection ?? defaultSelection;
524
+ const columns = buildSelectionColumns(selection, joinSchema, db, joinTableConf, joinDef.fields !== undefined);
490
525
  const ids = collectIds(mainResults, mainField);
491
526
  if (ids.length === 0) {
492
527
  result[alias] = [];
@@ -499,8 +534,6 @@ async function executeJoinMultiple(db, dbTables, tableConf, mainResults, joinMul
499
534
  appendJoinTenantScope(db, joinTableConf, tenant, joinSchema.tableName, cb, tenantJoins);
500
535
  const where = cb.build(1, db.ph);
501
536
  const values = cb.getValues();
502
- const selection = ref?.selection ?? defaultSelection;
503
- const columns = buildSelectionColumns(selection, joinSchema, db, joinTableConf);
504
537
  const rows = await db.select({
505
538
  tableName: joinSchema.tableName,
506
539
  columns,
@@ -519,6 +552,10 @@ async function executeJoinLeft(db, dbTables, tableConf, mainResults, joinLeft, t
519
552
  const joinDef = requireJoin(tableConf, alias, true);
520
553
  const { joinSchema, joinField, mainField, selection: defaultSelection } = joinDef;
521
554
  const joinTableConf = dbTables[joinSchema.tableName];
555
+ // Resolved before the bail-out, for the same reason as joinMultiple: an invalid
556
+ // selection is a 400 whether or not the main query matched anything.
557
+ const selection = ref?.selection ?? defaultSelection;
558
+ const columns = buildSelectionColumns(selection, joinSchema, db, joinTableConf, joinDef.fields !== undefined);
522
559
  // For joinLeft (N:1), mainField on main is the FK pointing to joinField (PK) on parent.
523
560
  // We collect the FK values from the main results and look up parents by their PK.
524
561
  const ids = collectIds(mainResults, mainField);
@@ -533,8 +570,6 @@ async function executeJoinLeft(db, dbTables, tableConf, mainResults, joinLeft, t
533
570
  appendJoinTenantScope(db, joinTableConf, tenant, joinSchema.tableName, cb, tenantJoins);
534
571
  const where = cb.build(1, db.ph);
535
572
  const values = cb.getValues();
536
- const selection = ref?.selection ?? defaultSelection;
537
- const columns = buildSelectionColumns(selection, joinSchema, db, joinTableConf);
538
573
  const rows = await db.select({
539
574
  tableName: joinSchema.tableName,
540
575
  columns,
@@ -577,10 +612,6 @@ async function executeJoinGroup(db, dbTables, tableConf, mainResults, joinGroup,
577
612
  const { joinSchema, joinField, mainField } = joinDef;
578
613
  const joinTableConf = dbTables[joinSchema.tableName];
579
614
  const ids = collectIds(mainResults, mainField);
580
- if (ids.length === 0) {
581
- result[alias] = {};
582
- continue;
583
- }
584
615
  const { aggregations, filters: groupFilters, conditions: groupConditions } = groupReq;
585
616
  const selectParts = [];
586
617
  const groupByParts = [];
@@ -604,7 +635,9 @@ async function executeJoinGroup(db, dbTables, tableConf, mainResults, joinGroup,
604
635
  addAgg('sum', aggregations.sum);
605
636
  addAgg('avg', aggregations.avg);
606
637
  addAgg('count', aggregations.count);
607
- if (selectParts.length === 0) {
638
+ // Bail out only here: `by` and every aggregation field have now been validated against
639
+ // the join schema, so an unknown or unreachable field is a 400 regardless of the data.
640
+ if (selectParts.length === 0 || ids.length === 0) {
608
641
  result[alias] = {};
609
642
  continue;
610
643
  }
@@ -676,6 +709,8 @@ function dispatchConditionMethod(cb, method, colOrExpr, params) {
676
709
  }
677
710
  function buildJoinRefCondition(joinTableConf, joinSchema, ref, db, qualifier) {
678
711
  assertFiltersReadable(ref.filters, joinTableConf);
712
+ // Filter keys are checked by assertJoinFilterKeys, up front in searchEngine — this side
713
+ // query may never run, so it is not a place a request can be rejected from.
679
714
  // Bare table name for plain side-queries; the subquery alias when the condition lands
680
715
  // inside an aliased, correlated FROM (there the table name would resolve to the outer query).
681
716
  const colQualifier = qualifier ?? joinSchema.tableName;
@@ -690,7 +725,7 @@ function buildJoinRefCondition(joinTableConf, joinSchema, ref, db, qualifier) {
690
725
  continue;
691
726
  if (!computed[name])
692
727
  continue;
693
- const expr = evaluateComputedField(name, computed[name], joinTableConf.Schema, db, colQualifier, '', true);
728
+ const expr = evaluateComputedField(name, computed[name], joinSchema, db, colQualifier, '', true);
694
729
  cb.isEqual(expr, value);
695
730
  }
696
731
  }
@@ -702,7 +737,7 @@ function buildJoinRefCondition(joinTableConf, joinSchema, ref, db, qualifier) {
702
737
  // Computed fields resolve to an Expression carrying their own bound values, which
703
738
  // the ConditionBuilder places together with the compared value.
704
739
  const operand = computed?.[c.field]
705
- ? evaluateComputedField(c.field, computed[c.field], joinTableConf.Schema, db, colQualifier, '', true)
740
+ ? evaluateComputedField(c.field, computed[c.field], joinSchema, db, colQualifier, '', true)
706
741
  : `${db.qi(colQualifier)}.${db.qi(validateSchemaField(c.field, joinSchema, joinTableConf))}`;
707
742
  dispatchConditionMethod(cb, c.method, operand, c.params ?? []);
708
743
  }
@@ -809,8 +844,16 @@ export async function searchEngine(dbTables, params) {
809
844
  if (joinLeft)
810
845
  for (const a of Object.keys(joinLeft))
811
846
  requireJoin(tableConf, a, true);
847
+ // Join filter keys, validated before any query so the outcome never depends on whether
848
+ // the main query returned rows. joinLeft is the odd one: its condition is built inline
849
+ // and never runs the target's extendedCondition, so extraFilters are not accepted there.
850
+ assertJoinFilterKeys(dbTables, tableConf, joinMustExist, true);
851
+ assertJoinFilterKeys(dbTables, tableConf, joinMultiple, true);
852
+ assertJoinFilterKeys(dbTables, tableConf, joinGroup, true);
853
+ assertJoinFilterKeys(dbTables, tableConf, joinLeft, false);
812
854
  // Build main condition
813
855
  assertFiltersReadable(filters, tableConf);
856
+ assertKnownFilterKeys(filters, tableConf.Schema, tableConf);
814
857
  const condition = tableConf.filters(filters || {}, db.cbDialect);
815
858
  // Filters and conditions targeting computed fields go on the same ConditionBuilder:
816
859
  // a computed resolves to an Expression carrying its own values, and the builder is
@@ -17,6 +17,26 @@ export declare function assertReadable(tableConf: ITable | undefined, field: str
17
17
  * excluded pass through untouched (extraFilters and computed names included).
18
18
  */
19
19
  export declare function assertFiltersReadable(filters: Record<string, unknown> | undefined, tableConf: ITable | undefined): void;
20
+ /**
21
+ * Throws 400 when a filter map carries a key the engine would never visit.
22
+ *
23
+ * Filters are matched against the schema fields, the table's `extraFilters` and its computed
24
+ * fields; anything else used to be dropped without a word, so a mistyped key returned MORE
25
+ * rows than the caller asked for and nothing said so. That is the dangerous direction to fail
26
+ * in — filters are also how consumers narrow visibility — and it contradicted every
27
+ * neighbouring path, where an unknown field in `selection`, `conditions`, `orderBy` or an
28
+ * aggregation is already a 400.
29
+ *
30
+ * `schema` is the schema the relation was declared with, which for a join may be a trimmed
31
+ * copy: a field outside it is unknown here exactly as it is for `selection`.
32
+ *
33
+ * `allowExtraFilters` is false on the joinLeft path, whose condition is built inline
34
+ * (`buildLeftJoinClauses`) and never runs the table's `extendedCondition`.
35
+ *
36
+ * Call AFTER `assertFiltersReadable`, so a read-excluded field keeps its own message
37
+ * instead of being reported as unknown.
38
+ */
39
+ export declare function assertKnownFilterKeys(filters: Record<string, unknown> | undefined, schema: SchemaDefinition, tableConf: ITable | undefined, allowExtraFilters?: boolean): void;
20
40
  /** Schema field names that may be read, in schema declaration order. */
21
41
  export declare function readableFieldNames(tableConf: ITable | undefined, schema: SchemaDefinition): string[];
22
42
  /**
@@ -28,4 +48,9 @@ export declare function readableFieldNames(tableConf: ITable | undefined, schema
28
48
  * would be ambiguous.
29
49
  */
30
50
  export declare function readableSelectColumns(tableConf: ITable | undefined, schema: SchemaDefinition, db: QueryClient): string | undefined;
51
+ /**
52
+ * The same list, always spelled out. Used where `*` is not an acceptable fallback: a
53
+ * relation narrowed by a `fields` allowlist must project the allowlist, not the table.
54
+ */
55
+ export declare function explicitSelectColumns(tableConf: ITable | undefined, schema: SchemaDefinition, db: QueryClient): string;
31
56
  //# sourceMappingURL=read-access.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"read-access.d.ts","sourceRoot":"","sources":["../../src/lib/read-access.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC3C,OAAO,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAG5D;;;;;;;;GAQG;AAEH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAEpF;AAED,+DAA+D;AAC/D,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAIjF;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC5C,SAAS,EAAE,MAAM,GAAG,SAAS,GAC5B,IAAI,CAKN;AAED,wEAAwE;AACxE,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,gBAAgB,GACvB,MAAM,EAAE,CAIV;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,gBAAgB,EACxB,EAAE,EAAE,WAAW,GACd,MAAM,GAAG,SAAS,CAMpB"}
1
+ {"version":3,"file":"read-access.d.ts","sourceRoot":"","sources":["../../src/lib/read-access.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC3C,OAAO,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAG5D;;;;;;;;GAQG;AAEH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAEpF;AAED,+DAA+D;AAC/D,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAIjF;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC5C,SAAS,EAAE,MAAM,GAAG,SAAS,GAC5B,IAAI,CAKN;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC5C,MAAM,EAAE,gBAAgB,EACxB,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,iBAAiB,UAAO,GACvB,IAAI,CAkBN;AAED,wEAAwE;AACxE,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,gBAAgB,GACvB,MAAM,EAAE,CAIV;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,gBAAgB,EACxB,EAAE,EAAE,WAAW,GACd,MAAM,GAAG,SAAS,CAGpB;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,gBAAgB,EACxB,EAAE,EAAE,WAAW,GACd,MAAM,CAKR"}
@@ -28,6 +28,46 @@ export function assertFiltersReadable(filters, tableConf) {
28
28
  assertReadable(tableConf, key);
29
29
  }
30
30
  }
31
+ /**
32
+ * Throws 400 when a filter map carries a key the engine would never visit.
33
+ *
34
+ * Filters are matched against the schema fields, the table's `extraFilters` and its computed
35
+ * fields; anything else used to be dropped without a word, so a mistyped key returned MORE
36
+ * rows than the caller asked for and nothing said so. That is the dangerous direction to fail
37
+ * in — filters are also how consumers narrow visibility — and it contradicted every
38
+ * neighbouring path, where an unknown field in `selection`, `conditions`, `orderBy` or an
39
+ * aggregation is already a 400.
40
+ *
41
+ * `schema` is the schema the relation was declared with, which for a join may be a trimmed
42
+ * copy: a field outside it is unknown here exactly as it is for `selection`.
43
+ *
44
+ * `allowExtraFilters` is false on the joinLeft path, whose condition is built inline
45
+ * (`buildLeftJoinClauses`) and never runs the table's `extendedCondition`.
46
+ *
47
+ * Call AFTER `assertFiltersReadable`, so a read-excluded field keeps its own message
48
+ * instead of being reported as unknown.
49
+ */
50
+ export function assertKnownFilterKeys(filters, schema, tableConf, allowExtraFilters = true) {
51
+ if (!filters)
52
+ return;
53
+ for (const [key, value] of Object.entries(filters)) {
54
+ // `undefined` means "filter not supplied" everywhere else in the engine; an absent
55
+ // filter cannot be a wrong one. `null` does filter (IS NULL) and is validated.
56
+ if (value === undefined)
57
+ continue;
58
+ if (key in schema.fields)
59
+ continue;
60
+ if (tableConf?.computedFields?.[key])
61
+ continue;
62
+ if (tableConf?.extraFilters && key in tableConf.extraFilters) {
63
+ if (allowExtraFilters)
64
+ continue;
65
+ throw httpError(400, `Filter '${key}' is an extraFilter: extraFilters are not applied on joinLeft ` +
66
+ `(only schema fields and computed fields are). Use joinMustExist on the same relation.`);
67
+ }
68
+ throw httpError(400, `Unknown filter field: ${key}`);
69
+ }
70
+ }
31
71
  /** Schema field names that may be read, in schema declaration order. */
32
72
  export function readableFieldNames(tableConf, schema) {
33
73
  const fields = Object.keys(schema.fields);
@@ -46,6 +86,13 @@ export function readableFieldNames(tableConf, schema) {
46
86
  export function readableSelectColumns(tableConf, schema, db) {
47
87
  if (!tableConf?.readExclude?.length)
48
88
  return undefined;
89
+ return explicitSelectColumns(tableConf, schema, db);
90
+ }
91
+ /**
92
+ * The same list, always spelled out. Used where `*` is not an acceptable fallback: a
93
+ * relation narrowed by a `fields` allowlist must project the allowlist, not the table.
94
+ */
95
+ export function explicitSelectColumns(tableConf, schema, db) {
49
96
  const table = db.qi(schema.tableName);
50
97
  return readableFieldNames(tableConf, schema)
51
98
  .map((f) => `${table}.${db.qi(schema.col(f))}`)
@@ -1 +1 @@
1
- {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/search.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAIrE,OAAO,KAAK,EAAE,QAAQ,EAA4B,MAAM,gBAAgB,CAAC;AAyBzE,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAuHlF;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,eAAe,GAAE,MAAmC,GAAG,OAAO,CAe9F;AAED,qFAAqF;AACrF,eAAO,MAAM,sBAAsB,kDAAqB,CAAC;AAEzD,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAsElF"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/search.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAIrE,OAAO,KAAK,EAAE,QAAQ,EAA4B,MAAM,gBAAgB,CAAC;AAsCzE,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CA+HlF;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,eAAe,GAAE,MAAmC,GAAG,OAAO,CAe9F;AAED,qFAAqF;AACrF,eAAO,MAAM,sBAAsB,kDAAqB,CAAC;AAEzD,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAsElF"}
@@ -12,6 +12,18 @@ function readableFields(schema, tableConf) {
12
12
  }
13
13
  return out;
14
14
  }
15
+ /**
16
+ * A `filters` map.
17
+ *
18
+ * Deliberately NOT `additionalProperties: false`, unlike the write bodies. Fastify runs Ajv
19
+ * with `removeAdditional: true`, so a closed schema would silently *strip* an unknown filter
20
+ * key before the handler ever sees it — reproducing the very bug it looks like it fixes, and
21
+ * leaving the engine's rejection unreachable over HTTP. Unknown keys are rejected by
22
+ * `assertKnownFilterKeys` in the engine instead, which also covers `sqlApi.search()`.
23
+ */
24
+ function filtersObject(fields) {
25
+ return Type.Partial(Type.Object(fields));
26
+ }
15
27
  const JoinGroupResultItem = Type.Object({
16
28
  sum: Type.Optional(Type.Record(Type.String(), Type.Any())),
17
29
  min: Type.Optional(Type.Record(Type.String(), Type.Any())),
@@ -54,7 +66,7 @@ export function SearchTableBodyPost(dbTables, tableName) {
54
66
  ...tableConf.extraFilters,
55
67
  ...computedTypes,
56
68
  };
57
- const filtersSchema = Type.Optional(Type.Partial(Type.Object(filterFields)));
69
+ const filtersSchema = Type.Optional(filtersObject(filterFields));
58
70
  // Per-alias entries split by unique flag:
59
71
  // - unique:false → joinMustExist / joinMultiple / joinGroup
60
72
  // - unique:true → joinLeft
@@ -82,23 +94,31 @@ export function SearchTableBodyPost(dbTables, tableName) {
82
94
  }
83
95
  }
84
96
  }
85
- const joinFilterFields = joinTableConf
86
- ? { ...readableFields(joinSchema, joinTableConf), ...joinTableConf.extraFilters, ...joinComputedTypes }
97
+ const joinSchemaFields = joinTableConf
98
+ ? { ...readableFields(joinSchema, joinTableConf), ...joinComputedTypes }
87
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 ?? {}) };
88
105
  const joinRefShape = {
89
- filters: Type.Optional(Type.Partial(Type.Object(joinFilterFields))),
106
+ filters: Type.Optional(filtersObject(joinFilterFields)),
90
107
  conditions: Type.Optional(Type.Array(conditionItemSchema)),
91
108
  };
92
- const joinFetchShape = {
93
- ...joinRefShape,
94
- selection: Type.Optional(Type.String()),
95
- };
96
109
  if (unique) {
97
- joinLeftProps[alias] = Type.Object(joinFetchShape);
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
+ });
98
115
  }
99
116
  else {
100
117
  joinMustExistProps[alias] = Type.Object(joinRefShape);
101
- joinMultipleProps[alias] = Type.Object(joinFetchShape);
118
+ joinMultipleProps[alias] = Type.Object({
119
+ ...joinRefShape,
120
+ selection: Type.Optional(Type.String()),
121
+ });
102
122
  joinGroupProps[alias] = Type.Object({
103
123
  aggregations: Type.Object({
104
124
  by: Type.Optional(Type.String()),
@@ -109,7 +129,7 @@ export function SearchTableBodyPost(dbTables, tableName) {
109
129
  avg: Type.Optional(Type.Array(Type.String())),
110
130
  count: Type.Optional(Type.Array(Type.String())),
111
131
  }),
112
- filters: Type.Optional(Type.Partial(Type.Object(joinFilterFields))),
132
+ filters: Type.Optional(filtersObject(joinFilterFields)),
113
133
  conditions: Type.Optional(Type.Array(conditionItemSchema)),
114
134
  });
115
135
  }
@@ -1,5 +1,5 @@
1
1
  import { ConditionBuilder, type ConditionValueOrUndefined } from 'node-condition-builder';
2
- import type { TSchema, TObject } from '@sinclair/typebox';
2
+ import { type TSchema, type TObject } from '@sinclair/typebox';
3
3
  import type { SchemaDefinition, JoinDefinition, TableFilterFn, ITable } from '../types.js';
4
4
  type ExtraProps<EF> = EF extends TObject<infer P> ? P : EF extends Record<string, TSchema> ? EF : Record<string, never>;
5
5
  export declare function exportTableInfo<F extends Record<string, TSchema>, EF extends TObject | Record<string, TSchema> = Record<never, TSchema>>(Schema: SchemaDefinition<F>, extraFilters?: EF, extendedCondition?: (condition: ConditionBuilder, filters: {
@@ -14,6 +14,21 @@ export interface BuildRelationOptions {
14
14
  alias?: string;
15
15
  selection?: string;
16
16
  unique?: boolean;
17
+ /**
18
+ * Allowlist of the target table's fields reachable through this relation.
19
+ *
20
+ * The relation is declared against a schema narrowed to these fields, so every read
21
+ * surface — `selection`, `filters`, `conditions`, `orderBy`, aggregations, and the
22
+ * generated request/response schemas — rejects anything outside the list with
23
+ * `400 Unknown field`. Use it to expose a table broadly while keeping some of its
24
+ * columns out of one relation, without hiding them from the table's own routes the way
25
+ * `readExclude` would.
26
+ *
27
+ * Fail-closed: a column added to the table later is not reachable through the relation
28
+ * until it is added here. Must include the join field, and is not allowed on a write
29
+ * join.
30
+ */
31
+ fields?: string[];
17
32
  }
18
33
  export declare function buildRelation<M extends Record<string, TSchema>, J extends Record<string, TSchema>>(mainSchema: SchemaDefinition<M>, mainField: string & keyof M | (string & keyof M)[], joinSchema: SchemaDefinition<J>, joinField: string & keyof J, options?: BuildRelationOptions): JoinDefinition;
19
34
  export declare function buildUpsertRule<F extends Record<string, TSchema>>(schema: SchemaDefinition<F>, columns: (string & keyof F)[]): [SchemaDefinition, string[]];
@@ -1 +1 @@
1
- {"version":3,"file":"table-helpers.d.ts","sourceRoot":"","sources":["../../src/lib/table-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,yBAAyB,EAAiC,MAAM,wBAAwB,CAAC;AACzH,OAAO,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAC1D,OAAO,KAAK,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,MAAM,EACP,MAAM,aAAa,CAAC;AAarB,KAAK,UAAU,CAAC,EAAE,IAAI,EAAE,SAAS,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAExH,wBAAgB,eAAe,CAC7B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,EAAE,SAAS,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,EAErE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,YAAY,GAAE,EAAa,EAC3B,iBAAiB,CAAC,EAAE,CAClB,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,yBAAyB;CAAE,KAC3E,IAAI,GACR;IAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAAC,OAAO,EAAE,aAAa,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAsChG;AAED,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAChB,MAAM,CAAC,CAAC,CAAC,CAMX;AAkED,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,wBAAgB,aAAa,CAC3B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAEjC,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,EAClD,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,EAC3B,OAAO,CAAC,EAAE,oBAAoB,GAC7B,cAAc,CAShB;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,OAAO,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,GAC5B,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAE9B;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,KAAK,EAAE,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,EAAE,GACvC,GAAG,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAEjC"}
1
+ {"version":3,"file":"table-helpers.d.ts","sourceRoot":"","sources":["../../src/lib/table-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,yBAAyB,EAAiC,MAAM,wBAAwB,CAAC;AACzH,OAAO,EAAQ,KAAK,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,MAAM,EACP,MAAM,aAAa,CAAC;AAarB,KAAK,UAAU,CAAC,EAAE,IAAI,EAAE,SAAS,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAExH,wBAAgB,eAAe,CAC7B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,EAAE,SAAS,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,EAErE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,YAAY,GAAE,EAAa,EAC3B,iBAAiB,CAAC,EAAE,CAClB,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,yBAAyB;CAAE,KAC3E,IAAI,GACR;IAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAAC,OAAO,EAAE,aAAa,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAsChG;AAED,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAChB,MAAM,CAAC,CAAC,CAAC,CAOX;AAqFD,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AAgDD,wBAAgB,aAAa,CAC3B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAEjC,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,EAClD,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,EAC3B,OAAO,CAAC,EAAE,oBAAoB,GAC7B,cAAc,CAWhB;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,OAAO,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,GAC5B,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAE9B;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,KAAK,EAAE,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,EAAE,GACvC,GAAG,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAEjC"}
@@ -1,4 +1,5 @@
1
1
  import { ConditionBuilder } from 'node-condition-builder';
2
+ import { Type } from '@sinclair/typebox';
2
3
  import { getDialect } from './dialect.js';
3
4
  // Quote an identifier using the given ConditionBuilder dialect (or the global
4
5
  // default when not provided). Needed so that DB columns with uppercase letters
@@ -46,10 +47,27 @@ export function exportTableInfo(Schema, extraFilters = {}, extendedCondition) {
46
47
  export function defineTable(config) {
47
48
  validateAliasUniqueness(config.allowedReadJoins, 'allowedReadJoins');
48
49
  validateAliasUniqueness(config.allowedWriteJoins, 'allowedWriteJoins');
50
+ validateWriteJoinsUnrestricted(config.allowedWriteJoins);
49
51
  validateComputedFields(config);
50
52
  validateReadExclude(config);
51
53
  return config;
52
54
  }
55
+ /**
56
+ * `fields` narrows a relation for reading; it has no meaning on a write join and would be
57
+ * actively harmful there. Write paths resolve the secondary's upsert rule by looking the
58
+ * schema object up in `upsertMap` **by identity**, and a narrowed relation carries a copy —
59
+ * the lookup would miss and the upsert would silently degrade to a plain insert. The
60
+ * narrowed schema would also drop columns the caller legitimately sends.
61
+ */
62
+ function validateWriteJoinsUnrestricted(joins) {
63
+ for (const join of joins ?? []) {
64
+ if (join.fields) {
65
+ throw new Error(`defineTable: relation '${join.alias}' in allowedWriteJoins declares 'fields'. ` +
66
+ `The fields allowlist restricts reading only — remove it, and use a separate ` +
67
+ `buildRelation for the read side if that relation is also in allowedReadJoins.`);
68
+ }
69
+ }
70
+ }
53
71
  function validateReadExclude(config) {
54
72
  const excluded = config.readExclude;
55
73
  if (!excluded?.length)
@@ -97,14 +115,50 @@ function validateAliasUniqueness(joins, label) {
97
115
  seen.add(j.alias);
98
116
  }
99
117
  }
118
+ /**
119
+ * Copy of `schema` exposing only `fields`, in schema declaration order.
120
+ *
121
+ * `tableName`, `col` and `colMap` are carried over untouched: the engine resolves the
122
+ * target's configuration through `dbTables[joinSchema.tableName]`, so a narrowed relation
123
+ * must still answer to its real table name, and column mapping must keep working for the
124
+ * fields that survive.
125
+ */
126
+ function narrowSchemaToFields(schema, fields, joinField) {
127
+ const declared = Object.keys(schema.fields);
128
+ for (const field of fields) {
129
+ if (!declared.includes(field)) {
130
+ throw new Error(`buildRelation: 'fields' entry '${field}' is not a field of schema ` +
131
+ `'${schema.tableName}'. Available: ${declared.join(', ')}.`);
132
+ }
133
+ }
134
+ // The join field is what ties the fetched rows back to the main result set. Dropping it
135
+ // from the projection would hand the caller rows it cannot match to anything.
136
+ if (!fields.includes(joinField)) {
137
+ throw new Error(`buildRelation: 'fields' must include the join field '${joinField}' on ` +
138
+ `'${schema.tableName}' — it is what correlates the joined rows with the main ones.`);
139
+ }
140
+ const picked = {};
141
+ for (const field of declared) {
142
+ if (fields.includes(field))
143
+ picked[field] = schema.fields[field];
144
+ }
145
+ return {
146
+ ...schema,
147
+ fields: picked,
148
+ validation: Type.Object(picked),
149
+ partialValidation: Type.Partial(Type.Object(picked)),
150
+ };
151
+ }
100
152
  export function buildRelation(mainSchema, mainField, joinSchema, joinField, options) {
153
+ const fields = options?.fields;
101
154
  return {
102
- joinSchema,
155
+ joinSchema: fields ? narrowSchemaToFields(joinSchema, fields, joinField) : joinSchema,
103
156
  joinField,
104
157
  mainField,
105
158
  alias: options?.alias ?? joinSchema.tableName,
106
159
  selection: options?.selection ?? '*',
107
160
  unique: options?.unique ?? false,
161
+ ...(fields ? { fields: [...fields] } : {}),
108
162
  };
109
163
  }
110
164
  export function buildUpsertRule(schema, columns) {
@@ -4,6 +4,12 @@ export interface SqlApiConfig {
4
4
  schema?: string;
5
5
  dialect?: DialectName;
6
6
  envFile?: string;
7
+ /**
8
+ * Tables to skip during schema generation (`sqlapi-generate-schema`).
9
+ * Matched against the DB table name; `*` is a wildcard (e.g. "knex_*").
10
+ * Excluded schemas already on disk are removed as orphans on the next full run.
11
+ */
12
+ excludeTables?: string[];
7
13
  }
8
14
  export interface ColumnInfo {
9
15
  table_name: string;
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/types/cli.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,yFAAyF;IACzF,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,wFAAwF;IACxF,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED,MAAM,WAAW,QAAQ;IACvB,CAAC,UAAU,EAAE,MAAM,GAAG;QACpB,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC/B,iFAAiF;QACjF,OAAO,EAAE,MAAM,EAAE,CAAC;KACnB,CAAC;CACH"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/types/cli.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,yFAAyF;IACzF,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,wFAAwF;IACxF,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED,MAAM,WAAW,QAAQ;IACvB,CAAC,UAAU,EAAE,MAAM,GAAG;QACpB,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC/B,iFAAiF;QACjF,OAAO,EAAE,MAAM,EAAE,CAAC;KACnB,CAAC;CACH"}
@@ -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"}
@@ -0,0 +1,47 @@
1
+ # 0009. Schema exclusion is a config blacklist — excluded schemas are removed as orphans
2
+
3
+ - **Status**: accepted
4
+ - **Date**: 2026-07-27
5
+
6
+ ## Context
7
+
8
+ `sqlapi-generate-schema` regenerates every table by default (ADR 0007: schemas are
9
+ disposable mirrors). There was no way to keep unwanted tables — migration bookkeeping
10
+ (`knex_migrations*`), PostGIS internals (`spatial_ref_sys`), vendor tables — out of the
11
+ generated schemas short of deleting the files after every run.
12
+
13
+ ## Decision
14
+
15
+ A blacklist lives in the existing config file: `excludeTables: string[]` in
16
+ `sqlapi.config.ts`, matched against the DB table name, with `*` as the only wildcard.
17
+
18
+ Excluded tables are **invisible to the generator**: their schemas are not generated, and a
19
+ previously generated `Schema*.ts` for an excluded table is removed by the orphan cleanup
20
+ on the next full run — exactly as if the table had been dropped. This keeps the ADR 0007
21
+ invariant intact: everything under `schemas/` is a disposable mirror; excluding a table
22
+ must not turn its schema file into a hand-maintained exception.
23
+
24
+ This does not contradict ADR 0007's rejection of a skip-list for *tables*: there, explicit
25
+ invocation solves exclusion for free. For schemas the default is "all", so a blacklist is
26
+ the only way to exclude — and the same ADR is why `excludeTables` does **not** apply to
27
+ `sqlapi-generate-tables`.
28
+
29
+ ## Alternatives considered
30
+
31
+ - **`.schemaignore` file next to the schemas** — rejected: a second config format and a
32
+ second source of truth to parse and document, when a typed config file already exists.
33
+ - **CLI-only `--exclude` flag** — rejected as the primary mechanism: the exclusion is a
34
+ durable property of the project, not of one invocation; forgetting the flag once would
35
+ resurrect the unwanted schemas.
36
+ - **Keeping excluded schema files on disk (generator "never touches" them)** — rejected:
37
+ it would silently support hand-maintained files under `schemas/`, breaking the "always
38
+ safe to regenerate everything" property of ADR 0007.
39
+
40
+ ## Consequences
41
+
42
+ - Adding a table to `excludeTables` deletes its generated schema file on the next full
43
+ run. This is intended: excluded means gone, not frozen.
44
+ - Hand-written schemas do not belong in `schemas/`; exclusion is not a mechanism to
45
+ protect local edits.
46
+ - An invalid `excludeTables` value (not an array of strings) fails the run with an
47
+ explicit error rather than being silently ignored.