fastify-auto-sqlapi 0.1.14 → 0.2.1

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 (101) hide show
  1. package/AGENTS_BACKEND.md +52 -7
  2. package/BREAKING_CHANGES.md +86 -5
  3. package/CHANGELOG.md +57 -0
  4. package/README.md +110 -3
  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/index.d.ts +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/lib/agent/manifest.d.ts +3 -2
  12. package/dist/lib/agent/manifest.d.ts.map +1 -1
  13. package/dist/lib/agent/manifest.js +102 -80
  14. package/dist/lib/cli/mysql-introspect.d.ts +4 -9
  15. package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
  16. package/dist/lib/cli/mysql-introspect.js +8 -7
  17. package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
  18. package/dist/lib/cli/pg-introspect.js +0 -1
  19. package/dist/lib/cli/schema-codegen.d.ts +3 -0
  20. package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
  21. package/dist/lib/cli/schema-codegen.js +3 -0
  22. package/dist/lib/cli/tables-codegen.d.ts +8 -1
  23. package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
  24. package/dist/lib/cli/tables-codegen.js +100 -118
  25. package/dist/lib/condition-methods.d.ts +14 -4
  26. package/dist/lib/condition-methods.d.ts.map +1 -1
  27. package/dist/lib/condition-methods.js +18 -4
  28. package/dist/lib/db.d.ts +7 -2
  29. package/dist/lib/db.d.ts.map +1 -1
  30. package/dist/lib/db.js +24 -6
  31. package/dist/lib/dialect.d.ts +2 -1
  32. package/dist/lib/dialect.d.ts.map +1 -1
  33. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  34. package/dist/lib/engine/bulk/bulk-upsert.js +44 -36
  35. package/dist/lib/engine/rest/delete.d.ts.map +1 -1
  36. package/dist/lib/engine/rest/get.d.ts.map +1 -1
  37. package/dist/lib/engine/rest/get.js +5 -4
  38. package/dist/lib/engine/rest/insert.d.ts.map +1 -1
  39. package/dist/lib/engine/rest/insert.js +1 -1
  40. package/dist/lib/engine/rest/update.d.ts.map +1 -1
  41. package/dist/lib/engine/rest/update.js +26 -19
  42. package/dist/lib/engine/search/aggregations.d.ts +33 -0
  43. package/dist/lib/engine/search/aggregations.d.ts.map +1 -0
  44. package/dist/lib/engine/search/aggregations.js +109 -0
  45. package/dist/lib/engine/search/conditions.d.ts +31 -0
  46. package/dist/lib/engine/search/conditions.d.ts.map +1 -0
  47. package/dist/lib/engine/search/conditions.js +131 -0
  48. package/dist/lib/engine/search/fields.d.ts +49 -0
  49. package/dist/lib/engine/search/fields.d.ts.map +1 -0
  50. package/dist/lib/engine/search/fields.js +142 -0
  51. package/dist/lib/engine/search/join-families.d.ts +35 -0
  52. package/dist/lib/engine/search/join-families.d.ts.map +1 -0
  53. package/dist/lib/engine/search/join-families.js +345 -0
  54. package/dist/lib/engine/search/joins.d.ts +55 -0
  55. package/dist/lib/engine/search/joins.d.ts.map +1 -0
  56. package/dist/lib/engine/search/joins.js +81 -0
  57. package/dist/lib/engine/search/order-by.d.ts +35 -0
  58. package/dist/lib/engine/search/order-by.d.ts.map +1 -0
  59. package/dist/lib/engine/search/order-by.js +104 -0
  60. package/dist/lib/engine/search/search.d.ts.map +1 -1
  61. package/dist/lib/engine/search/search.js +106 -791
  62. package/dist/lib/engine/validate.d.ts +0 -5
  63. package/dist/lib/engine/validate.d.ts.map +1 -1
  64. package/dist/lib/engine/write-helpers.d.ts +25 -17
  65. package/dist/lib/engine/write-helpers.d.ts.map +1 -1
  66. package/dist/lib/engine/write-helpers.js +86 -44
  67. package/dist/lib/errors.d.ts +24 -0
  68. package/dist/lib/errors.d.ts.map +1 -1
  69. package/dist/lib/errors.js +59 -0
  70. package/dist/lib/read-access.d.ts +12 -1
  71. package/dist/lib/read-access.d.ts.map +1 -1
  72. package/dist/lib/read-access.js +20 -4
  73. package/dist/lib/schema/helpers.d.ts +0 -15
  74. package/dist/lib/schema/helpers.d.ts.map +1 -1
  75. package/dist/lib/schema/helpers.js +7 -4
  76. package/dist/lib/schema/search.d.ts.map +1 -1
  77. package/dist/lib/schema/search.js +107 -128
  78. package/dist/lib/sql-api.d.ts.map +1 -1
  79. package/dist/lib/table-helpers.d.ts.map +1 -1
  80. package/dist/lib/table-helpers.js +48 -0
  81. package/dist/lib/tenant.d.ts +66 -5
  82. package/dist/lib/tenant.d.ts.map +1 -1
  83. package/dist/lib/tenant.js +209 -48
  84. package/dist/routes/auto/plugin.d.ts.map +1 -1
  85. package/dist/routes/auto/plugin.js +6 -0
  86. package/dist/routes/auto/route-helpers.d.ts +0 -7
  87. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  88. package/dist/routes/auto/route-helpers.js +58 -33
  89. package/dist/routes/auto/search.routes.js +0 -1
  90. package/dist/types/plugin.d.ts +19 -0
  91. package/dist/types/plugin.d.ts.map +1 -1
  92. package/dist/types/table.d.ts +1 -1
  93. package/dist/types/tenant.d.ts +26 -2
  94. package/dist/types/tenant.d.ts.map +1 -1
  95. package/docs/GLOSSARY.md +30 -0
  96. package/docs/adr/0006-raw-db-errors.md +2 -1
  97. package/docs/adr/0010-joins-do-not-run-route-guards.md +4 -1
  98. package/docs/adr/0012-anti-duplication-tooling.md +59 -0
  99. package/docs/adr/0013-sanitized-db-errors.md +114 -0
  100. package/docs/adr/README.md +4 -1
  101. package/package.json +29 -2
@@ -3,31 +3,113 @@ import { httpError } from './errors.js';
3
3
  function isIndirect(scope) {
4
4
  return 'through' in scope;
5
5
  }
6
+ function isAnyOf(scope) {
7
+ return 'anyOf' in scope;
8
+ }
9
+ /**
10
+ * The tenant columns living on the scoped table itself, in declaration order: one for a direct
11
+ * scope, several for an `anyOf` one. Never called for an indirect scope, whose column belongs to
12
+ * the through table.
13
+ */
14
+ function ownColumns(scope) {
15
+ return isAnyOf(scope) ? scope.anyOf : [scope.column];
16
+ }
17
+ /**
18
+ * Whether this request could touch a scoped row at all: the addressed table is scoped, or one of
19
+ * the tables it can reach through a declared relation is.
20
+ *
21
+ * Relations are what makes the second case necessary — a relation is a read grant on its target
22
+ * and a write join a write grant, so a deliberately public host must still carry its neighbours'
23
+ * scopes into the joins and secondaries it drives. One level is the whole reach: joins resolve
24
+ * from the addressed table's own `allowedReadJoins`/`allowedWriteJoins` and never chain further.
25
+ *
26
+ * When nothing scoped is in reach, `getTenantId` is not called at all — a request that cannot
27
+ * read or write a scoped row should not pay for resolving a tenant, and consumer code that
28
+ * assumes an authenticated request should not run on routes that never needed one.
29
+ */
30
+ function scopeInReach(tableConf, dbTables) {
31
+ if (tableConf.tenantScope)
32
+ return true;
33
+ if (!dbTables)
34
+ return false;
35
+ const relations = [...(tableConf.allowedReadJoins ?? []), ...(tableConf.allowedWriteJoins ?? [])];
36
+ return relations.some((join) => dbTables[join.joinSchema.tableName]?.tenantScope !== undefined);
37
+ }
6
38
  export async function resolveTenant(options, tableConf, request) {
7
39
  if (!options.getTenantId)
8
40
  return undefined;
9
- if (!tableConf.tenantScope)
41
+ if (!scopeInReach(tableConf, options.DbTables))
10
42
  return undefined;
11
43
  const raw = await options.getTenantId(request);
12
44
  if (raw == null)
13
45
  return undefined; // admin
14
46
  const ids = Array.isArray(raw) ? raw : [raw];
47
+ // `scope` stays undefined for an unscoped host: the ids still travel, so the tables it reaches
48
+ // enforce their own scopes, while the host itself keeps no filter.
15
49
  return { ids, scope: tableConf.tenantScope };
16
50
  }
51
+ /**
52
+ * The read predicate for a scope, as a fragment every caller appends to its own WHERE.
53
+ *
54
+ * An `anyOf` scope returns an OR-mode builder — the row is visible when any listed column holds
55
+ * a tenant id — and every caller appends it, so the group keeps its own parentheses. A NULL
56
+ * column simply fails to match, which is the wanted behaviour: the other party still decides.
57
+ */
17
58
  export function buildTenantCondition(db, scope, tenantIds, ownTable) {
18
- const col = scope.column;
19
- let qualifier;
20
59
  if (isIndirect(scope)) {
21
60
  const throughTable = scope.through.schema.tableName;
22
- qualifier = `${db.qi(throughTable)}.${db.qi(col)}`;
61
+ const cb = new ConditionBuilder('AND', db.cbDialect);
62
+ cb.isIn(`${db.qi(throughTable)}.${db.qi(scope.column)}`, tenantIds);
63
+ return cb;
23
64
  }
24
- else {
25
- // Qualify with the table the scope column lives on: the statement may carry
26
- // joins, and a bare column shared with a joined table would be ambiguous.
27
- qualifier = `${db.qi(ownTable)}.${db.qi(col)}`;
65
+ // Qualify with the table the scope columns live on: the statement may carry
66
+ // joins, and a bare column shared with a joined table would be ambiguous.
67
+ const cols = ownColumns(scope);
68
+ const cb = new ConditionBuilder(cols.length > 1 ? 'OR' : 'AND', db.cbDialect);
69
+ for (const col of cols) {
70
+ cb.isIn(`${db.qi(ownTable)}.${db.qi(col)}`, tenantIds);
28
71
  }
72
+ return cb;
73
+ }
74
+ /**
75
+ * Re-target a tenant context onto a related table: the caller's tenant ids paired with that
76
+ * table's OWN scope. A child table declares its own `tenantScope`, which may differ in shape
77
+ * from the main table's, so a write to it must be checked against its own column/through-FK.
78
+ *
79
+ * Returns undefined for the two cases that must stay unscoped — no tenant context (admin) and
80
+ * a table that declares no scope — mirroring `appendJoinTenantScope` on the read side.
81
+ */
82
+ export function tenantForTable(tenant, tableConf) {
83
+ const scope = tableConf?.tenantScope;
84
+ if (!tenant || !scope)
85
+ return undefined;
86
+ return { ids: tenant.ids, scope };
87
+ }
88
+ /**
89
+ * Tenant guard as a self-contained boolean fragment, for the positions where the scope cannot
90
+ * be expressed as an extra INNER JOIN: a child DELETE, a LEFT JOIN's ON clause, and the
91
+ * correlated subquery of an aggregation. A direct scope compares the table's own column; an
92
+ * indirect one tests the through table with a correlated EXISTS rather than a join.
93
+ *
94
+ * `tableRef` is the name the scoped rows go by in that statement — the table name in a DELETE,
95
+ * the join alias in an ON clause, the subquery alias inside a correlated SELECT. Returns
96
+ * undefined without a tenant context (admin) or when the table carries no scope.
97
+ */
98
+ export function buildTenantRowGuard(db, tenant, tableRef) {
99
+ const scope = tenant?.scope;
100
+ if (!tenant || !scope)
101
+ return undefined;
29
102
  const cb = new ConditionBuilder('AND', db.cbDialect);
30
- cb.isIn(qualifier, tenantIds);
103
+ if (!isIndirect(scope)) {
104
+ cb.append(buildTenantCondition(db, scope, tenant.ids, tableRef));
105
+ return cb;
106
+ }
107
+ const { through, column } = scope;
108
+ const throughRef = db.qi(through.schema.tableName);
109
+ const markers = tenant.ids.map(() => '?').join(', ');
110
+ cb.raw(`EXISTS (SELECT 1 FROM ${throughRef} WHERE ` +
111
+ `${throughRef}.${db.qi(through.foreignField)} = ${db.qi(tableRef)}.${db.qi(through.localField)} AND ` +
112
+ `${throughRef}.${db.qi(column)} IN (${markers}))`, tenant.ids);
31
113
  return cb;
32
114
  }
33
115
  export function buildTenantJoin(db, scope, mainTableName) {
@@ -36,6 +118,30 @@ export function buildTenantJoin(db, scope, mainTableName) {
36
118
  const foreignField = scope.through.foreignField;
37
119
  return `INNER JOIN ${db.qi(throughTable)} ON ${db.qi(mainTableName)}.${db.qi(localField)} = ${db.qi(throughTable)}.${db.qi(foreignField)}`;
38
120
  }
121
+ /**
122
+ * The insert-side rule for an `anyOf` scope: the payload must anchor the row itself.
123
+ *
124
+ * Nothing is auto-injected — with several owner columns the plugin cannot know which party the
125
+ * caller is meant to be, and guessing would either invent a relationship or refuse a legitimate
126
+ * one. So the record must name at least one listed column, and at least one of the named ones
127
+ * must hold a tenant id. The *other* parties are deliberately left alone: a swap request points
128
+ * at a colleague by definition, and requiring every listed column to match would make the shape
129
+ * unusable.
130
+ *
131
+ * @testonly Exported only so unit tests can exercise it directly.
132
+ */
133
+ export function assertTenantAnchor(record, scope, tenantIds) {
134
+ const present = scope.anyOf.filter((col) => record[col] != null);
135
+ if (!present.length) {
136
+ throw httpError(400, `Missing tenant anchor: one of ${scope.anyOf.join(', ')} must be set`);
137
+ }
138
+ if (!present.some((col) => tenantIds.includes(record[col]))) {
139
+ throw httpError(403, 'Access denied: tenant value does not match');
140
+ }
141
+ }
142
+ /** Writes the tenant value into a record according to its scope.
143
+ * @testonly Exported only so unit tests can exercise it directly.
144
+ */
39
145
  export function injectTenantValue(record, scope, tenantIds) {
40
146
  if (isIndirect(scope))
41
147
  return;
@@ -52,6 +158,9 @@ export function injectTenantValue(record, scope, tenantIds) {
52
158
  }
53
159
  throw httpError(400, 'Ambiguous tenant: specify the tenant value');
54
160
  }
161
+ /** Checks that an indirect-scope FK really belongs to the tenant.
162
+ * @testonly Exported only so unit tests can exercise it directly.
163
+ */
55
164
  export async function validateTenantFK(db, scope, tenantIds, fkValues) {
56
165
  if (!fkValues.length)
57
166
  return;
@@ -75,16 +184,18 @@ export async function validateTenantFK(db, scope, tenantIds, fkValues) {
75
184
  }
76
185
  /**
77
186
  * Build WHERE clause for DELETE with tenant filtering.
78
- * Handles both direct (simple AND) and indirect (subquery via JOIN) scopes.
187
+ * Handles both direct (simple AND) and indirect (subquery via JOIN) scopes; a tenant whose
188
+ * addressed table declares no scope yields the plain primary-key clause.
79
189
  */
80
190
  export function buildTenantDeleteWhere(db, tableName, pkCol, pkValue, tenant) {
81
- if (isIndirect(tenant.scope)) {
191
+ const scope = tenant.scope;
192
+ if (scope && isIndirect(scope)) {
82
193
  const innerCb = new ConditionBuilder('AND', db.cbDialect);
83
194
  innerCb.isIn(`${db.qi(tableName)}.${db.qi(pkCol)}`, (Array.isArray(pkValue) ? pkValue : [pkValue]));
84
- innerCb.append(buildTenantCondition(db, tenant.scope, tenant.ids, tableName));
195
+ innerCb.append(buildTenantCondition(db, scope, tenant.ids, tableName));
85
196
  const innerWhere = innerCb.build(1, db.ph);
86
197
  const values = innerCb.getValues();
87
- const joinSql = buildTenantJoin(db, tenant.scope, tableName);
198
+ const joinSql = buildTenantJoin(db, scope, tableName);
88
199
  const where = `${db.qi(pkCol)} IN (SELECT ${db.qi(tableName)}.${db.qi(pkCol)} FROM ${db.qi(tableName)} ${joinSql} WHERE ${innerWhere})`;
89
200
  return { where, values };
90
201
  }
@@ -95,7 +206,8 @@ export function buildTenantDeleteWhere(db, tableName, pkCol, pkValue, tenant) {
95
206
  else {
96
207
  cb.isEqual(db.qi(pkCol), pkValue);
97
208
  }
98
- cb.append(buildTenantCondition(db, tenant.scope, tenant.ids, tableName));
209
+ if (scope)
210
+ cb.append(buildTenantCondition(db, scope, tenant.ids, tableName));
99
211
  const where = cb.build(1, db.ph);
100
212
  const values = cb.getValues();
101
213
  return { where, values };
@@ -112,7 +224,8 @@ export function buildTenantDeleteWhere(db, tableName, pkCol, pkValue, tenant) {
112
224
  * any conflict column are skipped (they can only INSERT, never hit a conflict).
113
225
  */
114
226
  export async function assertTenantOwnsConflicts(db, tenant, tableName, conflictCols, records) {
115
- if (!tenant || !conflictCols.length)
227
+ const scope = tenant?.scope;
228
+ if (!tenant || !scope || !conflictCols.length)
116
229
  return;
117
230
  // Collect distinct, fully-specified conflict-key tuples.
118
231
  const tuples = [];
@@ -153,19 +266,35 @@ export async function assertTenantOwnsConflicts(db, tenant, tableName, conflictC
153
266
  }
154
267
  // Tenant-mismatch clause: rows whose tenant is NOT one the caller owns.
155
268
  let joinSql = '';
156
- let tenantCol;
157
- if (isIndirect(tenant.scope)) {
158
- joinSql = ` ${buildTenantJoin(db, tenant.scope, tableName)}`;
159
- tenantCol = `${db.qi(tenant.scope.through.schema.tableName)}.${db.qi(tenant.scope.column)}`;
269
+ let tenantCols;
270
+ if (isIndirect(scope)) {
271
+ joinSql = ` ${buildTenantJoin(db, scope, tableName)}`;
272
+ tenantCols = [`${db.qi(scope.through.schema.tableName)}.${db.qi(scope.column)}`];
273
+ }
274
+ else {
275
+ tenantCols = ownColumns(scope).map((c) => `${qTable}.${db.qi(c)}`);
276
+ }
277
+ const inClause = (col) => {
278
+ const phs = tenant.ids.map((id) => {
279
+ values.push(id);
280
+ return db.ph(values.length);
281
+ });
282
+ return `${col} IN (${phs.join(', ')})`;
283
+ };
284
+ let mismatchSql;
285
+ if (tenantCols.length === 1) {
286
+ mismatchSql = `${tenantCols[0]} NOT IN (${tenant.ids.map((id) => {
287
+ values.push(id);
288
+ return db.ph(values.length);
289
+ }).join(', ')})`;
160
290
  }
161
291
  else {
162
- tenantCol = `${qTable}.${db.qi(tenant.scope.column)}`;
292
+ // A NULL party makes `IN` return NULL, and `NOT NULL` is not TRUE — a plain negation would
293
+ // let a row with one party NULL and the other owned by a stranger pass the probe unflagged.
294
+ // COALESCE turns "unknown" into "not visible to me", which is what the read side already
295
+ // does implicitly by never matching a NULL.
296
+ mismatchSql = `NOT COALESCE(${tenantCols.map(inClause).join(' OR ')}, FALSE)`;
163
297
  }
164
- const tenantPhs = tenant.ids.map((id) => {
165
- values.push(id);
166
- return db.ph(values.length);
167
- });
168
- const mismatchSql = `${tenantCol} NOT IN (${tenantPhs.join(', ')})`;
169
298
  const sql = `SELECT 1 FROM ${qTable}${joinSql} WHERE ${matchSql} AND ${mismatchSql} LIMIT 1`;
170
299
  const r = await db.query(sql, values);
171
300
  if (r.rows.length > 0) {
@@ -182,24 +311,27 @@ export async function assertTenantOwnsConflicts(db, tenant, tableName, conflictC
182
311
  * `updateFields` keys are DB column names (snake_case), matching `through.localField`.
183
312
  */
184
313
  export async function enforceTenantFKOnUpdate(db, tenant, updateFields) {
185
- if (!tenant || !isIndirect(tenant.scope))
314
+ if (!tenant?.scope || !isIndirect(tenant.scope))
186
315
  return;
187
- const localCol = tenant.scope.through.localField;
316
+ const scope = tenant.scope;
317
+ const localCol = scope.through.localField;
188
318
  if (!(localCol in updateFields))
189
319
  return;
190
320
  const newValue = updateFields[localCol];
191
321
  if (newValue == null)
192
322
  return;
193
- await validateTenantFK(db, tenant.scope, tenant.ids, [newValue]);
323
+ await validateTenantFK(db, scope, tenant.ids, [newValue]);
194
324
  }
195
325
  /**
196
- * Mutates `fields` in-place: removes the tenant column from a SET payload (direct scopes only).
197
- * No-op for indirect or no scope.
326
+ * Mutates `fields` in-place: removes the tenant columns from a SET payload (scopes whose columns
327
+ * live on the table itself). An `anyOf` scope drops *every* listed column — neither party can be
328
+ * reassigned, the same spirit as "a tenant cannot move its own row". No-op for indirect scopes.
198
329
  */
199
330
  export function stripTenantColumn(fields, scope) {
200
331
  if (isIndirect(scope))
201
332
  return;
202
- delete fields[scope.column];
333
+ for (const col of ownColumns(scope))
334
+ delete fields[col];
203
335
  }
204
336
  /**
205
337
  * For insert/bulk-upsert: enforce tenant on records that are about to be written.
@@ -208,15 +340,38 @@ export function stripTenantColumn(fields, scope) {
208
340
  * Throws 400 (ambiguous tenant) or 403 (mismatch) on violation. No-op when `tenant` is undefined.
209
341
  */
210
342
  export async function enforceTenantOnWrites(db, tenant, records) {
211
- if (!tenant)
343
+ const scope = tenant?.scope;
344
+ if (!tenant || !scope)
212
345
  return;
213
- if (isIndirect(tenant.scope)) {
214
- const fkCol = tenant.scope.through.localField;
215
- await validateTenantFK(db, tenant.scope, tenant.ids, records.map((r) => r[fkCol]));
346
+ if (isIndirect(scope)) {
347
+ const fkCol = scope.through.localField;
348
+ await validateTenantFK(db, scope, tenant.ids, records.map((r) => r[fkCol]));
349
+ return;
350
+ }
351
+ if (isAnyOf(scope)) {
352
+ for (const r of records)
353
+ assertTenantAnchor(r, scope, tenant.ids);
216
354
  return;
217
355
  }
218
356
  for (const r of records)
219
- injectTenantValue(r, tenant.scope, tenant.ids);
357
+ injectTenantValue(r, scope, tenant.ids);
358
+ }
359
+ /**
360
+ * Columns an UPSERT must leave alone when it lands on an existing row.
361
+ *
362
+ * `stripTenantColumn` does this for a plain UPDATE, but an upsert's payload doubles as the
363
+ * INSERT branch, where the owner columns are exactly what anchors a new row — they cannot simply
364
+ * be dropped. So they are excluded from the `DO UPDATE SET` instead: state which party you are
365
+ * to create the row, and that statement is ignored when the row already exists.
366
+ *
367
+ * Only `anyOf` scopes need this. A direct scope injects the caller's own tenant, so writing it
368
+ * back onto a row the conflict guard already proved theirs is a no-op; an indirect one keeps its
369
+ * owner on another table entirely.
370
+ */
371
+ export function tenantImmutableColumns(tenant) {
372
+ if (!tenant?.scope || !isAnyOf(tenant.scope))
373
+ return [];
374
+ return tenant.scope.anyOf;
220
375
  }
221
376
  /**
222
377
  * For update: builds the optional `extraCondition` ConditionBuilder for direct tenants.
@@ -224,13 +379,17 @@ export async function enforceTenantOnWrites(db, tenant, records) {
224
379
  * (indirect uses pre-check via `assertTenantOwnership`).
225
380
  */
226
381
  export function buildTenantUpdateExtra(db, tenant) {
227
- if (!tenant || isIndirect(tenant.scope))
382
+ const scope = tenant?.scope;
383
+ if (!scope || isIndirect(scope))
228
384
  return undefined;
229
- const cb = new ConditionBuilder('AND', db.cbDialect);
230
- if (tenant.ids.length === 1)
231
- cb.isEqual(tenant.scope.column, tenant.ids[0]);
232
- else
233
- cb.isIn(tenant.scope.column, tenant.ids);
385
+ const cols = ownColumns(scope);
386
+ const cb = new ConditionBuilder(cols.length > 1 ? 'OR' : 'AND', db.cbDialect);
387
+ for (const col of cols) {
388
+ if (tenant.ids.length === 1)
389
+ cb.isEqual(col, tenant.ids[0]);
390
+ else
391
+ cb.isIn(col, tenant.ids);
392
+ }
234
393
  return cb;
235
394
  }
236
395
  /**
@@ -239,14 +398,15 @@ export function buildTenantUpdateExtra(db, tenant) {
239
398
  * No-op for direct or no-tenant — those are enforced via `extraCondition` in the UPDATE itself.
240
399
  */
241
400
  export async function assertTenantOwnership(db, tenant, tableName, pkCol, pkValue) {
242
- if (!tenant || !isIndirect(tenant.scope))
401
+ if (!tenant?.scope || !isIndirect(tenant.scope))
243
402
  return;
403
+ const scope = tenant.scope;
244
404
  const cb = new ConditionBuilder('AND', db.cbDialect);
245
405
  const cols = Array.isArray(pkCol) ? pkCol : [pkCol];
246
406
  const vals = Array.isArray(pkValue) ? pkValue : [pkValue];
247
407
  cols.forEach((c, i) => cb.isEqual(`${db.qi(tableName)}.${db.qi(c)}`, vals[i]));
248
- cb.append(buildTenantCondition(db, tenant.scope, tenant.ids, tableName));
249
- const sql = `SELECT 1 FROM ${db.qi(tableName)} ${buildTenantJoin(db, tenant.scope, tableName)} WHERE ${cb.build(1, db.ph)} LIMIT 1`;
408
+ cb.append(buildTenantCondition(db, scope, tenant.ids, tableName));
409
+ const sql = `SELECT 1 FROM ${db.qi(tableName)} ${buildTenantJoin(db, scope, tableName)} WHERE ${cb.build(1, db.ph)} LIMIT 1`;
250
410
  const r = await db.query(sql, cb.getValues());
251
411
  if (r.rows.length === 0) {
252
412
  throw httpError(404, 'Record not found');
@@ -259,13 +419,14 @@ export async function assertTenantOwnership(db, tenant, tableName, pkCol, pkValu
259
419
  * `beforeBulkDelete` hook, so user code never runs against rows the caller cannot access.
260
420
  */
261
421
  export async function assertTenantOwnsAll(db, tenant, tableName, pkCol, ids) {
262
- if (!tenant || !ids.length)
422
+ const scope = tenant?.scope;
423
+ if (!tenant || !scope || !ids.length)
263
424
  return;
264
425
  const qualifiedPk = `${db.qi(tableName)}.${db.qi(pkCol)}`;
265
426
  const cb = new ConditionBuilder('AND', db.cbDialect);
266
427
  cb.isIn(qualifiedPk, ids);
267
- cb.append(buildTenantCondition(db, tenant.scope, tenant.ids, tableName));
268
- const join = isIndirect(tenant.scope) ? ` ${buildTenantJoin(db, tenant.scope, tableName)}` : '';
428
+ cb.append(buildTenantCondition(db, scope, tenant.ids, tableName));
429
+ const join = isIndirect(scope) ? ` ${buildTenantJoin(db, scope, tableName)}` : '';
269
430
  const sql = `SELECT DISTINCT ${qualifiedPk} AS pk FROM ${db.qi(tableName)}${join} WHERE ${cb.build(1, db.ph)}`;
270
431
  const r = await db.query(sql, cb.getValues());
271
432
  const uniqueRequested = new Set(ids.map((v) => String(v))).size;
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAgB,MAAM,SAAS,CAAC;AAc7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;kCAuD/C,eAAe,WACf,mBAAmB,KAC3B,OAAO,CAAC,IAAI,CAAC;AAHhB,wBA6DoC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAgB,MAAM,SAAS,CAAC;AAc7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;kCAuD/C,eAAe,WACf,mBAAmB,KAC3B,OAAO,CAAC,IAAI,CAAC;AAHhB,wBAsEoC"}
@@ -82,10 +82,16 @@ export default fp(async function fastifyAutoSqlApi(fastify, options) {
82
82
  });
83
83
  }
84
84
  const statusCode = error.statusCode || 500;
85
+ // A sanitized error carries the request id so the client can point at the log line
86
+ // holding the real cause, and — under `exposeDebugInfo` — the description of that
87
+ // cause (ADR 0013). Both are additive: the fields above never change shape.
88
+ const sanitized = error;
85
89
  reply.status(statusCode).send({
86
90
  statusCode,
87
91
  error: error.name || 'Error',
88
92
  message: error.message,
93
+ ...(sanitized.requestId ? { requestId: sanitized.requestId } : {}),
94
+ ...(sanitized.debugInfo ? { debugInfo: sanitized.debugInfo } : {}),
89
95
  });
90
96
  });
91
97
  if (options.swagger) {
@@ -1,7 +1,5 @@
1
1
  import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2
2
  import type { ITable, SqlApiPluginOptions, DbTables, TableOperation } from '../../types.js';
3
- type RequestHook = (request: FastifyRequest, reply: FastifyReply) => Promise<void | FastifyReply>;
4
- export declare function mergeOnRequests(options: SqlApiPluginOptions, tableConf: ITable): RequestHook[];
5
3
  export declare function buildWriteDescription(action: string, tableName: string, tableConf: ITable): string;
6
4
  interface RouteSchema {
7
5
  body?: any;
@@ -31,11 +29,6 @@ export interface AutoRouteSpec {
31
29
  * the registrar will reply.status(successStatus).send(returnValue). */
32
30
  handle: (fastify: FastifyInstance, tableName: string, tableConf: ITable, request: FastifyRequest, reply: FastifyReply) => Promise<unknown>;
33
31
  }
34
- /**
35
- * Iterates over every table in `options.DbTables` and registers one Fastify route for it
36
- * following `spec`. Centralizes onRequest hook merging, tags, schema response wrapping,
37
- * default reply when handler returns a value.
38
- */
39
32
  export declare function registerForAllTables(fastify: FastifyInstance, options: SqlApiPluginOptions, spec: AutoRouteSpec): Promise<void>;
40
33
  export {};
41
34
  //# sourceMappingURL=route-helpers.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"route-helpers.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/route-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAG7E,OAAO,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAE5F,KAAK,WAAW,GAAG,CAAC,OAAO,EAAE,cAAc,EAAE,KAAK,EAAE,YAAY,KAAK,OAAO,CAAC,IAAI,GAAG,YAAY,CAAC,CAAC;AAElG,wBAAgB,eAAe,CAAC,OAAO,EAAE,mBAAmB,EAAE,SAAS,EAAE,MAAM,GAAG,WAAW,EAAE,CAE9F;AAED,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAQlG;AAED,UAAU,WAAW;IAEnB,IAAI,CAAC,EAAE,GAAG,CAAC;IAEX,MAAM,CAAC,EAAE,GAAG,CAAC;IAEb,WAAW,CAAC,EAAE,GAAG,CAAC;IAElB,QAAQ,CAAC,EAAE,GAAG,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,iGAAiG;IACjG,SAAS,EAAE,cAAc,CAAC;IAC1B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC1C,gFAAgF;IAChF,GAAG,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,CAAC;IACnC,aAAa,EAAE,GAAG,GAAG,GAAG,CAAC;IACzB,wIAAwI;IACxI,OAAO,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,WAAW,CAAC;IACnF,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,KAAK,MAAM,CAAC;IACpF;2EACuE;IACvE,MAAM,EAAE,CACN,OAAO,EAAE,eAAe,EACxB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,cAAc,EACvB,KAAK,EAAE,YAAY,KAChB,OAAO,CAAC,OAAO,CAAC,CAAC;CACvB;AAED;;;;GAIG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,EAC5B,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,IAAI,CAAC,CAkDf"}
1
+ {"version":3,"file":"route-helpers.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/route-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAI7E,OAAO,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAQ5F,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAQlG;AAED,UAAU,WAAW;IAEnB,IAAI,CAAC,EAAE,GAAG,CAAC;IAEX,MAAM,CAAC,EAAE,GAAG,CAAC;IAEb,WAAW,CAAC,EAAE,GAAG,CAAC;IAElB,QAAQ,CAAC,EAAE,GAAG,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,iGAAiG;IACjG,SAAS,EAAE,cAAc,CAAC;IAC1B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC1C,gFAAgF;IAChF,GAAG,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,CAAC;IACnC,aAAa,EAAE,GAAG,GAAG,GAAG,CAAC;IACzB,wIAAwI;IACxI,OAAO,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,WAAW,CAAC;IACnF,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,KAAK,MAAM,CAAC;IACpF;2EACuE;IACvE,MAAM,EAAE,CACN,OAAO,EAAE,eAAe,EACxB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,cAAc,EACvB,KAAK,EAAE,YAAY,KAChB,OAAO,CAAC,OAAO,CAAC,CAAC;CACvB;AA6DD,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,EAC5B,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,IAAI,CAAC,CA6Bf"}
@@ -1,6 +1,7 @@
1
1
  import { ensureSqlApiDecorator } from '../../lib/sql-api-decorator.js';
2
+ import { sanitizeRouteError } from '../../lib/errors.js';
2
3
  import { isCompositePrimary } from '../../types.js';
3
- export function mergeOnRequests(options, tableConf) {
4
+ function mergeOnRequests(options, tableConf) {
4
5
  return [...(options.onRequests || []), ...(tableConf.onRequests || [])];
5
6
  }
6
7
  export function buildWriteDescription(action, tableName, tableConf) {
@@ -17,51 +18,75 @@ export function buildWriteDescription(action, tableName, tableConf) {
17
18
  * following `spec`. Centralizes onRequest hook merging, tags, schema response wrapping,
18
19
  * default reply when handler returns a value.
19
20
  */
21
+ /**
22
+ * Whether this table is addressable by the spec's route shape.
23
+ *
24
+ * A single-PK route cannot address a composite-PK table. Skipping is the default, but when
25
+ * the consumer explicitly listed the operation it is a configuration error: failing at
26
+ * startup beats a route that silently never exists.
27
+ */
28
+ function isTableRoutable(tableName, tableConf, spec) {
29
+ // operations acts as a whitelist; when omitted every operation is exposed.
30
+ if (tableConf.operations && !tableConf.operations.includes(spec.operation))
31
+ return false;
32
+ if (!spec.singlePkOnly || !isCompositePrimary(tableConf.primary))
33
+ return true;
34
+ if (tableConf.operations?.includes(spec.operation)) {
35
+ throw new Error(`Table "${tableName}" has a composite primary key (${tableConf.primary.join(', ')}): ` +
36
+ `operation "${spec.operation}" addresses records by a single PK value and cannot be exposed. ` +
37
+ `Remove it from "operations", or handle the table via search/update or a custom route.`);
38
+ }
39
+ return false;
40
+ }
41
+ /**
42
+ * Fastify route schema for one table.
43
+ *
44
+ * Only keys that are actually defined are included: Fastify checks for key *presence*
45
+ * (not an undefined value) to decide whether to emit FSTWRN001.
46
+ */
47
+ function buildRouteSchema(tableName, tableConf, spec, schemas) {
48
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
49
+ const schema = {
50
+ tags: [`SqlAPI-${tableName}`],
51
+ summary: `${spec.summary} ${tableName}`,
52
+ description: spec.description(tableName, tableConf, schemas),
53
+ };
54
+ if (schemas.body !== undefined)
55
+ schema.body = schemas.body;
56
+ if (schemas.params !== undefined)
57
+ schema.params = schemas.params;
58
+ if (schemas.querystring !== undefined)
59
+ schema.querystring = schemas.querystring;
60
+ if (schemas.response !== undefined)
61
+ schema.response = { [spec.successStatus]: schemas.response };
62
+ return schema;
63
+ }
20
64
  export async function registerForAllTables(fastify, options, spec) {
21
65
  // Granular composition support: when a route plugin is registered without the
22
66
  // main plugin, no ancestor has decorated `sqlApi` — create it in this scope.
23
67
  ensureSqlApiDecorator(fastify, options);
24
68
  const { DbTables } = options;
25
69
  for (const [tableName, tableConf] of Object.entries(DbTables)) {
26
- // operations acts as a whitelist; when omitted every operation is exposed.
27
- if (tableConf.operations && !tableConf.operations.includes(spec.operation))
28
- continue;
29
- // Single-PK routes cannot address composite-PK tables — skip them (default) or,
30
- // when the consumer explicitly asked for the operation, fail loudly at startup.
31
- if (spec.singlePkOnly && isCompositePrimary(tableConf.primary)) {
32
- if (tableConf.operations?.includes(spec.operation)) {
33
- throw new Error(`Table "${tableName}" has a composite primary key (${tableConf.primary.join(', ')}): ` +
34
- `operation "${spec.operation}" addresses records by a single PK value and cannot be exposed. ` +
35
- `Remove it from "operations", or handle the table via search/update or a custom route.`);
36
- }
70
+ if (!isTableRoutable(tableName, tableConf, spec))
37
71
  continue;
38
- }
39
72
  const schemas = spec.schemas(DbTables, tableName, tableConf);
40
- // Only include schema keys that are actually defined: Fastify checks for key
41
- // *presence* (not undefined value) to decide whether to emit FSTWRN001.
42
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
- const schema = {
44
- tags: [`SqlAPI-${tableName}`],
45
- summary: `${spec.summary} ${tableName}`,
46
- description: spec.description(tableName, tableConf, schemas),
47
- };
48
- if (schemas.body !== undefined)
49
- schema.body = schemas.body;
50
- if (schemas.params !== undefined)
51
- schema.params = schemas.params;
52
- if (schemas.querystring !== undefined)
53
- schema.querystring = schemas.querystring;
54
- if (schemas.response !== undefined)
55
- schema.response = { [spec.successStatus]: schemas.response };
56
73
  fastify.route({
57
74
  method: spec.method,
58
75
  url: spec.url(tableConf),
59
- schema,
76
+ schema: buildRouteSchema(tableName, tableConf, spec, schemas),
60
77
  onRequest: mergeOnRequests(options, tableConf),
61
78
  handler: async (request, reply) => {
62
- const result = await spec.handle(fastify, tableName, tableConf, request, reply);
63
- if (!reply.sent)
64
- reply.status(spec.successStatus).send(result);
79
+ try {
80
+ const result = await spec.handle(fastify, tableName, tableConf, request, reply);
81
+ if (!reply.sent)
82
+ reply.status(spec.successStatus).send(result);
83
+ }
84
+ catch (err) {
85
+ const safe = sanitizeRouteError(err, request.id, options.exposeDebugInfo);
86
+ if (safe !== err)
87
+ request.log.error({ err }, 'sqlapi: unhandled error');
88
+ throw safe;
89
+ }
65
90
  },
66
91
  });
67
92
  }
@@ -15,7 +15,6 @@ export default async function searchRoutes(fastify, options) {
15
15
  }),
16
16
  summary: 'Search',
17
17
  description: (name, _tc, schemas) => {
18
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
18
  const body = schemas.body;
20
19
  const multiAliases = Object.keys(body?.properties?.joinMultiple?.properties || {});
21
20
  const leftAliases = Object.keys(body?.properties?.joinLeft?.properties || {});
@@ -35,6 +35,25 @@ export interface SqlApiPluginOptions {
35
35
  * routes run behind the same global `onRequests` hooks as the data routes.
36
36
  */
37
37
  agentManifest?: boolean;
38
+ /**
39
+ * Attach a `debugInfo` payload — the driver message, its `code`/`constraint`/`detail`,
40
+ * the stack — to the `500` an error the plugin did not raise itself produces (a
41
+ * constraint violation, a hook throwing without a `statusCode`).
42
+ *
43
+ * **Development only**: it hands whoever triggers an error a description of the schema,
44
+ * and the auto routes are open by default (ADR 0002). Off unless set. It is additive —
45
+ * `statusCode`, `error`, `message` and `requestId` keep the shape they have in production,
46
+ * so client-side error handling does not fork between environments.
47
+ *
48
+ * Without it the response carries no database detail, and the original error is still
49
+ * reachable: logged through `request.log.error` under the same `reqId` the body reports as
50
+ * `requestId`, and attached as the `cause` of the error a consumer `setErrorHandler`
51
+ * receives — which is where policy such as unique-violation → `409` belongs (ADR 0006).
52
+ *
53
+ * Nothing derives this: not `debug` (whoever registers the plugin decides that one, and
54
+ * may wire it to an environment variable), not `NODE_ENV`. See ADR 0013.
55
+ */
56
+ exposeDebugInfo?: boolean;
38
57
  }
39
58
  /** Default row cap for search (page size and no-paginator LIMIT). Override via `maxItemsPerPage`. */
40
59
  export declare const DEFAULT_MAX_ITEMS_PER_PAGE = 1000;
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../src/types/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAC5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAE5C,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,QAAQ,CAAC;IACnB,UAAU,CAAC,EAAE,CAAC,CAAC,OAAO,EAAE,cAAc,EAAE,KAAK,EAAE,YAAY,KAAK,OAAO,CAAC,IAAI,GAAG,YAAY,CAAC,CAAC,EAAE,CAAC;IAChG,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,GAAG,cAAc,CAAC;IACnC,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,cAAc,KAAK,QAAQ,GAAG,QAAQ,EAAE,GAAG,IAAI,GAAG,SAAS,GAC/E,OAAO,CAAC,QAAQ,GAAG,QAAQ,EAAE,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;IACtD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,qGAAqG;AACrG,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAE/C,8EAA8E;AAC9E,eAAO,MAAM,sBAAsB,OAAO,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../src/types/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAC5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAE5C,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,QAAQ,CAAC;IACnB,UAAU,CAAC,EAAE,CAAC,CAAC,OAAO,EAAE,cAAc,EAAE,KAAK,EAAE,YAAY,KAAK,OAAO,CAAC,IAAI,GAAG,YAAY,CAAC,CAAC,EAAE,CAAC;IAChG,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,GAAG,cAAc,CAAC;IACnC,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,cAAc,KAAK,QAAQ,GAAG,QAAQ,EAAE,GAAG,IAAI,GAAG,SAAS,GAC/E,OAAO,CAAC,QAAQ,GAAG,QAAQ,EAAE,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;IACtD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;;;;;;;;;;;;;;;OAiBG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED,qGAAqG;AACrG,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAE/C,8EAA8E;AAC9E,eAAO,MAAM,sBAAsB,OAAO,CAAC"}
@@ -30,7 +30,7 @@ export interface ITable<F extends Record<string, TSchema> = Record<string, TSche
30
30
  extraFilters: Record<string, TSchema>;
31
31
  /**
32
32
  * Virtual fields produced by a SQL expression. Usable like schema fields in
33
- * `filters`, `conditions` (non-dotted), `orderBy` (1-parte),
33
+ * `filters`, `conditions` (non-dotted), `orderBy` (1-part),
34
34
  * `computeMin/Max/Sum/Avg`, and (opt-in) in `selectComputed` for the main
35
35
  * response. Each entry MUST declare `type` for Swagger and validation.
36
36
  *
@@ -11,9 +11,33 @@ export interface TenantScopeIndirect {
11
11
  foreignField: string;
12
12
  };
13
13
  }
14
- export type TenantScope = TenantScopeDirect | TenantScopeIndirect;
14
+ /**
15
+ * A row owned by several parties, visible to any of them: a message (`sender_id` /
16
+ * `recipient_id`), a transfer (`from_account_id` / `to_account_id`), a shift swap. Reads test
17
+ * every listed column with an OR, so a row matches when *any* of them holds a tenant id.
18
+ *
19
+ * Writes cannot auto-inject a tenant the way a single-column scope does — the plugin has no way
20
+ * to know which party the caller is meant to be — so the payload must anchor the row itself:
21
+ * see `assertTenantAnchor`. Columns are DB column names, like `TenantScopeDirect.column`.
22
+ *
23
+ * Not combinable with `column`/`through`: an entry reached through a parent FK would need its
24
+ * own join per entry, and is rejected at `defineTable` time rather than half-supported.
25
+ */
26
+ export interface TenantScopeAnyOf {
27
+ anyOf: string[];
28
+ }
29
+ export type TenantScope = TenantScopeDirect | TenantScopeIndirect | TenantScopeAnyOf;
30
+ /**
31
+ * Who the caller is, for this request — plus how the table currently being addressed is scoped.
32
+ *
33
+ * `ids` is request-level: it is resolved once from `getTenantId` and stays valid for every table
34
+ * the request touches. `scope` is table-level and therefore **optional**: the addressed table may
35
+ * declare no scope of its own while a table it reaches through a relation does. Absent `scope`
36
+ * means "no filtering on this table", never "no tenant" — `tenantForTable` re-targets the same
37
+ * ids onto a related table's own scope.
38
+ */
15
39
  export interface TenantContext {
16
40
  ids: TenantId[];
17
- scope: TenantScope;
41
+ scope?: TenantScope;
18
42
  }
19
43
  //# sourceMappingURL=tenant.d.ts.map