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