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.
- package/AGENTS_BACKEND.md +52 -7
- package/BREAKING_CHANGES.md +86 -5
- package/CHANGELOG.md +57 -0
- package/README.md +110 -3
- package/dist/bin/generate-schema.js +89 -77
- package/dist/bin/generate-tables.js +72 -62
- package/dist/bin/utils.d.ts.map +1 -1
- package/dist/bin/utils.js +47 -33
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/lib/agent/manifest.d.ts +3 -2
- package/dist/lib/agent/manifest.d.ts.map +1 -1
- package/dist/lib/agent/manifest.js +102 -80
- package/dist/lib/cli/mysql-introspect.d.ts +4 -9
- package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
- package/dist/lib/cli/mysql-introspect.js +8 -7
- package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
- package/dist/lib/cli/pg-introspect.js +0 -1
- package/dist/lib/cli/schema-codegen.d.ts +3 -0
- package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
- package/dist/lib/cli/schema-codegen.js +3 -0
- package/dist/lib/cli/tables-codegen.d.ts +8 -1
- package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
- package/dist/lib/cli/tables-codegen.js +100 -118
- package/dist/lib/condition-methods.d.ts +14 -4
- package/dist/lib/condition-methods.d.ts.map +1 -1
- package/dist/lib/condition-methods.js +18 -4
- package/dist/lib/db.d.ts +7 -2
- package/dist/lib/db.d.ts.map +1 -1
- package/dist/lib/db.js +24 -6
- package/dist/lib/dialect.d.ts +2 -1
- package/dist/lib/dialect.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-upsert.js +44 -36
- package/dist/lib/engine/rest/delete.d.ts.map +1 -1
- package/dist/lib/engine/rest/get.d.ts.map +1 -1
- package/dist/lib/engine/rest/get.js +5 -4
- package/dist/lib/engine/rest/insert.d.ts.map +1 -1
- package/dist/lib/engine/rest/insert.js +1 -1
- package/dist/lib/engine/rest/update.d.ts.map +1 -1
- package/dist/lib/engine/rest/update.js +26 -19
- package/dist/lib/engine/search/aggregations.d.ts +33 -0
- package/dist/lib/engine/search/aggregations.d.ts.map +1 -0
- package/dist/lib/engine/search/aggregations.js +109 -0
- package/dist/lib/engine/search/conditions.d.ts +31 -0
- package/dist/lib/engine/search/conditions.d.ts.map +1 -0
- package/dist/lib/engine/search/conditions.js +131 -0
- package/dist/lib/engine/search/fields.d.ts +49 -0
- package/dist/lib/engine/search/fields.d.ts.map +1 -0
- package/dist/lib/engine/search/fields.js +142 -0
- package/dist/lib/engine/search/join-families.d.ts +35 -0
- package/dist/lib/engine/search/join-families.d.ts.map +1 -0
- package/dist/lib/engine/search/join-families.js +345 -0
- package/dist/lib/engine/search/joins.d.ts +55 -0
- package/dist/lib/engine/search/joins.d.ts.map +1 -0
- package/dist/lib/engine/search/joins.js +81 -0
- package/dist/lib/engine/search/order-by.d.ts +35 -0
- package/dist/lib/engine/search/order-by.d.ts.map +1 -0
- package/dist/lib/engine/search/order-by.js +104 -0
- package/dist/lib/engine/search/search.d.ts.map +1 -1
- package/dist/lib/engine/search/search.js +106 -791
- package/dist/lib/engine/validate.d.ts +0 -5
- package/dist/lib/engine/validate.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.d.ts +25 -17
- package/dist/lib/engine/write-helpers.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.js +86 -44
- package/dist/lib/errors.d.ts +24 -0
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +59 -0
- package/dist/lib/read-access.d.ts +12 -1
- package/dist/lib/read-access.d.ts.map +1 -1
- package/dist/lib/read-access.js +20 -4
- package/dist/lib/schema/helpers.d.ts +0 -15
- package/dist/lib/schema/helpers.d.ts.map +1 -1
- package/dist/lib/schema/helpers.js +7 -4
- package/dist/lib/schema/search.d.ts.map +1 -1
- package/dist/lib/schema/search.js +107 -128
- package/dist/lib/sql-api.d.ts.map +1 -1
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +48 -0
- package/dist/lib/tenant.d.ts +66 -5
- package/dist/lib/tenant.d.ts.map +1 -1
- package/dist/lib/tenant.js +209 -48
- package/dist/routes/auto/plugin.d.ts.map +1 -1
- package/dist/routes/auto/plugin.js +6 -0
- package/dist/routes/auto/route-helpers.d.ts +0 -7
- package/dist/routes/auto/route-helpers.d.ts.map +1 -1
- package/dist/routes/auto/route-helpers.js +58 -33
- package/dist/routes/auto/search.routes.js +0 -1
- package/dist/types/plugin.d.ts +19 -0
- package/dist/types/plugin.d.ts.map +1 -1
- package/dist/types/table.d.ts +1 -1
- package/dist/types/tenant.d.ts +26 -2
- package/dist/types/tenant.d.ts.map +1 -1
- package/docs/GLOSSARY.md +30 -0
- package/docs/adr/0006-raw-db-errors.md +2 -1
- package/docs/adr/0010-joins-do-not-run-route-guards.md +4 -1
- package/docs/adr/0012-anti-duplication-tooling.md +59 -0
- package/docs/adr/0013-sanitized-db-errors.md +114 -0
- package/docs/adr/README.md +4 -1
- package/package.json +29 -2
package/dist/lib/tenant.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
157
|
-
if (isIndirect(
|
|
158
|
-
joinSql = ` ${buildTenantJoin(db,
|
|
159
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
323
|
+
await validateTenantFK(db, scope, tenant.ids, [newValue]);
|
|
194
324
|
}
|
|
195
325
|
/**
|
|
196
|
-
* Mutates `fields` in-place: removes the tenant
|
|
197
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
343
|
+
const scope = tenant?.scope;
|
|
344
|
+
if (!tenant || !scope)
|
|
212
345
|
return;
|
|
213
|
-
if (isIndirect(
|
|
214
|
-
const fkCol =
|
|
215
|
-
await validateTenantFK(db,
|
|
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,
|
|
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
|
-
|
|
382
|
+
const scope = tenant?.scope;
|
|
383
|
+
if (!scope || isIndirect(scope))
|
|
228
384
|
return undefined;
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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,
|
|
249
|
-
const sql = `SELECT 1 FROM ${db.qi(tableName)} ${buildTenantJoin(db,
|
|
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
|
-
|
|
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,
|
|
268
|
-
const join = isIndirect(
|
|
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,
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
reply.
|
|
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 || {});
|
package/dist/types/plugin.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/types/table.d.ts
CHANGED
|
@@ -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-
|
|
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
|
*
|
package/dist/types/tenant.d.ts
CHANGED
|
@@ -11,9 +11,33 @@ export interface TenantScopeIndirect {
|
|
|
11
11
|
foreignField: string;
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
|
-
|
|
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
|
|
41
|
+
scope?: TenantScope;
|
|
18
42
|
}
|
|
19
43
|
//# sourceMappingURL=tenant.d.ts.map
|