fastify-auto-sqlapi 0.2.0 → 0.2.2

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 (122) hide show
  1. package/AGENTS.md +50 -0
  2. package/AGENTS_BACKEND.md +312 -9
  3. package/AGENTS_FRONTEND.md +1 -1
  4. package/BREAKING_CHANGES.md +132 -0
  5. package/CHANGELOG.md +170 -0
  6. package/README.md +255 -877
  7. package/dist/bin/generate-schema.js +49 -2
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/lib/agent/manifest.d.ts +2 -0
  12. package/dist/lib/agent/manifest.d.ts.map +1 -1
  13. package/dist/lib/agent/manifest.js +4 -0
  14. package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
  15. package/dist/lib/cli/mysql-introspect.js +15 -5
  16. package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
  17. package/dist/lib/cli/pg-introspect.js +53 -14
  18. package/dist/lib/cli/schema-codegen.d.ts +2 -1
  19. package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
  20. package/dist/lib/cli/schema-codegen.js +17 -4
  21. package/dist/lib/cli/tables-codegen.d.ts +4 -0
  22. package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
  23. package/dist/lib/cli/tables-codegen.js +125 -27
  24. package/dist/lib/db.d.ts +6 -1
  25. package/dist/lib/db.d.ts.map +1 -1
  26. package/dist/lib/db.js +8 -2
  27. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  28. package/dist/lib/engine/bulk/bulk-upsert.js +6 -5
  29. package/dist/lib/engine/read-hooks.d.ts +19 -0
  30. package/dist/lib/engine/read-hooks.d.ts.map +1 -0
  31. package/dist/lib/engine/read-hooks.js +20 -0
  32. package/dist/lib/engine/rest/get.d.ts.map +1 -1
  33. package/dist/lib/engine/rest/get.js +10 -6
  34. package/dist/lib/engine/rest/insert.js +1 -1
  35. package/dist/lib/engine/rest/update.d.ts.map +1 -1
  36. package/dist/lib/engine/rest/update.js +15 -11
  37. package/dist/lib/engine/search/join-families.d.ts +11 -2
  38. package/dist/lib/engine/search/join-families.d.ts.map +1 -1
  39. package/dist/lib/engine/search/join-families.js +11 -13
  40. package/dist/lib/engine/search/search.d.ts.map +1 -1
  41. package/dist/lib/engine/search/search.js +14 -7
  42. package/dist/lib/engine/validate.d.ts +3 -3
  43. package/dist/lib/engine/validate.d.ts.map +1 -1
  44. package/dist/lib/engine/write-helpers.d.ts +7 -3
  45. package/dist/lib/engine/write-helpers.d.ts.map +1 -1
  46. package/dist/lib/engine/write-helpers.js +29 -30
  47. package/dist/lib/errors.d.ts +24 -0
  48. package/dist/lib/errors.d.ts.map +1 -1
  49. package/dist/lib/errors.js +59 -0
  50. package/dist/lib/schema/bulk-upsert.d.ts.map +1 -1
  51. package/dist/lib/schema/bulk-upsert.js +2 -1
  52. package/dist/lib/schema/get.d.ts +14 -0
  53. package/dist/lib/schema/get.d.ts.map +1 -0
  54. package/dist/lib/schema/get.js +18 -0
  55. package/dist/lib/schema/helpers.d.ts +21 -3
  56. package/dist/lib/schema/helpers.d.ts.map +1 -1
  57. package/dist/lib/schema/helpers.js +37 -5
  58. package/dist/lib/schema/insert.d.ts.map +1 -1
  59. package/dist/lib/schema/insert.js +7 -3
  60. package/dist/lib/schema/search.d.ts.map +1 -1
  61. package/dist/lib/schema/search.js +3 -11
  62. package/dist/lib/schema/update.d.ts.map +1 -1
  63. package/dist/lib/schema/update.js +7 -3
  64. package/dist/lib/sql-api.d.ts +9 -9
  65. package/dist/lib/sql-api.d.ts.map +1 -1
  66. package/dist/lib/sql-api.js +2 -1
  67. package/dist/lib/table-helpers.d.ts.map +1 -1
  68. package/dist/lib/table-helpers.js +169 -0
  69. package/dist/lib/tenant.d.ts +43 -8
  70. package/dist/lib/tenant.d.ts.map +1 -1
  71. package/dist/lib/tenant.js +169 -54
  72. package/dist/lib/write-access.d.ts +36 -0
  73. package/dist/lib/write-access.d.ts.map +1 -0
  74. package/dist/lib/write-access.js +58 -0
  75. package/dist/routes/auto/get.routes.d.ts.map +1 -1
  76. package/dist/routes/auto/get.routes.js +3 -2
  77. package/dist/routes/auto/plugin.d.ts.map +1 -1
  78. package/dist/routes/auto/plugin.js +8 -2
  79. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  80. package/dist/routes/auto/route-helpers.js +13 -4
  81. package/dist/routes/setup-swagger.d.ts.map +1 -0
  82. package/dist/{lib → routes}/sql-api-decorator.d.ts +1 -1
  83. package/dist/routes/sql-api-decorator.d.ts.map +1 -0
  84. package/dist/{lib → routes}/sql-api-decorator.js +4 -4
  85. package/dist/types/cli.d.ts +27 -1
  86. package/dist/types/cli.d.ts.map +1 -1
  87. package/dist/types/dml.d.ts +6 -4
  88. package/dist/types/dml.d.ts.map +1 -1
  89. package/dist/types/plugin.d.ts +22 -3
  90. package/dist/types/plugin.d.ts.map +1 -1
  91. package/dist/types/request.d.ts +24 -0
  92. package/dist/types/request.d.ts.map +1 -0
  93. package/dist/types/request.js +1 -0
  94. package/dist/types/schema.d.ts +15 -0
  95. package/dist/types/schema.d.ts.map +1 -1
  96. package/dist/types/search.d.ts +3 -0
  97. package/dist/types/search.d.ts.map +1 -1
  98. package/dist/types/table.d.ts +84 -11
  99. package/dist/types/table.d.ts.map +1 -1
  100. package/dist/types/tenant.d.ts +26 -2
  101. package/dist/types/tenant.d.ts.map +1 -1
  102. package/dist/types/validation.d.ts +3 -3
  103. package/dist/types/validation.d.ts.map +1 -1
  104. package/dist/types.d.ts +1 -0
  105. package/dist/types.d.ts.map +1 -1
  106. package/dist/types.js +1 -0
  107. package/docs/GLOSSARY.md +3 -1
  108. package/docs/adr/0004-updates-always-open.md +2 -1
  109. package/docs/adr/0006-raw-db-errors.md +2 -1
  110. package/docs/adr/0010-joins-do-not-run-route-guards.md +4 -1
  111. package/docs/adr/0013-sanitized-db-errors.md +114 -0
  112. package/docs/adr/0014-what-follows-a-write-join.md +84 -0
  113. package/docs/adr/0015-non-writable-columns.md +86 -0
  114. package/docs/adr/0016-a-view-is-a-table-config.md +92 -0
  115. package/docs/adr/0017-the-schema-is-the-contract.md +85 -0
  116. package/docs/adr/0018-one-seam-to-the-http-framework.md +68 -0
  117. package/docs/adr/README.md +6 -0
  118. package/package.json +1 -1
  119. package/dist/lib/setup-swagger.d.ts.map +0 -1
  120. package/dist/lib/sql-api-decorator.d.ts.map +0 -1
  121. /package/dist/{lib → routes}/setup-swagger.d.ts +0 -0
  122. /package/dist/{lib → routes}/setup-swagger.js +0 -0
@@ -1 +1 @@
1
- {"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/lib/db.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/lib/db.ts"],"names":[],"mappings":"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"}
package/dist/lib/db.js CHANGED
@@ -195,11 +195,17 @@ export class QueryClient {
195
195
  }
196
196
  return results;
197
197
  }
198
- async bulkInsertOrUpdate(table, records, conflictKeys, pkCol, chunkSize = DEFAULT_CHUNK_SIZE) {
198
+ /**
199
+ * `immutableCols` are written when the row is inserted but left untouched when the statement
200
+ * lands on an existing row — like the conflict keys, which are never in the update set either.
201
+ * Used by tenant isolation to keep an upsert from re-assigning a row's owner.
202
+ */
203
+ async bulkInsertOrUpdate(table, records, conflictKeys, pkCol, chunkSize = DEFAULT_CHUNK_SIZE, immutableCols = []) {
199
204
  if (!records.length)
200
205
  return [];
201
206
  const fields = Object.keys(records[0]);
202
- const upsertClause = this.dialect.upsertSql(this.#q(conflictKeys), this.dialect.upsertUpdateSet(fields, conflictKeys));
207
+ const keepAsIs = immutableCols.length ? [...conflictKeys, ...immutableCols] : conflictKeys;
208
+ const upsertClause = this.dialect.upsertSql(this.#q(conflictKeys), this.dialect.upsertUpdateSet(fields, keepAsIs));
203
209
  const returning = this.dialect.returningPk(this.#quotedPkCols(pkCol));
204
210
  const results = [];
205
211
  for (let i = 0; i < records.length; i += chunkSize) {
@@ -1 +1 @@
1
- {"version":3,"file":"bulk-upsert.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/bulk/bulk-upsert.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAEV,gBAAgB,EAChB,gBAAgB,EAEjB,MAAM,mBAAmB,CAAC;AA4D3B,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CA0C5F"}
1
+ {"version":3,"file":"bulk-upsert.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/bulk/bulk-upsert.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAEV,gBAAgB,EAChB,gBAAgB,EAEjB,MAAM,mBAAmB,CAAC;AA+D3B,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CA0C5F"}
@@ -1,6 +1,6 @@
1
1
  import { camelcaseObject } from '../../naming.js';
2
2
  import { processSecondaries, processDeletions, prepareInsertRecord } from '../write-helpers.js';
3
- import { enforceTenantOnWrites, assertTenantOwnsConflicts } from '../../tenant.js';
3
+ import { enforceTenantOnWrites, assertTenantOwnsConflicts, tenantImmutableColumns, } from '../../tenant.js';
4
4
  import { runBulkValidation } from '../validate.js';
5
5
  import { primaryAsCols } from '../../../types.js';
6
6
  /** One bulk statement for every main row, returning the PKs only. */
@@ -12,9 +12,10 @@ function upsertMains(params, preparedMains, pkCol) {
12
12
  return db.bulkInsert(schema.tableName, preparedMains, pkCol);
13
13
  }
14
14
  const conflictCols = upsertKeys.map((k) => schema.col(k));
15
- // Tenant isolation: a conflicting upsert must not overwrite rows owned by another tenant.
15
+ // Tenant isolation: a conflicting upsert must not overwrite rows owned by another tenant, nor
16
+ // re-assign the owner of a row it legitimately updates.
16
17
  return assertTenantOwnsConflicts(db, tenant, schema.tableName, conflictCols, preparedMains)
17
- .then(() => db.bulkInsertOrUpdate(schema.tableName, preparedMains, conflictCols, pkCol));
18
+ .then(() => db.bulkInsertOrUpdate(schema.tableName, preparedMains, conflictCols, pkCol, undefined, tenantImmutableColumns(tenant)));
18
19
  }
19
20
  /** Secondaries, deletions and the afterInsert hook for one upserted item. */
20
21
  async function finalizeUpsertedItem(params, item, inputMain, pkRow) {
@@ -26,12 +27,12 @@ async function finalizeUpsertedItem(params, item, inputMain, pkRow) {
26
27
  // FK auto-fill: input camelCase + generated PK
27
28
  const mainForFK = { ...inputMain, ...mainPkCamel };
28
29
  if (item.secondaries && Object.keys(item.secondaries).length > 0) {
29
- const sec = await processSecondaries({ db, tableConf, dbTables, mainRecord: mainForFK, tenant }, item.secondaries);
30
+ const sec = await processSecondaries({ db, tableConf, dbTables, mainRecord: mainForFK, tenant, request }, item.secondaries);
30
31
  if (Object.keys(sec).length > 0)
31
32
  result.secondaries = sec;
32
33
  }
33
34
  if (item.deletions && Object.keys(item.deletions).length > 0) {
34
- const del = await processDeletions({ db, tableConf, dbTables, mainRecord: mainForFK, tenant }, item.deletions);
35
+ const del = await processDeletions({ db, tableConf, dbTables, mainRecord: mainForFK, tenant, request }, item.deletions);
35
36
  if (Object.keys(del).length > 0)
36
37
  result.deletions = del;
37
38
  }
@@ -0,0 +1,19 @@
1
+ import type { ApiRequest } from '../../types/request.js';
2
+ import type { QueryClient } from '../db.js';
3
+ import type { ITable, ReadSource } from '../../types.js';
4
+ /**
5
+ * Run a table's `afterRead` hook over the rows a read produced.
6
+ *
7
+ * Every read path funnels through this one helper so the hook has a single meaning: it is
8
+ * handed camelCase rows of `tableConf`'s own table, in one batch, and mutates them in place.
9
+ * Batching is the contract, not an optimisation — a hook that decrypts through a remote KMS
10
+ * gets one call per result set instead of one per row.
11
+ *
12
+ * `tableConf` is resolved by the caller, which on a join is the JOINED table's config
13
+ * (`dbTables[joinSchema.tableName]`). That is what makes the hook follow a relation without
14
+ * being declared on the host, the way `readExclude` and `tenantScope` do (ADR 0010).
15
+ *
16
+ * An empty result set is skipped: there is nothing to transform, and the hook may be costly.
17
+ */
18
+ export declare function runAfterRead(db: QueryClient, request: ApiRequest | undefined, rows: Record<string, unknown>[], tableConf: ITable | undefined, source: ReadSource, alias?: string): Promise<void>;
19
+ //# sourceMappingURL=read-hooks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"read-hooks.d.ts","sourceRoot":"","sources":["../../../src/lib/engine/read-hooks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAC5C,OAAO,KAAK,EAAoB,MAAM,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAE3E;;;;;;;;;;;;;GAaG;AACH,wBAAsB,YAAY,CAChC,EAAE,EAAE,WAAW,EACf,OAAO,EAAE,UAAU,GAAG,SAAS,EAC/B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAC/B,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,UAAU,EAClB,KAAK,CAAC,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC,CAIf"}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Run a table's `afterRead` hook over the rows a read produced.
3
+ *
4
+ * Every read path funnels through this one helper so the hook has a single meaning: it is
5
+ * handed camelCase rows of `tableConf`'s own table, in one batch, and mutates them in place.
6
+ * Batching is the contract, not an optimisation — a hook that decrypts through a remote KMS
7
+ * gets one call per result set instead of one per row.
8
+ *
9
+ * `tableConf` is resolved by the caller, which on a join is the JOINED table's config
10
+ * (`dbTables[joinSchema.tableName]`). That is what makes the hook follow a relation without
11
+ * being declared on the host, the way `readExclude` and `tenantScope` do (ADR 0010).
12
+ *
13
+ * An empty result set is skipped: there is nothing to transform, and the hook may be costly.
14
+ */
15
+ export async function runAfterRead(db, request, rows, tableConf, source, alias) {
16
+ if (!tableConf?.afterRead || rows.length === 0)
17
+ return;
18
+ const ctx = alias === undefined ? { source } : { source, alias };
19
+ await tableConf.afterRead(db, request, rows, ctx);
20
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"get.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/get.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAE9D,wBAAsB,SAAS,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC,CAsCrE"}
1
+ {"version":3,"file":"get.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/get.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAE9D,wBAAsB,SAAS,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC,CA0CrE"}
@@ -3,9 +3,10 @@ import { camelcaseObject } from '../../naming.js';
3
3
  import { buildTenantCondition, buildTenantJoin } from '../../tenant.js';
4
4
  import { httpError } from '../../errors.js';
5
5
  import { readableSelectColumns } from '../../read-access.js';
6
+ import { runAfterRead } from '../read-hooks.js';
6
7
  import { primaryAsString, isCompositePrimary } from '../../../types.js';
7
8
  export async function getEngine(params) {
8
- const { db, tableConf, id, tenant } = params;
9
+ const { db, tableConf, id, tenant, request } = params;
9
10
  if (isCompositePrimary(tableConf.primary)) {
10
11
  throw httpError(400, `Table "${tableConf.Schema.tableName}" has a composite primary key: get-by-id would match on "${primaryAsString(tableConf.primary)}" alone. Use search with all PK fields.`);
11
12
  }
@@ -15,10 +16,11 @@ export async function getEngine(params) {
15
16
  // shared with the through table would be ambiguous.
16
17
  cb.isEqual(`${db.qi(tableConf.Schema.tableName)}.${db.qi(pkCol)}`, id);
17
18
  const joins = [];
18
- if (tenant) {
19
- cb.append(buildTenantCondition(db, tenant.scope, tenant.ids, tableConf.Schema.tableName));
20
- if ('through' in tenant.scope) {
21
- joins.push(buildTenantJoin(db, tenant.scope, tableConf.Schema.tableName));
19
+ if (tenant?.scope) {
20
+ const scope = tenant.scope;
21
+ cb.append(buildTenantCondition(db, scope, tenant.ids, tableConf.Schema.tableName));
22
+ if ('through' in scope) {
23
+ joins.push(buildTenantJoin(db, scope, tableConf.Schema.tableName));
22
24
  }
23
25
  }
24
26
  const where = cb.build(1, db.ph);
@@ -33,5 +35,7 @@ export async function getEngine(params) {
33
35
  });
34
36
  if (rows.length === 0)
35
37
  throw httpError(404, `Record not found: ${id}`);
36
- return { main: camelcaseObject(rows[0], tableConf.Schema) };
38
+ const main = camelcaseObject(rows[0], tableConf.Schema);
39
+ await runAfterRead(db, request, [main], tableConf, 'get');
40
+ return { main };
37
41
  }
@@ -32,7 +32,7 @@ export async function insertEngine(params) {
32
32
  let secondaryResults;
33
33
  if (secondaries && Object.keys(secondaries).length > 0) {
34
34
  const mainForFK = { ...inputRecord, ...mainPkCamel };
35
- secondaryResults = await processSecondaries({ db: tx, tableConf, dbTables, mainRecord: mainForFK, tenant }, secondaries);
35
+ secondaryResults = await processSecondaries({ db: tx, tableConf, dbTables, mainRecord: mainForFK, tenant, request }, secondaries);
36
36
  }
37
37
  // 5. afterInsert hook (camelCase — input merged with generated PK)
38
38
  if (tableConf.afterInsert) {
@@ -1 +1 @@
1
- {"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/update.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EACV,YAAY,EACZ,YAAY,EAEb,MAAM,mBAAmB,CAAC;AAiC3B,wBAAsB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CA2G9E"}
1
+ {"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/update.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EACV,YAAY,EACZ,YAAY,EAEb,MAAM,mBAAmB,CAAC;AAiC3B,wBAAsB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CA+G9E"}
@@ -1,4 +1,5 @@
1
1
  import { snakecaseRecord } from '../../naming.js';
2
+ import { removeWriteExcluded } from '../../write-access.js';
2
3
  import { processSecondaries, processDeletions } from '../write-helpers.js';
3
4
  import { stripTenantColumn, assertTenantOwnership, buildTenantUpdateExtra, enforceTenantFKOnUpdate, } from '../../tenant.js';
4
5
  import { runValidation } from '../validate.js';
@@ -46,22 +47,25 @@ export async function updateEngine(params) {
46
47
  if (tableConf.beforeUpdate) {
47
48
  await tableConf.beforeUpdate(db, request, inputRecord);
48
49
  }
49
- // 5. Convert to DB format (after all user mutations)
50
+ // 5. Drop what the database will not be told about (generated columns, writeExclude).
51
+ // After the hook on purpose: the column refuses a value whoever assigned it.
52
+ removeWriteExcluded(inputRecord, tableConf, schema);
53
+ // 6. Convert to DB format (after all user mutations)
50
54
  const snaked = snakecaseRecord(inputRecord, schema);
51
55
  const updateFields = { ...snaked };
52
56
  for (const c of pkCols)
53
57
  delete updateFields[c];
54
- // 6. Strip tenant column (user cannot change tenant of an existing record).
58
+ // 7. Strip tenant column (user cannot change tenant of an existing record).
55
59
  // For indirect scopes the tenant link is the through-FK (localField): it may be changed,
56
60
  // but only to another value the caller owns — re-validate the new FK against the tenant.
57
- if (tenant) {
61
+ if (tenant?.scope) {
58
62
  stripTenantColumn(updateFields, tenant.scope);
59
63
  await enforceTenantFKOnUpdate(db, tenant, updateFields);
60
64
  }
61
- // Steps 7-9 are atomic: a failure in secondaries or deletions rolls back the main
65
+ // Steps 8-10 are atomic: a failure in secondaries or deletions rolls back the main
62
66
  // update too. Degrades to non-transactional when the adapter has no connect().
63
67
  return db.withTransaction(async (tx) => {
64
- // 7. Update main
68
+ // 8. Update main
65
69
  const hasFieldsToUpdate = Object.keys(updateFields).length > 0;
66
70
  const extraCondition = buildTenantUpdateExtra(tx, tenant);
67
71
  // WHERE matching every primary key column (single or composite).
@@ -76,23 +80,23 @@ export async function updateEngine(params) {
76
80
  }
77
81
  // Build the main response (PK-only — every PK field)
78
82
  const mainResult = Object.fromEntries(pkFields.map((f, i) => [f, pkValues[i]]));
79
- // 8. Secondaries (upsert/insert with FK auto-fill — camelCase)
83
+ // 9. Secondaries (upsert/insert with FK auto-fill — camelCase)
80
84
  let secondaryResults;
81
85
  if (secondaries && Object.keys(secondaries).length > 0) {
82
86
  const mainForFK = { ...inputRecord };
83
- secondaryResults = await processSecondaries({ db: tx, tableConf, dbTables, mainRecord: mainForFK, tenant }, secondaries);
87
+ secondaryResults = await processSecondaries({ db: tx, tableConf, dbTables, mainRecord: mainForFK, tenant, request }, secondaries);
84
88
  }
85
- // 9. Deletions (FK auto-fill from main like secondaries)
89
+ // 10. Deletions (FK auto-fill from main like secondaries)
86
90
  let deletionResults;
87
91
  if (deletions && Object.keys(deletions).length > 0) {
88
92
  const mainForFK = { ...inputRecord };
89
- deletionResults = await processDeletions({ db: tx, tableConf, dbTables, mainRecord: mainForFK, tenant }, deletions);
93
+ deletionResults = await processDeletions({ db: tx, tableConf, dbTables, mainRecord: mainForFK, tenant, request }, deletions);
90
94
  }
91
- // 10. afterUpdate hook (inside the transaction: throwing rolls everything back)
95
+ // 11. afterUpdate hook (inside the transaction: throwing rolls everything back)
92
96
  if (tableConf.afterUpdate) {
93
97
  await tableConf.afterUpdate(tx, request, inputRecord, secondaryResults, deletionResults);
94
98
  }
95
- // 11. Return PK-only
99
+ // 12. Return PK-only
96
100
  const result = { main: mainResult };
97
101
  if (secondaryResults && Object.keys(secondaryResults).length > 0) {
98
102
  result.secondaries = secondaryResults;
@@ -1,3 +1,12 @@
1
+ /**
2
+ * The four join families, each executed against the rows the main query returned.
3
+ *
4
+ * joinMustExist child -> main, 1:N EXISTS filter on the main query (returns no rows)
5
+ * joinMultiple child -> main, 1:N side query, rows under result.joinMultiple.<alias>
6
+ * joinGroup child -> main, 1:N aggregations under result.joinGroup.<alias>
7
+ * joinLeft parent -> main, N:1 real LEFT JOIN on demand + side query for the rows
8
+ */
9
+ import type { ApiRequest } from '../../../types/request.js';
1
10
  import type { QueryClient } from '../../db.js';
2
11
  import type { QueryParams } from '../query-params.js';
3
12
  import type { DbTables, ITable, JoinFetchRequest, JoinGroupRequest, JoinRefFilter, TenantContext } from '../../../types.js';
@@ -26,9 +35,9 @@ export declare function buildLeftJoinOnClauses(db: QueryClient, dbTables: DbTabl
26
35
  /** The WHERE fragments a `joinLeft` request's own filters and conditions contribute. */
27
36
  export declare function buildLeftJoinFilters(db: QueryClient, dbTables: DbTables, tableConf: ITable, aliasesNeedingJoin: Set<string>, joinLeft: Record<string, JoinFetchRequest> | undefined, startIdx: number): LeftJoinFilterBuild;
28
37
  /** joinMultiple: 1:N children, returned as rows under `result.joinMultiple.<alias>`. */
29
- export declare function executeJoinMultiple(db: QueryClient, dbTables: DbTables, tableConf: ITable, mainResults: Record<string, unknown>[], joinMultiple: Record<string, JoinFetchRequest>, tenant?: TenantContext): Promise<Record<string, Record<string, unknown>[]>>;
38
+ export declare function executeJoinMultiple(db: QueryClient, dbTables: DbTables, tableConf: ITable, mainResults: Record<string, unknown>[], joinMultiple: Record<string, JoinFetchRequest>, tenant?: TenantContext, request?: ApiRequest): Promise<Record<string, Record<string, unknown>[]>>;
30
39
  /** joinLeft: N:1 parents, returned as rows under `result.joinLeft.<alias>`. */
31
- export declare function executeJoinLeft(db: QueryClient, dbTables: DbTables, tableConf: ITable, mainResults: Record<string, unknown>[], joinLeft: Record<string, JoinFetchRequest>, tenant?: TenantContext): Promise<Record<string, Record<string, unknown>[]>>;
40
+ export declare function executeJoinLeft(db: QueryClient, dbTables: DbTables, tableConf: ITable, mainResults: Record<string, unknown>[], joinLeft: Record<string, JoinFetchRequest>, tenant?: TenantContext, request?: ApiRequest): Promise<Record<string, Record<string, unknown>[]>>;
32
41
  export declare function executeJoinGroup(db: QueryClient, dbTables: DbTables, tableConf: ITable, mainResults: Record<string, unknown>[], joinGroup: Record<string, JoinGroupRequest>, tenant?: TenantContext): Promise<Record<string, Record<string, unknown>>>;
33
42
  export declare function buildJoinMustExistClauses(params: QueryParams, db: QueryClient, dbTables: DbTables, tableConf: ITable, joinMustExist: Record<string, JoinRefFilter>, tenant?: TenantContext): string;
34
43
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"join-families.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/join-families.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAmBtD,OAAO,KAAK,EAEV,QAAQ,EAER,MAAM,EACN,gBAAgB,EAChB,gBAAgB,EAChB,aAAa,EAGb,aAAa,EAEd,MAAM,mBAAmB,CAAC;AAI3B,UAAU,eAAe;IACvB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,MAAM,EAAE,OAAO,EAAE,CAAC;CACnB;AAED,UAAU,mBAAmB;IAC3B,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,MAAM,EAAE,OAAO,EAAE,CAAC;CACnB;AAsED;;;;;;;;;;;;GAYG;AACH,wBAAgB,sBAAsB,CACpC,EAAE,EAAE,WAAW,EACf,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,MAAM,EACjB,kBAAkB,EAAE,GAAG,CAAC,MAAM,CAAC,EAC/B,QAAQ,EAAE,MAAM,EAChB,MAAM,CAAC,EAAE,aAAa,GACrB,eAAe,CAgCjB;AAED,wFAAwF;AACxF,wBAAgB,oBAAoB,CAClC,EAAE,EAAE,WAAW,EACf,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,MAAM,EACjB,kBAAkB,EAAE,GAAG,CAAC,MAAM,CAAC,EAC/B,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,GAAG,SAAS,EACtD,QAAQ,EAAE,MAAM,GACf,mBAAmB,CA0CrB;AAmGD,wFAAwF;AACxF,wBAAgB,mBAAmB,CACjC,EAAE,EAAE,WAAW,EACf,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EACtC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,EAC9C,MAAM,CAAC,EAAE,aAAa,GACrB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CAKpD;AAED,+EAA+E;AAC/E,wBAAgB,eAAe,CAC7B,EAAE,EAAE,WAAW,EACf,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EACtC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,EAC1C,MAAM,CAAC,EAAE,aAAa,GACrB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CAKpD;AAsGD,wBAAsB,gBAAgB,CACpC,EAAE,EAAE,WAAW,EACf,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EACtC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,EAC3C,MAAM,CAAC,EAAE,aAAa,GACrB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CAqClD;AAyBD,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,WAAW,EACnB,EAAE,EAAE,WAAW,EACf,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,MAAM,EACjB,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,EAC5C,MAAM,CAAC,EAAE,aAAa,GACrB,MAAM,CA4BR"}
1
+ {"version":3,"file":"join-families.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/join-families.ts"],"names":[],"mappings":"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"}
@@ -1,15 +1,8 @@
1
- /**
2
- * The four join families, each executed against the rows the main query returned.
3
- *
4
- * joinMustExist child -> main, 1:N EXISTS filter on the main query (returns no rows)
5
- * joinMultiple child -> main, 1:N side query, rows under result.joinMultiple.<alias>
6
- * joinGroup child -> main, 1:N aggregations under result.joinGroup.<alias>
7
- * joinLeft parent -> main, N:1 real LEFT JOIN on demand + side query for the rows
8
- */
9
1
  import { ConditionBuilder, Expression } from 'node-condition-builder';
10
2
  import { assertReadable, assertFiltersReadable, hasOwnField, ownField } from '../../read-access.js';
11
3
  import { ALLOWED_SET } from '../../condition-methods.js';
12
4
  import { err400, validateSchemaField, evaluateComputedField, mapRowsToCamelCase, buildSelectionColumns, } from './fields.js';
5
+ import { runAfterRead } from '../read-hooks.js';
13
6
  import { appendJoinTenantScope, subqueryAlias, requireJoin, extractJoinRefs, } from './joins.js';
14
7
  import { tenantForTable, buildTenantRowGuard } from '../../tenant.js';
15
8
  import { dispatchConditionMethod, buildJoinRefCondition } from './conditions.js';
@@ -152,7 +145,7 @@ function scopeJoinSideQuery(cb, db, joinSchema, joinField, ids, joinTableConf, t
152
145
  const where = cb.build(1, db.ph);
153
146
  return { where, values: cb.getValues(), tenantJoins };
154
147
  }
155
- async function fetchJoinRows(db, dbTables, tableConf, mainResults, requests, mode, tenant) {
148
+ async function fetchJoinRows(db, dbTables, tableConf, mainResults, requests, mode, tenant, request) {
156
149
  const result = {};
157
150
  for (const [alias, ref] of Object.entries(requests)) {
158
151
  const joinDef = requireJoin(tableConf, alias, mode.requireUnique);
@@ -182,22 +175,27 @@ async function fetchJoinRows(db, dbTables, tableConf, mainResults, requests, mod
182
175
  joins: tenantJoins.length > 0 ? tenantJoins : undefined,
183
176
  });
184
177
  result[alias] = mapRowsToCamelCase(rows, joinSchema);
178
+ // The joined table's own hook, not the host's: a transform belongs to the table that
179
+ // owns the column, wherever its rows surface.
180
+ await runAfterRead(db, request, result[alias], joinTableConf, mode.source, alias);
185
181
  }
186
182
  return result;
187
183
  }
188
184
  /** joinMultiple: 1:N children, returned as rows under `result.joinMultiple.<alias>`. */
189
- export function executeJoinMultiple(db, dbTables, tableConf, mainResults, joinMultiple, tenant) {
185
+ export function executeJoinMultiple(db, dbTables, tableConf, mainResults, joinMultiple, tenant, request) {
190
186
  return fetchJoinRows(db, dbTables, tableConf, mainResults, joinMultiple, {
187
+ source: 'joinMultiple',
191
188
  requireUnique: false,
192
189
  applyRefCondition: true,
193
- }, tenant);
190
+ }, tenant, request);
194
191
  }
195
192
  /** joinLeft: N:1 parents, returned as rows under `result.joinLeft.<alias>`. */
196
- export function executeJoinLeft(db, dbTables, tableConf, mainResults, joinLeft, tenant) {
193
+ export function executeJoinLeft(db, dbTables, tableConf, mainResults, joinLeft, tenant, request) {
197
194
  return fetchJoinRows(db, dbTables, tableConf, mainResults, joinLeft, {
195
+ source: 'joinLeft',
198
196
  requireUnique: true,
199
197
  applyRefCondition: false,
200
- }, tenant);
198
+ }, tenant, request);
201
199
  }
202
200
  /**
203
201
  * Resolve `aggregations.by` to a SQL expression. Accepts:
@@ -1 +1 @@
1
- {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/search.ts"],"names":[],"mappings":"AAgCA,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EACZ,YAAY,EAGb,MAAM,mBAAmB,CAAC;AAwP3B,wBAAsB,YAAY,CAChC,QAAQ,EAAE,QAAQ,EAClB,MAAM,EAAE,YAAY,GACnB,OAAO,CAAC,YAAY,CAAC,CAsFvB"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/search.ts"],"names":[],"mappings":"AAiCA,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EACZ,YAAY,EAGb,MAAM,mBAAmB,CAAC;AAyP3B,wBAAsB,YAAY,CAChC,QAAQ,EAAE,QAAQ,EAClB,MAAM,EAAE,YAAY,GACnB,OAAO,CAAC,YAAY,CAAC,CA4FvB"}
@@ -5,6 +5,7 @@ import { MAX_CONDITIONS, MAX_ORDER_BY_PARTS } from '../../condition-methods.js';
5
5
  import { primaryAsString } from '../../../types.js';
6
6
  import { err400, resolveFieldRef, evaluateComputedField, refValues, renderRef, mapRowsToCamelCase, } from './fields.js';
7
7
  import { assertJoinFilterKeys, requireJoin } from './joins.js';
8
+ import { runAfterRead } from '../read-hooks.js';
8
9
  import { collectOrderByLeftAliases, validateOrderBy, convertConfiguredOrder } from './order-by.js';
9
10
  import { applyConditions, applyComputedFilters } from './conditions.js';
10
11
  import { appendAggConditions } from './aggregations.js';
@@ -153,10 +154,11 @@ function buildMainCondition(params) {
153
154
  applyConditions(condition, conditions, tableConf.Schema, tableConf, db);
154
155
  }
155
156
  const tenantJoins = [];
156
- if (tenant) {
157
- condition.append(buildTenantCondition(db, tenant.scope, tenant.ids, tableConf.Schema.tableName));
158
- if ('through' in tenant.scope) {
159
- tenantJoins.push(buildTenantJoin(db, tenant.scope, tableConf.Schema.tableName));
157
+ if (tenant?.scope) {
158
+ const scope = tenant.scope;
159
+ condition.append(buildTenantCondition(db, scope, tenant.ids, tableConf.Schema.tableName));
160
+ if ('through' in scope) {
161
+ tenantJoins.push(buildTenantJoin(db, scope, tableConf.Schema.tableName));
160
162
  }
161
163
  }
162
164
  return { condition, tenantJoins };
@@ -186,12 +188,12 @@ function collectLeftJoinAliases(params) {
186
188
  * it returned.
187
189
  */
188
190
  async function attachJoinResults(result, dbTables, params, main) {
189
- const { db, tableConf, joinMultiple, joinLeft, joinGroup, tenant } = params;
191
+ const { db, tableConf, joinMultiple, joinLeft, joinGroup, tenant, request } = params;
190
192
  if (joinMultiple && Object.keys(joinMultiple).length > 0) {
191
- result.joinMultiple = await executeJoinMultiple(db, dbTables, tableConf, main, joinMultiple, tenant);
193
+ result.joinMultiple = await executeJoinMultiple(db, dbTables, tableConf, main, joinMultiple, tenant, request);
192
194
  }
193
195
  if (joinLeft && Object.keys(joinLeft).length > 0) {
194
- result.joinLeft = await executeJoinLeft(db, dbTables, tableConf, main, joinLeft, tenant);
196
+ result.joinLeft = await executeJoinLeft(db, dbTables, tableConf, main, joinLeft, tenant, request);
195
197
  }
196
198
  if (joinGroup && Object.keys(joinGroup).length > 0) {
197
199
  result.joinGroup = await executeJoinGroup(db, dbTables, tableConf, main, joinGroup, tenant);
@@ -247,6 +249,11 @@ export async function searchEngine(dbTables, params) {
247
249
  }
248
250
  const result = { main };
249
251
  await attachJoinResults(result, dbTables, params, main);
252
+ // After the joins, not before: they correlate to the main rows by the values the DB
253
+ // returned (`fk IN (ids)`), and a hook that rewrites the column holding that key would
254
+ // have them look for a value no child row carries. `main` is the same array either way,
255
+ // so the response still carries the hook's mutations.
256
+ await runAfterRead(db, params.request, main, tableConf, 'search');
250
257
  if (pagination) {
251
258
  result.pagination = pagination;
252
259
  }
@@ -1,6 +1,6 @@
1
- import type { FastifyRequest } from 'fastify';
1
+ import type { ApiRequest } from '../../types/request.js';
2
2
  import type { QueryClient } from '../db.js';
3
3
  import type { ITable, BulkValidatorItem } from '../../types.js';
4
- export declare function runValidation(db: QueryClient, request: FastifyRequest, tableConf: ITable, main: Record<string, unknown>, secondaries?: Record<string, Record<string, unknown>[]>): Promise<void>;
5
- export declare function runBulkValidation(db: QueryClient, request: FastifyRequest, tableConf: ITable, items: BulkValidatorItem[]): Promise<void>;
4
+ export declare function runValidation(db: QueryClient, request: ApiRequest, tableConf: ITable, main: Record<string, unknown>, secondaries?: Record<string, Record<string, unknown>[]>): Promise<void>;
5
+ export declare function runBulkValidation(db: QueryClient, request: ApiRequest, tableConf: ITable, items: BulkValidatorItem[]): Promise<void>;
6
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;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
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../../src/lib/engine/validate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACzD,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,UAAU,EACnB,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,UAAU,EACnB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,iBAAiB,EAAE,GACzB,OAAO,CAAC,IAAI,CAAC,CAOf"}
@@ -1,4 +1,4 @@
1
- import type { FastifyRequest } from 'fastify';
1
+ import type { ApiRequest } from '../../types/request.js';
2
2
  import type { QueryClient } from '../db.js';
3
3
  import type { ITable, DbTables, TenantContext } from '../../types.js';
4
4
  export declare function findSecondaryTableConf(dbTables: DbTables, joinTableName: string): ITable | undefined;
@@ -13,11 +13,13 @@ export interface WriteJoinPass {
13
13
  /** Main record (camelCase), the source of the FK auto-filled into every child row. */
14
14
  mainRecord: Record<string, unknown>;
15
15
  tenant?: TenantContext;
16
+ /** The request that triggered the write, handed to the child table's `beforeInsert`. */
17
+ request: ApiRequest;
16
18
  }
17
19
  interface PrepareCtx {
18
20
  db: QueryClient;
19
21
  tableConf: ITable;
20
- request: FastifyRequest;
22
+ request: ApiRequest;
21
23
  }
22
24
  /**
23
25
  * Prepare a single record for INSERT/UPSERT:
@@ -26,7 +28,9 @@ interface PrepareCtx {
26
28
  * 3. drop excludeFromCreation fields (client-input whitelist; applied BEFORE the
27
29
  * hook so values it assigns — e.g. a server-generated id — reach the INSERT)
28
30
  * 4. beforeInsert hook (caller can mutate the camelCase copy)
29
- * 5. snakecaseRecord → DB format
31
+ * 5. drop non-writable fields (generated columns, writeExclude) — AFTER the hook, because
32
+ * the database refuses those values whoever assigned them
33
+ * 6. snakecaseRecord → DB format
30
34
  *
31
35
  * Returns both `camel` (input + hook mutations, used for FK auto-fill / afterInsert) and
32
36
  * `snake` (DB-ready record). The original `input` is left intact.
@@ -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;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
+ {"version":3,"file":"write-helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/engine/write-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAEzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAU5C,OAAO,KAAK,EACV,MAAM,EACN,QAAQ,EAGR,aAAa,EACd,MAAM,gBAAgB,CAAC;AAmCxB,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;IACvB,wFAAwF;IACxF,OAAO,EAAE,UAAU,CAAC;CACrB;AAmCD,UAAU,UAAU;IAClB,EAAE,EAAE,WAAW,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,UAAU,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;GAgBG;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,CAgB7E;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,CA0DpD;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,7 @@
1
1
  import { camelcaseObject, snakecaseRecord } from '../naming.js';
2
2
  import { runValidation } from './validate.js';
3
- import { tenantForTable, buildTenantRowGuard, enforceTenantOnWrites, assertTenantOwnsConflicts, } from '../tenant.js';
3
+ import { removeWriteExcluded } from '../write-access.js';
4
+ import { tenantForTable, buildTenantRowGuard, enforceTenantOnWrites, assertTenantOwnsConflicts, tenantImmutableColumns, } from '../tenant.js';
4
5
  /**
5
6
  * Resolve the write join an alias names, or undefined when the table does not declare it.
6
7
  *
@@ -14,26 +15,11 @@ function findWriteJoin(tableConf, alias) {
14
15
  return tableConf.allowedWriteJoins?.find((j) => j.alias === alias);
15
16
  }
16
17
  /**
17
- * Drop `excludeFromCreation` columns from a snake_case (DB-format) record.
18
+ * Drop `excludeFromCreation` fields from a camelCase record, in place.
18
19
  *
19
- * Exclusion is a whitelist on CLIENT input: call this on the client payload only,
20
- * BEFORE any server-side value is assigned (beforeInsert mutations, FK auto-fill),
21
- * so engine/hook-generated values on those fields reach the SQL.
22
- */
23
- function removeExcludedFields(record, tableConf) {
24
- if (!tableConf.excludeFromCreation?.length)
25
- return record;
26
- const result = { ...record };
27
- for (const field of tableConf.excludeFromCreation) {
28
- const col = tableConf.Schema.col(field);
29
- delete result[col];
30
- }
31
- return result;
32
- }
33
- /**
34
- * Same whitelist as `removeExcludedFields`, but for a camelCase record (schema
35
- * field names) and in place. Used on the client payload before the beforeInsert
36
- * hook runs.
20
+ * Exclusion is a whitelist on CLIENT input: call this on the client payload only, BEFORE any
21
+ * server-side value is assigned (beforeInsert mutations, FK auto-fill), so engine- and
22
+ * hook-generated values on those fields reach the SQL (ADR 0005).
37
23
  */
38
24
  function removeExcludedFieldsCamel(record, tableConf) {
39
25
  if (!tableConf.excludeFromCreation?.length)
@@ -71,7 +57,9 @@ function resolveWriteJoinTarget(joinDef, dbTables, mainRecord, tenant) {
71
57
  * 3. drop excludeFromCreation fields (client-input whitelist; applied BEFORE the
72
58
  * hook so values it assigns — e.g. a server-generated id — reach the INSERT)
73
59
  * 4. beforeInsert hook (caller can mutate the camelCase copy)
74
- * 5. snakecaseRecord → DB format
60
+ * 5. drop non-writable fields (generated columns, writeExclude) — AFTER the hook, because
61
+ * the database refuses those values whoever assigned them
62
+ * 6. snakecaseRecord → DB format
75
63
  *
76
64
  * Returns both `camel` (input + hook mutations, used for FK auto-fill / afterInsert) and
77
65
  * `snake` (DB-ready record). The original `input` is left intact.
@@ -88,6 +76,7 @@ export async function prepareInsertRecord(ctx, input, secondaries, options) {
88
76
  if (ctx.tableConf.beforeInsert) {
89
77
  await ctx.tableConf.beforeInsert(ctx.db, ctx.request, camel);
90
78
  }
79
+ removeWriteExcluded(camel, ctx.tableConf, ctx.tableConf.Schema);
91
80
  const snake = snakecaseRecord(camel, ctx.tableConf.Schema);
92
81
  return { camel, snake };
93
82
  }
@@ -121,23 +110,33 @@ async function forEachWriteJoin(pass, payload, writeAlias) {
121
110
  * where the child is upserted, hijack one of that tenant's existing rows via the conflict key.
122
111
  */
123
112
  export function processSecondaries(pass, secondaries) {
124
- const { db, tableConf } = pass;
113
+ const { db, tableConf, request } = pass;
125
114
  return forEachWriteJoin(pass, secondaries, async (records, target, joinDef) => {
126
115
  const { joinSchema, joinCol, mainValue, childConf, childTenant } = target;
127
116
  const childPk = childConf?.primary || joinDef.joinField;
128
117
  const childPkCol = Array.isArray(childPk)
129
118
  ? childPk.map((f) => joinSchema.col(f))
130
119
  : joinSchema.col(childPk);
131
- const preparedRecords = records.map((rec) => {
132
- let prepared = snakecaseRecord(rec, joinSchema);
133
- // Remove excluded fields from the client payload BEFORE the FK auto-fill:
134
- // an excluded FK column must not strip the engine-injected value below.
120
+ // Same order as `prepareInsertRecord` (ADR 0005): the client whitelist runs first, then
121
+ // the child's own `beforeInsert`, then the DB format — so a value the hook assigns to an
122
+ // excluded field still reaches the INSERT. A secondary is a write like any other, and its
123
+ // table's transform has to run here too or a column that table encodes on the way in is
124
+ // stored raw whenever its rows arrive as another table's children.
125
+ const preparedRecords = [];
126
+ for (const rec of records) {
127
+ const camel = { ...rec };
135
128
  if (childConf) {
136
- prepared = removeExcludedFields(prepared, childConf);
129
+ removeExcludedFieldsCamel(camel, childConf);
130
+ if (childConf.beforeInsert) {
131
+ await childConf.beforeInsert(db, request, camel);
132
+ }
137
133
  }
134
+ removeWriteExcluded(camel, childConf, joinSchema);
135
+ const prepared = snakecaseRecord(camel, joinSchema);
136
+ // FK auto-fill last: the engine's own value, not the client's and not the hook's.
138
137
  prepared[joinCol] = mainValue;
139
- return prepared;
140
- });
138
+ preparedRecords.push(prepared);
139
+ }
141
140
  // Tenant enforcement runs after the FK auto-fill: for an indirect scope the through-FK
142
141
  // the engine just injected is the value being validated.
143
142
  await enforceTenantOnWrites(db, childTenant, preparedRecords);
@@ -148,7 +147,7 @@ export function processSecondaries(pass, secondaries) {
148
147
  // A conflicting upsert must not overwrite — or re-parent — a child row owned by
149
148
  // another tenant, which the ON CONFLICT key alone would happily match.
150
149
  await assertTenantOwnsConflicts(db, childTenant, joinSchema.tableName, conflictCols, preparedRecords);
151
- pkRows = await db.bulkInsertOrUpdate(joinSchema.tableName, preparedRecords, conflictCols, childPkCol);
150
+ pkRows = await db.bulkInsertOrUpdate(joinSchema.tableName, preparedRecords, conflictCols, childPkCol, undefined, tenantImmutableColumns(childTenant));
152
151
  }
153
152
  else {
154
153
  pkRows = await db.bulkInsert(joinSchema.tableName, preparedRecords, childPkCol);
@@ -8,4 +8,28 @@
8
8
  export declare function httpError(statusCode: number, message: string): Error & {
9
9
  statusCode: number;
10
10
  };
11
+ /**
12
+ * Replace an error escaping a route handler with one that carries no database detail.
13
+ *
14
+ * Driver messages name tables, columns and constraints, and the route error handler sends
15
+ * `error.message` verbatim — so a unique violation describes the schema to whoever
16
+ * triggered it. Errors the plugin raises itself are deliberate `4xx` with messages written
17
+ * for clients (`httpError`, `validationErrors`) and pass through untouched; everything
18
+ * else becomes a bare `500`.
19
+ *
20
+ * The original is attached as `cause`, so a consumer `setErrorHandler` can still apply its
21
+ * own policy — mapping SQLSTATE to a status code stays the consumer's decision (ADR 0006).
22
+ * No status code is remapped here: a `500` stays a `500`.
23
+ *
24
+ * `requestId` is carried on the sanitized error and rendered in the response body: it is the
25
+ * same value Fastify logs as `reqId` on every line, so the log entry holding the real error
26
+ * is one grep away from the client's `500`.
27
+ *
28
+ * With `exposeDebugInfo`, the original is also described in a `debugInfo` payload rendered
29
+ * beside those fields — additive, so the response a client codes against is the same one it
30
+ * gets in production. It is off by default and derived from nothing: `debug` belongs to the
31
+ * consumer, who may wire it to an environment variable, and no option may open an external
32
+ * channel as a side effect of meaning something internal. See ADR 0013.
33
+ */
34
+ export declare function sanitizeRouteError(err: unknown, requestId?: string, exposeDebugInfo?: boolean): unknown;
11
35
  //# sourceMappingURL=errors.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/lib/errors.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,KAAK,GAAG;IAAE,UAAU,EAAE,MAAM,CAAA;CAAE,CAI7F"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/lib/errors.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,KAAK,GAAG;IAAE,UAAU,EAAE,MAAM,CAAA;CAAE,CAI7F;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,OAAO,EACZ,SAAS,CAAC,EAAE,MAAM,EAClB,eAAe,UAAQ,GACtB,OAAO,CAcT"}