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,11 +1,6 @@
1
1
  import type { FastifyRequest } from 'fastify';
2
2
  import type { QueryClient } from '../db.js';
3
3
  import type { ITable, BulkValidatorItem } from '../../types.js';
4
- export interface ValidationResponseField {
5
- path: string;
6
- code: string;
7
- message: string;
8
- }
9
4
  export declare function runValidation(db: QueryClient, request: FastifyRequest, tableConf: ITable, main: Record<string, unknown>, secondaries?: Record<string, Record<string, unknown>[]>): Promise<void>;
10
5
  export declare function runBulkValidation(db: QueryClient, request: FastifyRequest, tableConf: ITable, items: BulkValidatorItem[]): Promise<void>;
11
6
  //# sourceMappingURL=validate.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../../src/lib/engine/validate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAC9C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAC5C,OAAO,KAAK,EAAE,MAAM,EAAmB,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAEjF,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAUD,wBAAsB,aAAa,CACjC,EAAE,EAAE,WAAW,EACf,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GACtD,OAAO,CAAC,IAAI,CAAC,CAOf;AAED,wBAAsB,iBAAiB,CACrC,EAAE,EAAE,WAAW,EACf,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,iBAAiB,EAAE,GACzB,OAAO,CAAC,IAAI,CAAC,CAOf"}
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../../src/lib/engine/validate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAC9C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAC5C,OAAO,KAAK,EAAE,MAAM,EAAmB,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAgBjF,wBAAsB,aAAa,CACjC,EAAE,EAAE,WAAW,EACf,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GACtD,OAAO,CAAC,IAAI,CAAC,CAOf;AAED,wBAAsB,iBAAiB,CACrC,EAAE,EAAE,WAAW,EACf,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,iBAAiB,EAAE,GACzB,OAAO,CAAC,IAAI,CAAC,CAOf"}
@@ -1,22 +1,19 @@
1
1
  import type { FastifyRequest } from 'fastify';
2
2
  import type { QueryClient } from '../db.js';
3
- import type { ITable, DbTables, JoinDefinition } from '../../types.js';
4
- export declare function findWriteJoin(tableConf: ITable, alias: string): JoinDefinition | undefined;
5
- /**
6
- * Drop `excludeFromCreation` columns from a snake_case (DB-format) record.
7
- *
8
- * Exclusion is a whitelist on CLIENT input: call this on the client payload only,
9
- * BEFORE any server-side value is assigned (beforeInsert mutations, FK auto-fill),
10
- * so engine/hook-generated values on those fields reach the SQL.
11
- */
12
- export declare function removeExcludedFields(record: Record<string, unknown>, tableConf: ITable): Record<string, unknown>;
3
+ import type { ITable, DbTables, TenantContext } from '../../types.js';
4
+ export declare function findSecondaryTableConf(dbTables: DbTables, joinTableName: string): ITable | undefined;
13
5
  /**
14
- * Same whitelist as `removeExcludedFields`, but for a camelCase record (schema
15
- * field names) and in place. Used on the client payload before the beforeInsert
16
- * hook runs.
6
+ * The context a per-alias write pass runs in. Shared by `processSecondaries` and
7
+ * `processDeletions`, which differ only in the payload they are handed.
17
8
  */
18
- export declare function removeExcludedFieldsCamel(record: Record<string, unknown>, tableConf: ITable): void;
19
- export declare function findSecondaryTableConf(dbTables: DbTables, joinTableName: string): ITable | undefined;
9
+ export interface WriteJoinPass {
10
+ db: QueryClient;
11
+ tableConf: ITable;
12
+ dbTables: DbTables;
13
+ /** Main record (camelCase), the source of the FK auto-filled into every child row. */
14
+ mainRecord: Record<string, unknown>;
15
+ tenant?: TenantContext;
16
+ }
20
17
  interface PrepareCtx {
21
18
  db: QueryClient;
22
19
  tableConf: ITable;
@@ -43,7 +40,15 @@ export declare function prepareInsertRecord(ctx: PrepareCtx, input: Record<strin
43
40
  camel: Record<string, unknown>;
44
41
  snake: Record<string, unknown>;
45
42
  }>;
46
- export declare function processSecondaries(db: QueryClient, tableConf: ITable, dbTables: DbTables, mainRecord: Record<string, unknown>, secondaries: Record<string, Record<string, unknown>[]>): Promise<Record<string, Record<string, unknown>[]>>;
43
+ /**
44
+ * Write the child rows a request sent alongside the main record, one bulk statement per alias.
45
+ *
46
+ * A secondary is a write like any other: the child table's own `tenantScope` is enforced on it
47
+ * (`tenantForTable` re-targets the caller's tenant onto that table's scope). Without this the
48
+ * client picks the child's tenant value, which lets it plant rows inside another tenant — and,
49
+ * where the child is upserted, hijack one of that tenant's existing rows via the conflict key.
50
+ */
51
+ export declare function processSecondaries(pass: WriteJoinPass, secondaries: Record<string, Record<string, unknown>[]>): Promise<Record<string, Record<string, unknown>[]>>;
47
52
  /**
48
53
  * Process per-alias deletion requests for a write join.
49
54
  *
@@ -53,7 +58,10 @@ export declare function processSecondaries(db: QueryClient, tableConf: ITable, d
53
58
  * `DELETE FROM child WHERE id = 5 AND fk_to_main = <main.id>` — both ergonomic
54
59
  * (no need to repeat the FK) and safer (cannot accidentally delete a row that
55
60
  * doesn't belong to this main).
61
+ *
62
+ * The FK alone is not isolation: a child row owned by another tenant may still point at a main
63
+ * row the caller owns, so the child table's own `tenantScope` is added to the DELETE.
56
64
  */
57
- export declare function processDeletions(db: QueryClient, tableConf: ITable, mainRecord: Record<string, unknown>, deletions: Record<string, Record<string, unknown>[]>): Promise<Record<string, Record<string, unknown>[]>>;
65
+ export declare function processDeletions(pass: WriteJoinPass, deletions: Record<string, Record<string, unknown>[]>): Promise<Record<string, Record<string, unknown>[]>>;
58
66
  export {};
59
67
  //# sourceMappingURL=write-helpers.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"write-helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/engine/write-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE9C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAE5C,OAAO,KAAK,EACV,MAAM,EACN,QAAQ,EACR,cAAc,EAEf,MAAM,gBAAgB,CAAC;AAExB,wBAAgB,aAAa,CAC3B,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,GACZ,cAAc,GAAG,SAAS,CAE5B;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,GAChB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQzB;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,GAChB,IAAI,CAKN;AAED,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,QAAQ,EAClB,aAAa,EAAE,MAAM,GACpB,MAAM,GAAG,SAAS,CAKpB;AAED,UAAU,UAAU;IAClB,EAAE,EAAE,WAAW,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,cAAc,CAAC;CACzB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,UAAU,EACf,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,EACvD,OAAO,CAAC,EAAE;IAAE,YAAY,CAAC,EAAE,OAAO,CAAA;CAAE,GACnC,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAC,CAe7E;AAED,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,WAAW,EACf,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,QAAQ,EAClB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACnC,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GACrD,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CA4DpD;AAED;;;;;;;;;GASG;AACH,wBAAsB,gBAAgB,CACpC,EAAE,EAAE,WAAW,EACf,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACnC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GACnD,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CA+BpD"}
1
+ {"version":3,"file":"write-helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/engine/write-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE9C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAQ5C,OAAO,KAAK,EACV,MAAM,EACN,QAAQ,EAGR,aAAa,EACd,MAAM,gBAAgB,CAAC;AAqDxB,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,QAAQ,EAClB,aAAa,EAAE,MAAM,GACpB,MAAM,GAAG,SAAS,CAKpB;AAED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,WAAW,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,QAAQ,CAAC;IACnB,sFAAsF;IACtF,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpC,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB;AAmCD,UAAU,UAAU;IAClB,EAAE,EAAE,WAAW,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,cAAc,CAAC;CACzB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,UAAU,EACf,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,EACvD,OAAO,CAAC,EAAE;IAAE,YAAY,CAAC,EAAE,OAAO,CAAA;CAAE,GACnC,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAC,CAe7E;AAkCD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,aAAa,EACnB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GACrD,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CAiDpD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,aAAa,EACnB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GACnD,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CAoBpD"}
@@ -1,6 +1,16 @@
1
1
  import { camelcaseObject, snakecaseRecord } from '../naming.js';
2
2
  import { runValidation } from './validate.js';
3
- export function findWriteJoin(tableConf, alias) {
3
+ import { tenantForTable, buildTenantRowGuard, enforceTenantOnWrites, assertTenantOwnsConflicts, } from '../tenant.js';
4
+ /**
5
+ * Resolve the write join an alias names, or undefined when the table does not declare it.
6
+ *
7
+ * An undeclared alias is skipped rather than rejected: a table with no `allowedWriteJoins`
8
+ * ignores any secondaries sent to it, which is the tested contract (see the "ignores
9
+ * secondaries/deletions not in allowedWriteJoins" specs). The HTTP boundary is what fails
10
+ * closed — the generated body schema declares `additionalProperties:false` on the alias
11
+ * container, so a request naming an undeclared alias is a 400 before it reaches here.
12
+ */
13
+ function findWriteJoin(tableConf, alias) {
4
14
  return tableConf.allowedWriteJoins?.find((j) => j.alias === alias);
5
15
  }
6
16
  /**
@@ -10,7 +20,7 @@ export function findWriteJoin(tableConf, alias) {
10
20
  * BEFORE any server-side value is assigned (beforeInsert mutations, FK auto-fill),
11
21
  * so engine/hook-generated values on those fields reach the SQL.
12
22
  */
13
- export function removeExcludedFields(record, tableConf) {
23
+ function removeExcludedFields(record, tableConf) {
14
24
  if (!tableConf.excludeFromCreation?.length)
15
25
  return record;
16
26
  const result = { ...record };
@@ -25,7 +35,7 @@ export function removeExcludedFields(record, tableConf) {
25
35
  * field names) and in place. Used on the client payload before the beforeInsert
26
36
  * hook runs.
27
37
  */
28
- export function removeExcludedFieldsCamel(record, tableConf) {
38
+ function removeExcludedFieldsCamel(record, tableConf) {
29
39
  if (!tableConf.excludeFromCreation?.length)
30
40
  return;
31
41
  for (const field of tableConf.excludeFromCreation) {
@@ -39,6 +49,21 @@ export function findSecondaryTableConf(dbTables, joinTableName) {
39
49
  }
40
50
  return undefined;
41
51
  }
52
+ /**
53
+ * Resolve one write-join alias: where the child's FK lives, what value it takes, and which
54
+ * tenant scope applies to the child table.
55
+ */
56
+ function resolveWriteJoinTarget(joinDef, dbTables, mainRecord, tenant) {
57
+ const { joinSchema, joinField, mainField } = joinDef;
58
+ const childConf = findSecondaryTableConf(dbTables, joinSchema.tableName);
59
+ return {
60
+ joinSchema,
61
+ joinCol: joinSchema.col(joinField),
62
+ mainValue: Array.isArray(mainField) ? mainRecord[mainField[0]] : mainRecord[mainField],
63
+ childConf,
64
+ childTenant: tenantForTable(tenant, childConf),
65
+ };
66
+ }
42
67
  /**
43
68
  * Prepare a single record for INSERT/UPSERT:
44
69
  * 1. shallow camelCase copy of `input`
@@ -66,47 +91,70 @@ export async function prepareInsertRecord(ctx, input, secondaries, options) {
66
91
  const snake = snakecaseRecord(camel, ctx.tableConf.Schema);
67
92
  return { camel, snake };
68
93
  }
69
- export async function processSecondaries(db, tableConf, dbTables, mainRecord, secondaries) {
94
+ /**
95
+ * Run `writeAlias` once per alias of a write-join payload, handing it the resolved target for
96
+ * that alias and collecting the rows it reports back.
97
+ *
98
+ * Empty lists are a no-op, and an alias the table does not declare as a write join is skipped:
99
+ * rejecting it is the request schema's job (`additionalProperties:false` on the alias
100
+ * container), because a table declaring no write joins must keep ignoring what it is sent.
101
+ */
102
+ async function forEachWriteJoin(pass, payload, writeAlias) {
70
103
  const results = {};
71
- for (const [alias, records] of Object.entries(secondaries)) {
104
+ for (const [alias, records] of Object.entries(payload)) {
72
105
  if (!records?.length)
73
106
  continue;
74
- const joinDef = findWriteJoin(tableConf, alias);
107
+ const joinDef = findWriteJoin(pass.tableConf, alias);
75
108
  if (!joinDef)
76
109
  continue;
77
- const { joinSchema, joinField, mainField } = joinDef;
78
- const joinCol = joinSchema.col(joinField);
79
- const secondaryTableConf = findSecondaryTableConf(dbTables, joinSchema.tableName);
80
- const secondaryPk = secondaryTableConf?.primary || joinField;
81
- const secondaryPkCol = Array.isArray(secondaryPk)
82
- ? secondaryPk.map((f) => joinSchema.col(f))
83
- : joinSchema.col(secondaryPk);
110
+ const target = resolveWriteJoinTarget(joinDef, pass.dbTables, pass.mainRecord, pass.tenant);
111
+ results[alias] = await writeAlias(records, target, joinDef);
112
+ }
113
+ return results;
114
+ }
115
+ /**
116
+ * Write the child rows a request sent alongside the main record, one bulk statement per alias.
117
+ *
118
+ * A secondary is a write like any other: the child table's own `tenantScope` is enforced on it
119
+ * (`tenantForTable` re-targets the caller's tenant onto that table's scope). Without this the
120
+ * client picks the child's tenant value, which lets it plant rows inside another tenant — and,
121
+ * where the child is upserted, hijack one of that tenant's existing rows via the conflict key.
122
+ */
123
+ export function processSecondaries(pass, secondaries) {
124
+ const { db, tableConf } = pass;
125
+ return forEachWriteJoin(pass, secondaries, async (records, target, joinDef) => {
126
+ const { joinSchema, joinCol, mainValue, childConf, childTenant } = target;
127
+ const childPk = childConf?.primary || joinDef.joinField;
128
+ const childPkCol = Array.isArray(childPk)
129
+ ? childPk.map((f) => joinSchema.col(f))
130
+ : joinSchema.col(childPk);
84
131
  const preparedRecords = records.map((rec) => {
85
132
  let prepared = snakecaseRecord(rec, joinSchema);
86
133
  // Remove excluded fields from the client payload BEFORE the FK auto-fill:
87
134
  // an excluded FK column must not strip the engine-injected value below.
88
- if (secondaryTableConf) {
89
- prepared = removeExcludedFields(prepared, secondaryTableConf);
135
+ if (childConf) {
136
+ prepared = removeExcludedFields(prepared, childConf);
90
137
  }
91
- // Auto-fill FK from main record
92
- const mainValue = Array.isArray(mainField)
93
- ? mainRecord[mainField[0]]
94
- : mainRecord[mainField];
95
138
  prepared[joinCol] = mainValue;
96
139
  return prepared;
97
140
  });
98
- const secondaryUpsertKeys = tableConf.upsertMap?.get(joinSchema);
141
+ // Tenant enforcement runs after the FK auto-fill: for an indirect scope the through-FK
142
+ // the engine just injected is the value being validated.
143
+ await enforceTenantOnWrites(db, childTenant, preparedRecords);
144
+ const upsertKeys = tableConf.upsertMap?.get(joinSchema);
99
145
  let pkRows;
100
- if (secondaryUpsertKeys) {
101
- const conflictCols = secondaryUpsertKeys.map((k) => joinSchema.col(k));
102
- pkRows = await db.bulkInsertOrUpdate(joinSchema.tableName, preparedRecords, conflictCols, secondaryPkCol);
146
+ if (upsertKeys) {
147
+ const conflictCols = upsertKeys.map((k) => joinSchema.col(k));
148
+ // A conflicting upsert must not overwrite — or re-parent — a child row owned by
149
+ // another tenant, which the ON CONFLICT key alone would happily match.
150
+ await assertTenantOwnsConflicts(db, childTenant, joinSchema.tableName, conflictCols, preparedRecords);
151
+ pkRows = await db.bulkInsertOrUpdate(joinSchema.tableName, preparedRecords, conflictCols, childPkCol);
103
152
  }
104
153
  else {
105
- pkRows = await db.bulkInsert(joinSchema.tableName, preparedRecords, secondaryPkCol);
154
+ pkRows = await db.bulkInsert(joinSchema.tableName, preparedRecords, childPkCol);
106
155
  }
107
- results[alias] = pkRows.map((r) => camelcaseObject(r, joinSchema));
108
- }
109
- return results;
156
+ return pkRows.map((r) => camelcaseObject(r, joinSchema));
157
+ });
110
158
  }
111
159
  /**
112
160
  * Process per-alias deletion requests for a write join.
@@ -117,31 +165,25 @@ export async function processSecondaries(db, tableConf, dbTables, mainRecord, se
117
165
  * `DELETE FROM child WHERE id = 5 AND fk_to_main = <main.id>` — both ergonomic
118
166
  * (no need to repeat the FK) and safer (cannot accidentally delete a row that
119
167
  * doesn't belong to this main).
168
+ *
169
+ * The FK alone is not isolation: a child row owned by another tenant may still point at a main
170
+ * row the caller owns, so the child table's own `tenantScope` is added to the DELETE.
120
171
  */
121
- export async function processDeletions(db, tableConf, mainRecord, deletions) {
122
- const results = {};
123
- for (const [alias, records] of Object.entries(deletions)) {
124
- if (!records?.length)
125
- continue;
126
- const joinDef = findWriteJoin(tableConf, alias);
127
- if (!joinDef)
128
- continue;
129
- const { joinSchema, joinField, mainField } = joinDef;
130
- const joinCol = joinSchema.col(joinField);
131
- const mainValue = Array.isArray(mainField)
132
- ? mainRecord[mainField[0]]
133
- : mainRecord[mainField];
172
+ export function processDeletions(pass, deletions) {
173
+ const { db } = pass;
174
+ return forEachWriteJoin(pass, deletions, async (records, target) => {
175
+ const { joinSchema, joinCol, mainValue, childTenant } = target;
176
+ const tenantGuard = buildTenantRowGuard(db, childTenant, joinSchema.tableName);
134
177
  const deletedRows = [];
135
178
  for (const rec of records) {
136
179
  const snaked = snakecaseRecord(rec, joinSchema);
137
180
  // Auto-inject FK to main: scopes the DELETE to children owned by this main.
138
181
  snaked[joinCol] = mainValue;
139
- const affectedRows = await db.delete(joinSchema.tableName, snaked);
182
+ const affectedRows = await db.delete(joinSchema.tableName, snaked, tenantGuard);
140
183
  if (affectedRows > 0) {
141
184
  deletedRows.push(camelcaseObject(snaked, joinSchema));
142
185
  }
143
186
  }
144
- results[alias] = deletedRows;
145
- }
146
- return results;
187
+ return deletedRows;
188
+ });
147
189
  }
@@ -9,7 +9,18 @@ import type { ITable, SchemaDefinition } from '../types.js';
9
9
  * by bisection. Write paths (insert/update/upsert) are deliberately unaffected —
10
10
  * a field can be writable but never readable (e.g. a password hash).
11
11
  */
12
- export declare function isReadExcluded(tableConf: ITable | undefined, field: string): boolean;
12
+ /**
13
+ * Own-property membership for a field map.
14
+ *
15
+ * `key in map` and `map[key]` both walk `Object.prototype`, so `constructor`, `toString`,
16
+ * `valueOf`, `hasOwnProperty` and `__proto__` satisfy every "is this a known field" check and
17
+ * reach the SQL builders, where they render as `undefined` or a column that does not exist.
18
+ * Every allowlist below — and every field lookup in the search engine — goes through these two
19
+ * helpers so an inherited name is simply unknown.
20
+ */
21
+ export declare function hasOwnField(map: Record<string, unknown> | undefined, key: string): boolean;
22
+ /** The value at `key`, but only when it is the map's own property. */
23
+ export declare function ownField<T>(map: Record<string, T> | undefined, key: string): T | undefined;
13
24
  /** Throws 400 when `field` is read-excluded on `tableConf`. */
14
25
  export declare function assertReadable(tableConf: ITable | undefined, field: string): void;
15
26
  /**
@@ -17,6 +28,26 @@ export declare function assertReadable(tableConf: ITable | undefined, field: str
17
28
  * excluded pass through untouched (extraFilters and computed names included).
18
29
  */
19
30
  export declare function assertFiltersReadable(filters: Record<string, unknown> | undefined, tableConf: ITable | undefined): void;
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 declare function assertKnownFilterKeys(filters: Record<string, unknown> | undefined, schema: SchemaDefinition, tableConf: ITable | undefined, allowExtraFilters?: boolean): void;
20
51
  /** Schema field names that may be read, in schema declaration order. */
21
52
  export declare function readableFieldNames(tableConf: ITable | undefined, schema: SchemaDefinition): string[];
22
53
  /**
@@ -28,4 +59,9 @@ export declare function readableFieldNames(tableConf: ITable | undefined, schema
28
59
  * would be ambiguous.
29
60
  */
30
61
  export declare function readableSelectColumns(tableConf: ITable | undefined, schema: SchemaDefinition, db: QueryClient): string | undefined;
62
+ /**
63
+ * The same list, always spelled out. Used where `*` is not an acceptable fallback: a
64
+ * relation narrowed by a `fields` allowlist must project the allowlist, not the table.
65
+ */
66
+ export declare function explicitSelectColumns(tableConf: ITable | undefined, schema: SchemaDefinition, db: QueryClient): string;
31
67
  //# 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;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CACzB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EACxC,GAAG,EAAE,MAAM,GACV,OAAO,CAET;AAED,sEAAsE;AACtE,wBAAgB,QAAQ,CAAC,CAAC,EACxB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,GAAG,SAAS,EAClC,GAAG,EAAE,MAAM,GACV,CAAC,GAAG,SAAS,CAEf;AAMD,+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"}
@@ -8,7 +8,23 @@ import { httpError } from './errors.js';
8
8
  * by bisection. Write paths (insert/update/upsert) are deliberately unaffected —
9
9
  * a field can be writable but never readable (e.g. a password hash).
10
10
  */
11
- export function isReadExcluded(tableConf, field) {
11
+ /**
12
+ * Own-property membership for a field map.
13
+ *
14
+ * `key in map` and `map[key]` both walk `Object.prototype`, so `constructor`, `toString`,
15
+ * `valueOf`, `hasOwnProperty` and `__proto__` satisfy every "is this a known field" check and
16
+ * reach the SQL builders, where they render as `undefined` or a column that does not exist.
17
+ * Every allowlist below — and every field lookup in the search engine — goes through these two
18
+ * helpers so an inherited name is simply unknown.
19
+ */
20
+ export function hasOwnField(map, key) {
21
+ return map !== undefined && Object.hasOwn(map, key);
22
+ }
23
+ /** The value at `key`, but only when it is the map's own property. */
24
+ export function ownField(map, key) {
25
+ return map !== undefined && Object.hasOwn(map, key) ? map[key] : undefined;
26
+ }
27
+ function isReadExcluded(tableConf, field) {
12
28
  return tableConf?.readExclude?.includes(field) ?? false;
13
29
  }
14
30
  /** Throws 400 when `field` is read-excluded on `tableConf`. */
@@ -28,6 +44,46 @@ export function assertFiltersReadable(filters, tableConf) {
28
44
  assertReadable(tableConf, key);
29
45
  }
30
46
  }
47
+ /**
48
+ * Throws 400 when a filter map carries a key the engine would never visit.
49
+ *
50
+ * Filters are matched against the schema fields, the table's `extraFilters` and its computed
51
+ * fields; anything else used to be dropped without a word, so a mistyped key returned MORE
52
+ * rows than the caller asked for and nothing said so. That is the dangerous direction to fail
53
+ * in — filters are also how consumers narrow visibility — and it contradicted every
54
+ * neighbouring path, where an unknown field in `selection`, `conditions`, `orderBy` or an
55
+ * aggregation is already a 400.
56
+ *
57
+ * `schema` is the schema the relation was declared with, which for a join may be a trimmed
58
+ * copy: a field outside it is unknown here exactly as it is for `selection`.
59
+ *
60
+ * `allowExtraFilters` is false on the joinLeft path, whose condition is built inline
61
+ * (`buildLeftJoinClauses`) and never runs the table's `extendedCondition`.
62
+ *
63
+ * Call AFTER `assertFiltersReadable`, so a read-excluded field keeps its own message
64
+ * instead of being reported as unknown.
65
+ */
66
+ export function assertKnownFilterKeys(filters, schema, tableConf, allowExtraFilters = true) {
67
+ if (!filters)
68
+ return;
69
+ for (const [key, value] of Object.entries(filters)) {
70
+ // `undefined` means "filter not supplied" everywhere else in the engine; an absent
71
+ // filter cannot be a wrong one. `null` does filter (IS NULL) and is validated.
72
+ if (value === undefined)
73
+ continue;
74
+ if (hasOwnField(schema.fields, key))
75
+ continue;
76
+ if (ownField(tableConf?.computedFields, key))
77
+ continue;
78
+ if (hasOwnField(tableConf?.extraFilters, key)) {
79
+ if (allowExtraFilters)
80
+ continue;
81
+ throw httpError(400, `Filter '${key}' is an extraFilter: extraFilters are not applied on joinLeft ` +
82
+ `(only schema fields and computed fields are). Use joinMustExist on the same relation.`);
83
+ }
84
+ throw httpError(400, `Unknown filter field: ${key}`);
85
+ }
86
+ }
31
87
  /** Schema field names that may be read, in schema declaration order. */
32
88
  export function readableFieldNames(tableConf, schema) {
33
89
  const fields = Object.keys(schema.fields);
@@ -46,6 +102,13 @@ export function readableFieldNames(tableConf, schema) {
46
102
  export function readableSelectColumns(tableConf, schema, db) {
47
103
  if (!tableConf?.readExclude?.length)
48
104
  return undefined;
105
+ return explicitSelectColumns(tableConf, schema, db);
106
+ }
107
+ /**
108
+ * The same list, always spelled out. Used where `*` is not an acceptable fallback: a
109
+ * relation narrowed by a `fields` allowlist must project the allowlist, not the table.
110
+ */
111
+ export function explicitSelectColumns(tableConf, schema, db) {
49
112
  const table = db.qi(schema.tableName);
50
113
  return readableFieldNames(tableConf, schema)
51
114
  .map((f) => `${table}.${db.qi(schema.col(f))}`)
@@ -5,27 +5,12 @@ import type { ITable, SchemaDefinition, DbTables, JoinDefinition } from '../../t
5
5
  * Falls back to `fallback` field if tableConf is undefined.
6
6
  */
7
7
  export declare function pkSchema(tableConf: ITable | undefined, schema: SchemaDefinition, fallback: string): Record<string, TSchema>;
8
- /**
9
- * Build secondary table fields for body schemas:
10
- * - FK field becomes Optional (auto-filled from main)
11
- * - excludeFromCreation fields become Optional
12
- */
13
- export declare function buildSecondaryFields(joinSchema: SchemaDefinition, joinField: string, secondaryTableConf: ITable | undefined): Record<string, TSchema>;
14
8
  /**
15
9
  * Apply schemaOverrides to a fields record.
16
10
  * Replaces matching field schemas with the override.
17
11
  */
18
12
  export declare function applySchemaOverrides(fields: Record<string, TSchema>, tableConf: ITable): Record<string, TSchema>;
19
13
  type JoinFieldsBuilder = (join: JoinDefinition, secondaryConf: ITable | undefined) => Record<string, TSchema>;
20
- /**
21
- * Maps each writeJoin alias to a Type.Array(Type.Object(...)) using the provided fields builder.
22
- * When `itemsPartial` is true, wraps each item with Type.Partial — used for deletion sub-schemas
23
- * where every field is a matcher (the engine auto-injects the FK to main).
24
- */
25
- export declare function buildJoinAliasMap(tableConf: ITable, dbTables: DbTables, build: JoinFieldsBuilder, options?: {
26
- itemsPartial?: boolean;
27
- strictItems?: boolean;
28
- }): Record<string, TSchema>;
29
14
  /**
30
15
  * Attach `secondaries` (and optionally `deletions`) sections to the given target object,
31
16
  * iterating once over `tableConf.allowedWriteJoins`. Mutates `target` in-place.
@@ -1 +1 @@
1
- {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAGzF;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQ3H;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,gBAAgB,EAC5B,SAAS,EAAE,MAAM,EACjB,kBAAkB,EAAE,MAAM,GAAG,SAAS,GACrC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAgBzB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,GAChB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASzB;AAED,KAAK,iBAAiB,GAAG,CACvB,IAAI,EAAE,cAAc,EACpB,aAAa,EAAE,MAAM,GAAG,SAAS,KAC9B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAE7B;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,QAAQ,EAClB,KAAK,EAAE,iBAAiB,EACxB,OAAO,GAAE;IAAE,YAAY,CAAC,EAAE,OAAO,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,CAAA;CAAO,GAC9D,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAUzB;AAED;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE;IAAE,aAAa,EAAE,OAAO,CAAC;IAAC,eAAe,EAAE,iBAAiB,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,CAAA;CAAE,GAC7F,IAAI,CAmBN;AAED,qGAAqG;AACrG,eAAO,MAAM,mBAAmB,EAAE,iBACmB,CAAC;AAEtD,uDAAuD;AACvD,eAAO,MAAM,uBAAuB,EAAE,iBACG,CAAC"}
1
+ {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAGzF;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQ3H;AA6BD;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,GAChB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASzB;AAED,KAAK,iBAAiB,GAAG,CACvB,IAAI,EAAE,cAAc,EACpB,aAAa,EAAE,MAAM,GAAG,SAAS,KAC9B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAwB7B;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE;IAAE,aAAa,EAAE,OAAO,CAAC;IAAC,eAAe,EAAE,iBAAiB,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,CAAA;CAAE,GAC7F,IAAI,CA8BN;AAED,qGAAqG;AACrG,eAAO,MAAM,mBAAmB,EAAE,iBACmB,CAAC;AAEtD,uDAAuD;AACvD,eAAO,MAAM,uBAAuB,EAAE,iBACG,CAAC"}
@@ -18,7 +18,7 @@ export function pkSchema(tableConf, schema, fallback) {
18
18
  * - FK field becomes Optional (auto-filled from main)
19
19
  * - excludeFromCreation fields become Optional
20
20
  */
21
- export function buildSecondaryFields(joinSchema, joinField, secondaryTableConf) {
21
+ function buildSecondaryFields(joinSchema, joinField, secondaryTableConf) {
22
22
  const fields = { ...joinSchema.fields };
23
23
  if (joinField in fields) {
24
24
  fields[joinField] = Type.Optional(fields[joinField]);
@@ -52,7 +52,7 @@ export function applySchemaOverrides(fields, tableConf) {
52
52
  * When `itemsPartial` is true, wraps each item with Type.Partial — used for deletion sub-schemas
53
53
  * where every field is a matcher (the engine auto-injects the FK to main).
54
54
  */
55
- export function buildJoinAliasMap(tableConf, dbTables, build, options = {}) {
55
+ function buildJoinAliasMap(tableConf, dbTables, build, options = {}) {
56
56
  const out = {};
57
57
  if (!tableConf.allowedWriteJoins?.length)
58
58
  return out;
@@ -76,9 +76,12 @@ export function buildJoinAliasMap(tableConf, dbTables, build, options = {}) {
76
76
  export function attachWriteJoinSections(target, tableConf, dbTables, options) {
77
77
  if (!tableConf.allowedWriteJoins?.length)
78
78
  return;
79
- target.secondaries = Type.Optional(Type.Partial(Type.Object(buildJoinAliasMap(tableConf, dbTables, options.secondaryFields, { strictItems: options.strictItems }))));
79
+ // additionalProperties:false closes the alias container itself, not just the items: an alias
80
+ // the table does not declare must be a validation error, never a key that reaches the engine.
81
+ const aliasMap = { additionalProperties: false };
82
+ target.secondaries = Type.Optional(Type.Partial(Type.Object(buildJoinAliasMap(tableConf, dbTables, options.secondaryFields, { strictItems: options.strictItems }), aliasMap)));
80
83
  if (options.withDeletions) {
81
- target.deletions = Type.Optional(Type.Partial(Type.Object(buildJoinAliasMap(tableConf, dbTables, ({ joinSchema }) => joinSchema.fields, { itemsPartial: true }))));
84
+ target.deletions = Type.Optional(Type.Partial(Type.Object(buildJoinAliasMap(tableConf, dbTables, ({ joinSchema }) => joinSchema.fields, { itemsPartial: true }), aliasMap)));
82
85
  }
83
86
  }
84
87
  /** JoinFieldsBuilder for body schemas: includes all join fields with FK + excluded made Optional. */
@@ -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;AA+JzE,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAyClF;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,CAyClF"}