fastify-auto-sqlapi 0.2.1 → 0.2.3
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.md +50 -0
- package/AGENTS_BACKEND.md +265 -7
- package/AGENTS_FRONTEND.md +3 -3
- package/BREAKING_CHANGES.md +156 -0
- package/CHANGELOG.md +265 -19
- package/README.md +260 -983
- package/dist/bin/generate-schema.js +49 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/agent/manifest.d.ts +4 -1
- package/dist/lib/agent/manifest.d.ts.map +1 -1
- package/dist/lib/agent/manifest.js +15 -3
- package/dist/lib/cli/mysql-introspect.d.ts +28 -0
- package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
- package/dist/lib/cli/mysql-introspect.js +49 -16
- package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
- package/dist/lib/cli/pg-introspect.js +53 -14
- package/dist/lib/cli/schema-codegen.d.ts +2 -1
- package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
- package/dist/lib/cli/schema-codegen.js +17 -4
- package/dist/lib/cli/tables-codegen.d.ts +4 -0
- package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
- package/dist/lib/cli/tables-codegen.js +125 -27
- package/dist/lib/engine/bulk/bulk-upsert.js +2 -2
- package/dist/lib/engine/read-hooks.d.ts +19 -0
- package/dist/lib/engine/read-hooks.d.ts.map +1 -0
- package/dist/lib/engine/read-hooks.js +20 -0
- package/dist/lib/engine/rest/get.d.ts.map +1 -1
- package/dist/lib/engine/rest/get.js +5 -2
- 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 +14 -10
- package/dist/lib/engine/search/join-families.d.ts +11 -2
- package/dist/lib/engine/search/join-families.d.ts.map +1 -1
- package/dist/lib/engine/search/join-families.js +11 -13
- package/dist/lib/engine/search/search.d.ts.map +1 -1
- package/dist/lib/engine/search/search.js +9 -3
- package/dist/lib/engine/validate.d.ts +3 -3
- package/dist/lib/engine/validate.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.d.ts +7 -3
- package/dist/lib/engine/write-helpers.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.js +27 -28
- package/dist/lib/schema/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/schema/bulk-upsert.js +2 -1
- package/dist/lib/schema/get.d.ts +14 -0
- package/dist/lib/schema/get.d.ts.map +1 -0
- package/dist/lib/schema/get.js +18 -0
- package/dist/lib/schema/helpers.d.ts +21 -3
- package/dist/lib/schema/helpers.d.ts.map +1 -1
- package/dist/lib/schema/helpers.js +37 -5
- package/dist/lib/schema/insert.d.ts.map +1 -1
- package/dist/lib/schema/insert.js +7 -3
- package/dist/lib/schema/search.d.ts.map +1 -1
- package/dist/lib/schema/search.js +3 -11
- package/dist/lib/schema/update.d.ts.map +1 -1
- package/dist/lib/schema/update.js +7 -3
- package/dist/lib/sql-api.d.ts +9 -9
- package/dist/lib/sql-api.d.ts.map +1 -1
- package/dist/lib/sql-api.js +2 -1
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +123 -2
- package/dist/lib/tenant.d.ts +2 -2
- package/dist/lib/tenant.d.ts.map +1 -1
- package/dist/lib/tenant.js +7 -1
- package/dist/lib/write-access.d.ts +36 -0
- package/dist/lib/write-access.d.ts.map +1 -0
- package/dist/lib/write-access.js +58 -0
- package/dist/routes/auto/get.routes.d.ts.map +1 -1
- package/dist/routes/auto/get.routes.js +3 -2
- package/dist/routes/auto/plugin.js +2 -2
- package/dist/routes/auto/route-helpers.js +1 -1
- package/dist/routes/setup-swagger.d.ts.map +1 -0
- package/dist/{lib → routes}/sql-api-decorator.d.ts +1 -1
- package/dist/routes/sql-api-decorator.d.ts.map +1 -0
- package/dist/{lib → routes}/sql-api-decorator.js +4 -4
- package/dist/types/cli.d.ts +27 -1
- package/dist/types/cli.d.ts.map +1 -1
- package/dist/types/dml.d.ts +6 -4
- package/dist/types/dml.d.ts.map +1 -1
- package/dist/types/plugin.d.ts +24 -3
- package/dist/types/plugin.d.ts.map +1 -1
- package/dist/types/request.d.ts +24 -0
- package/dist/types/request.d.ts.map +1 -0
- package/dist/types/request.js +1 -0
- package/dist/types/schema.d.ts +15 -0
- package/dist/types/schema.d.ts.map +1 -1
- package/dist/types/search.d.ts +3 -0
- package/dist/types/search.d.ts.map +1 -1
- package/dist/types/table.d.ts +84 -11
- package/dist/types/table.d.ts.map +1 -1
- package/dist/types/validation.d.ts +3 -3
- package/dist/types/validation.d.ts.map +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -0
- package/docs/adr/0004-updates-always-open.md +2 -1
- package/docs/adr/0014-what-follows-a-write-join.md +84 -0
- package/docs/adr/0015-non-writable-columns.md +86 -0
- package/docs/adr/0016-a-view-is-a-table-config.md +92 -0
- package/docs/adr/0017-the-schema-is-the-contract.md +85 -0
- package/docs/adr/0018-one-seam-to-the-http-framework.md +68 -0
- package/docs/adr/README.md +5 -0
- package/package.json +1 -1
- package/dist/lib/setup-swagger.d.ts.map +0 -1
- package/dist/lib/sql-api-decorator.d.ts.map +0 -1
- /package/dist/{lib → routes}/setup-swagger.d.ts +0 -0
- /package/dist/{lib → routes}/setup-swagger.js +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { camelcaseObject, snakecaseRecord } from '../naming.js';
|
|
2
2
|
import { runValidation } from './validate.js';
|
|
3
|
+
import { removeWriteExcluded } from '../write-access.js';
|
|
3
4
|
import { tenantForTable, buildTenantRowGuard, enforceTenantOnWrites, assertTenantOwnsConflicts, tenantImmutableColumns, } from '../tenant.js';
|
|
4
5
|
/**
|
|
5
6
|
* Resolve the write join an alias names, or undefined when the table does not declare it.
|
|
@@ -14,26 +15,11 @@ function findWriteJoin(tableConf, alias) {
|
|
|
14
15
|
return tableConf.allowedWriteJoins?.find((j) => j.alias === alias);
|
|
15
16
|
}
|
|
16
17
|
/**
|
|
17
|
-
* Drop `excludeFromCreation`
|
|
18
|
+
* Drop `excludeFromCreation` fields from a camelCase record, in place.
|
|
18
19
|
*
|
|
19
|
-
* Exclusion is a whitelist on CLIENT input: call this on the client payload only,
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*/
|
|
23
|
-
function removeExcludedFields(record, tableConf) {
|
|
24
|
-
if (!tableConf.excludeFromCreation?.length)
|
|
25
|
-
return record;
|
|
26
|
-
const result = { ...record };
|
|
27
|
-
for (const field of tableConf.excludeFromCreation) {
|
|
28
|
-
const col = tableConf.Schema.col(field);
|
|
29
|
-
delete result[col];
|
|
30
|
-
}
|
|
31
|
-
return result;
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* Same whitelist as `removeExcludedFields`, but for a camelCase record (schema
|
|
35
|
-
* field names) and in place. Used on the client payload before the beforeInsert
|
|
36
|
-
* hook runs.
|
|
20
|
+
* Exclusion is a whitelist on CLIENT input: call this on the client payload only, BEFORE any
|
|
21
|
+
* server-side value is assigned (beforeInsert mutations, FK auto-fill), so engine- and
|
|
22
|
+
* hook-generated values on those fields reach the SQL (ADR 0005).
|
|
37
23
|
*/
|
|
38
24
|
function removeExcludedFieldsCamel(record, tableConf) {
|
|
39
25
|
if (!tableConf.excludeFromCreation?.length)
|
|
@@ -71,7 +57,9 @@ function resolveWriteJoinTarget(joinDef, dbTables, mainRecord, tenant) {
|
|
|
71
57
|
* 3. drop excludeFromCreation fields (client-input whitelist; applied BEFORE the
|
|
72
58
|
* hook so values it assigns — e.g. a server-generated id — reach the INSERT)
|
|
73
59
|
* 4. beforeInsert hook (caller can mutate the camelCase copy)
|
|
74
|
-
* 5.
|
|
60
|
+
* 5. drop non-writable fields (generated columns, writeExclude) — AFTER the hook, because
|
|
61
|
+
* the database refuses those values whoever assigned them
|
|
62
|
+
* 6. snakecaseRecord → DB format
|
|
75
63
|
*
|
|
76
64
|
* Returns both `camel` (input + hook mutations, used for FK auto-fill / afterInsert) and
|
|
77
65
|
* `snake` (DB-ready record). The original `input` is left intact.
|
|
@@ -88,6 +76,7 @@ export async function prepareInsertRecord(ctx, input, secondaries, options) {
|
|
|
88
76
|
if (ctx.tableConf.beforeInsert) {
|
|
89
77
|
await ctx.tableConf.beforeInsert(ctx.db, ctx.request, camel);
|
|
90
78
|
}
|
|
79
|
+
removeWriteExcluded(camel, ctx.tableConf, ctx.tableConf.Schema);
|
|
91
80
|
const snake = snakecaseRecord(camel, ctx.tableConf.Schema);
|
|
92
81
|
return { camel, snake };
|
|
93
82
|
}
|
|
@@ -121,23 +110,33 @@ async function forEachWriteJoin(pass, payload, writeAlias) {
|
|
|
121
110
|
* where the child is upserted, hijack one of that tenant's existing rows via the conflict key.
|
|
122
111
|
*/
|
|
123
112
|
export function processSecondaries(pass, secondaries) {
|
|
124
|
-
const { db, tableConf } = pass;
|
|
113
|
+
const { db, tableConf, request } = pass;
|
|
125
114
|
return forEachWriteJoin(pass, secondaries, async (records, target, joinDef) => {
|
|
126
115
|
const { joinSchema, joinCol, mainValue, childConf, childTenant } = target;
|
|
127
116
|
const childPk = childConf?.primary || joinDef.joinField;
|
|
128
117
|
const childPkCol = Array.isArray(childPk)
|
|
129
118
|
? childPk.map((f) => joinSchema.col(f))
|
|
130
119
|
: joinSchema.col(childPk);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
120
|
+
// Same order as `prepareInsertRecord` (ADR 0005): the client whitelist runs first, then
|
|
121
|
+
// the child's own `beforeInsert`, then the DB format — so a value the hook assigns to an
|
|
122
|
+
// excluded field still reaches the INSERT. A secondary is a write like any other, and its
|
|
123
|
+
// table's transform has to run here too or a column that table encodes on the way in is
|
|
124
|
+
// stored raw whenever its rows arrive as another table's children.
|
|
125
|
+
const preparedRecords = [];
|
|
126
|
+
for (const rec of records) {
|
|
127
|
+
const camel = { ...rec };
|
|
135
128
|
if (childConf) {
|
|
136
|
-
|
|
129
|
+
removeExcludedFieldsCamel(camel, childConf);
|
|
130
|
+
if (childConf.beforeInsert) {
|
|
131
|
+
await childConf.beforeInsert(db, request, camel);
|
|
132
|
+
}
|
|
137
133
|
}
|
|
134
|
+
removeWriteExcluded(camel, childConf, joinSchema);
|
|
135
|
+
const prepared = snakecaseRecord(camel, joinSchema);
|
|
136
|
+
// FK auto-fill last: the engine's own value, not the client's and not the hook's.
|
|
138
137
|
prepared[joinCol] = mainValue;
|
|
139
|
-
|
|
140
|
-
}
|
|
138
|
+
preparedRecords.push(prepared);
|
|
139
|
+
}
|
|
141
140
|
// Tenant enforcement runs after the FK auto-fill: for an indirect scope the through-FK
|
|
142
141
|
// the engine just injected is the value being validated.
|
|
143
142
|
await enforceTenantOnWrites(db, childTenant, preparedRecords);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bulk-upsert.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/bulk-upsert.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAEvD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"bulk-upsert.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/bulk-upsert.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAEvD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAU/C,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,oGAuB3F;AAED,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,oGAa5E"}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { Type } from '@sinclair/typebox';
|
|
2
2
|
import { primaryAsString } from '../../types.js';
|
|
3
|
+
import { writableFields } from '../write-access.js';
|
|
3
4
|
import { pkSchema, applySchemaOverrides, attachWriteJoinSections, writeJoinBodyFields, writeJoinResponseFields, } from './helpers.js';
|
|
4
5
|
export function BulkUpsertTableBody(dbTables, tableName, maxItems) {
|
|
5
6
|
const tableConf = dbTables[tableName];
|
|
6
7
|
const schema = tableConf.Schema;
|
|
7
8
|
// additionalProperties:false makes the schema the real write whitelist (no mass assignment).
|
|
8
|
-
const mainSchema = Type.Partial(Type.Object(applySchemaOverrides({ ...schema.fields }, tableConf), { additionalProperties: false }));
|
|
9
|
+
const mainSchema = Type.Partial(Type.Object(writableFields(applySchemaOverrides({ ...schema.fields }, tableConf), tableConf, schema), { additionalProperties: false }));
|
|
9
10
|
const itemProperties = {
|
|
10
11
|
main: mainSchema,
|
|
11
12
|
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type TObject } from '@sinclair/typebox';
|
|
2
|
+
import type { DbTables } from '../../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Response shape of `GET /rest/:table/:id`.
|
|
5
|
+
*
|
|
6
|
+
* Lives here rather than inline in the route so it is built from the same pieces as every
|
|
7
|
+
* other operation: it drifted while it was inline, documenting `readExclude`d fields the
|
|
8
|
+
* engine never selects.
|
|
9
|
+
*
|
|
10
|
+
* `Partial` because a record is returned column by column: a nullable column the driver
|
|
11
|
+
* reports as absent must not fail serialization.
|
|
12
|
+
*/
|
|
13
|
+
export declare function GetTableResponse(dbTables: DbTables, tableName: string): TObject;
|
|
14
|
+
//# sourceMappingURL=get.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"get.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/get.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAEvD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE/C;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAK/E"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Type } from '@sinclair/typebox';
|
|
2
|
+
import { readableFields } from './helpers.js';
|
|
3
|
+
/**
|
|
4
|
+
* Response shape of `GET /rest/:table/:id`.
|
|
5
|
+
*
|
|
6
|
+
* Lives here rather than inline in the route so it is built from the same pieces as every
|
|
7
|
+
* other operation: it drifted while it was inline, documenting `readExclude`d fields the
|
|
8
|
+
* engine never selects.
|
|
9
|
+
*
|
|
10
|
+
* `Partial` because a record is returned column by column: a nullable column the driver
|
|
11
|
+
* reports as absent must not fail serialization.
|
|
12
|
+
*/
|
|
13
|
+
export function GetTableResponse(dbTables, tableName) {
|
|
14
|
+
const tableConf = dbTables[tableName];
|
|
15
|
+
return Type.Object({
|
|
16
|
+
main: Type.Partial(Type.Object(readableFields(tableConf.Schema, tableConf))),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -5,11 +5,27 @@ 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
|
+
/** The same schema without the Optional modifier, i.e. as a required property. */
|
|
9
|
+
export declare function asRequired(schema: TSchema): TSchema;
|
|
8
10
|
/**
|
|
9
|
-
* Apply schemaOverrides to a fields record
|
|
10
|
-
*
|
|
11
|
+
* Apply schemaOverrides to a fields record: each matching field becomes the declared schema,
|
|
12
|
+
* verbatim.
|
|
13
|
+
*
|
|
14
|
+
* Write bodies only. An override is a rule about what this API accepts **from now on**, and
|
|
15
|
+
* the rows already in the table were written before it existed — a column narrowed to
|
|
16
|
+
* `format: 'email'` today holds whatever was accepted last year, and one made mandatory today
|
|
17
|
+
* is `NULL` on every pre-existing row. Describing the response with it would publish a promise
|
|
18
|
+
* about data nobody can retroactively make true. Responses stay on the generated type.
|
|
19
|
+
*
|
|
20
|
+
* Verbatim, because on the request side the schema is the rule: an override without
|
|
21
|
+
* `Type.Optional` makes the field mandatory and one without `Nullable` rejects an explicit
|
|
22
|
+
* `null`, whatever the column allows. That is how a column the DB had to leave nullable is
|
|
23
|
+
* made mandatory going forward. Write the modifiers when you want them.
|
|
24
|
+
*
|
|
25
|
+
* Replace-only: an override naming a field the table does not have is ignored, so the
|
|
26
|
+
* declaration can never introduce a property the engine will not produce.
|
|
11
27
|
*/
|
|
12
|
-
export declare function applySchemaOverrides(fields: Record<string, TSchema>, tableConf: ITable): Record<string, TSchema>;
|
|
28
|
+
export declare function applySchemaOverrides(fields: Record<string, TSchema>, tableConf: ITable | undefined): Record<string, TSchema>;
|
|
13
29
|
type JoinFieldsBuilder = (join: JoinDefinition, secondaryConf: ITable | undefined) => Record<string, TSchema>;
|
|
14
30
|
/**
|
|
15
31
|
* Attach `secondaries` (and optionally `deletions`) sections to the given target object,
|
|
@@ -29,5 +45,7 @@ export declare function attachWriteJoinSections(target: Record<string, TSchema>,
|
|
|
29
45
|
export declare const writeJoinBodyFields: JoinFieldsBuilder;
|
|
30
46
|
/** JoinFieldsBuilder for response schemas: PK only. */
|
|
31
47
|
export declare const writeJoinResponseFields: JoinFieldsBuilder;
|
|
48
|
+
/** Schema fields minus the ones hidden by `readExclude`. */
|
|
49
|
+
export declare function readableFields(schema: SchemaDefinition, tableConf: ITable | undefined): Record<string, TSchema>;
|
|
32
50
|
export {};
|
|
33
51
|
//# sourceMappingURL=helpers.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/helpers.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAKzF;;;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;AAiCD,kFAAkF;AAClF,wBAAgB,UAAU,CAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CAGnD;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,SAAS,GAC5B,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;AAE1C,4DAA4D;AAC5D,wBAAgB,cAAc,CAC5B,MAAM,EAAE,gBAAgB,EACxB,SAAS,EAAE,MAAM,GAAG,SAAS,GAC5B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAOzB"}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { Type } from '@sinclair/typebox';
|
|
1
|
+
import { Type, OptionalKind } from '@sinclair/typebox';
|
|
2
|
+
import { readableFieldNames } from '../read-access.js';
|
|
3
|
+
import { writableFields } from '../write-access.js';
|
|
2
4
|
import { findSecondaryTableConf } from '../engine/write-helpers.js';
|
|
3
5
|
/**
|
|
4
6
|
* Build a PK-only schema for response shapes (single or composite PK).
|
|
@@ -19,7 +21,7 @@ export function pkSchema(tableConf, schema, fallback) {
|
|
|
19
21
|
* - excludeFromCreation fields become Optional
|
|
20
22
|
*/
|
|
21
23
|
function buildSecondaryFields(joinSchema, joinField, secondaryTableConf) {
|
|
22
|
-
const fields = { ...joinSchema.fields };
|
|
24
|
+
const fields = writableFields({ ...joinSchema.fields }, secondaryTableConf, joinSchema);
|
|
23
25
|
if (joinField in fields) {
|
|
24
26
|
fields[joinField] = Type.Optional(fields[joinField]);
|
|
25
27
|
}
|
|
@@ -32,12 +34,32 @@ function buildSecondaryFields(joinSchema, joinField, secondaryTableConf) {
|
|
|
32
34
|
}
|
|
33
35
|
return fields;
|
|
34
36
|
}
|
|
37
|
+
/** The same schema without the Optional modifier, i.e. as a required property. */
|
|
38
|
+
export function asRequired(schema) {
|
|
39
|
+
if (!(OptionalKind in schema))
|
|
40
|
+
return schema;
|
|
41
|
+
return Type.Required(Type.Object({ value: schema })).properties.value;
|
|
42
|
+
}
|
|
35
43
|
/**
|
|
36
|
-
* Apply schemaOverrides to a fields record
|
|
37
|
-
*
|
|
44
|
+
* Apply schemaOverrides to a fields record: each matching field becomes the declared schema,
|
|
45
|
+
* verbatim.
|
|
46
|
+
*
|
|
47
|
+
* Write bodies only. An override is a rule about what this API accepts **from now on**, and
|
|
48
|
+
* the rows already in the table were written before it existed — a column narrowed to
|
|
49
|
+
* `format: 'email'` today holds whatever was accepted last year, and one made mandatory today
|
|
50
|
+
* is `NULL` on every pre-existing row. Describing the response with it would publish a promise
|
|
51
|
+
* about data nobody can retroactively make true. Responses stay on the generated type.
|
|
52
|
+
*
|
|
53
|
+
* Verbatim, because on the request side the schema is the rule: an override without
|
|
54
|
+
* `Type.Optional` makes the field mandatory and one without `Nullable` rejects an explicit
|
|
55
|
+
* `null`, whatever the column allows. That is how a column the DB had to leave nullable is
|
|
56
|
+
* made mandatory going forward. Write the modifiers when you want them.
|
|
57
|
+
*
|
|
58
|
+
* Replace-only: an override naming a field the table does not have is ignored, so the
|
|
59
|
+
* declaration can never introduce a property the engine will not produce.
|
|
38
60
|
*/
|
|
39
61
|
export function applySchemaOverrides(fields, tableConf) {
|
|
40
|
-
if (!tableConf
|
|
62
|
+
if (!tableConf?.schemaOverrides)
|
|
41
63
|
return fields;
|
|
42
64
|
const result = { ...fields };
|
|
43
65
|
for (const [field, schema] of Object.entries(tableConf.schemaOverrides)) {
|
|
@@ -88,3 +110,13 @@ export function attachWriteJoinSections(target, tableConf, dbTables, options) {
|
|
|
88
110
|
export const writeJoinBodyFields = (j, sc) => buildSecondaryFields(j.joinSchema, j.joinField, sc);
|
|
89
111
|
/** JoinFieldsBuilder for response schemas: PK only. */
|
|
90
112
|
export const writeJoinResponseFields = (j, sc) => pkSchema(sc, j.joinSchema, j.joinField);
|
|
113
|
+
/** Schema fields minus the ones hidden by `readExclude`. */
|
|
114
|
+
export function readableFields(schema, tableConf) {
|
|
115
|
+
if (!tableConf?.readExclude?.length)
|
|
116
|
+
return { ...schema.fields };
|
|
117
|
+
const out = {};
|
|
118
|
+
for (const field of readableFieldNames(tableConf, schema)) {
|
|
119
|
+
out[field] = schema.fields[field];
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"insert.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/insert.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAErE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"insert.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/insert.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAErE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAU/C,wBAAgB,eAAe,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CA+B9E;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAalF"}
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { Type } from '@sinclair/typebox';
|
|
2
2
|
import { primaryAsString } from '../../types.js';
|
|
3
|
+
import { writableFields } from '../write-access.js';
|
|
3
4
|
import { pkSchema, applySchemaOverrides, attachWriteJoinSections, writeJoinBodyFields, writeJoinResponseFields, } from './helpers.js';
|
|
4
5
|
export function InsertTableBody(dbTables, tableName) {
|
|
5
6
|
const tableConf = dbTables[tableName];
|
|
6
7
|
const schema = tableConf.Schema;
|
|
7
|
-
// Main: full validation, but excludeFromCreation fields become Optional
|
|
8
|
-
|
|
8
|
+
// Main: full validation, but excludeFromCreation fields become Optional. Fields the
|
|
9
|
+
// database computes are not offered at all — sending one is rejected here (400) rather
|
|
10
|
+
// than by the driver (500).
|
|
11
|
+
const mainFields = writableFields(applySchemaOverrides({ ...schema.fields }, tableConf), tableConf, schema);
|
|
9
12
|
if (tableConf.excludeFromCreation) {
|
|
10
13
|
for (const field of tableConf.excludeFromCreation) {
|
|
11
|
-
|
|
14
|
+
if (field in mainFields)
|
|
15
|
+
mainFields[field] = Type.Optional(mainFields[field]);
|
|
12
16
|
}
|
|
13
17
|
}
|
|
14
18
|
const bodyProperties = {
|
|
@@ -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,
|
|
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,EAAU,MAAM,gBAAgB,CAAC;AAkJvD,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,CA2ClF"}
|
|
@@ -1,17 +1,7 @@
|
|
|
1
1
|
import { Type } from '@sinclair/typebox';
|
|
2
2
|
import { ALLOWED_METHODS, MAX_CONDITIONS, MAX_ORDER_BY_LENGTH } from '../condition-methods.js';
|
|
3
|
-
import {
|
|
3
|
+
import { readableFields } from './helpers.js';
|
|
4
4
|
import { DEFAULT_MAX_ITEMS_PER_PAGE } from '../../types.js';
|
|
5
|
-
/** Schema fields minus the ones hidden by `readExclude`. */
|
|
6
|
-
function readableFields(schema, tableConf) {
|
|
7
|
-
if (!tableConf?.readExclude?.length)
|
|
8
|
-
return { ...schema.fields };
|
|
9
|
-
const out = {};
|
|
10
|
-
for (const field of readableFieldNames(tableConf, schema)) {
|
|
11
|
-
out[field] = schema.fields[field];
|
|
12
|
-
}
|
|
13
|
-
return out;
|
|
14
|
-
}
|
|
15
5
|
/**
|
|
16
6
|
* A `filters` map.
|
|
17
7
|
*
|
|
@@ -192,6 +182,8 @@ export function SearchTableResponse(dbTables, tableName) {
|
|
|
192
182
|
// Computed fields are present in main rows only when explicitly listed in
|
|
193
183
|
// request body's selectComputed, so they're optional/Partial here.
|
|
194
184
|
const computedTypes = computedFieldTypes(tableConf);
|
|
185
|
+
// Not narrowed by schemaOverrides: an override rules what this API accepts from now on,
|
|
186
|
+
// while the rows already stored predate it (see applySchemaOverrides).
|
|
195
187
|
const mainReadable = readableFields(tableConf.Schema, tableConf);
|
|
196
188
|
const mainItem = Object.keys(computedTypes).length > 0
|
|
197
189
|
? Type.Partial(Type.Object({ ...mainReadable, ...computedTypes }))
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/update.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAErE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/update.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAErE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAW/C,wBAAgB,eAAe,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CA+B9E;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAalF"}
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { Type } from '@sinclair/typebox';
|
|
2
2
|
import { primaryAsString } from '../../types.js';
|
|
3
|
-
import {
|
|
3
|
+
import { writableFields } from '../write-access.js';
|
|
4
|
+
import { pkSchema, applySchemaOverrides, asRequired, attachWriteJoinSections, writeJoinBodyFields, writeJoinResponseFields, } from './helpers.js';
|
|
4
5
|
export function UpdateTableBody(dbTables, tableName) {
|
|
5
6
|
const tableConf = dbTables[tableName];
|
|
6
7
|
const schema = tableConf.Schema;
|
|
7
8
|
// Main: PK required, all other fields optional (overrides applied before Optional wrap)
|
|
8
|
-
const baseFields = applySchemaOverrides({ ...schema.fields }, tableConf);
|
|
9
|
+
const baseFields = writableFields(applySchemaOverrides({ ...schema.fields }, tableConf), tableConf, schema);
|
|
9
10
|
const mainFields = {};
|
|
11
|
+
const pk = primaryAsString(tableConf.primary);
|
|
10
12
|
for (const [key, value] of Object.entries(baseFields)) {
|
|
11
|
-
|
|
13
|
+
// The PK identifies the row being updated, so it is mandatory here even when the column
|
|
14
|
+
// is optional on insert (a generated id) and the override carried that optionality over.
|
|
15
|
+
mainFields[key] = key === pk ? asRequired(value) : Type.Optional(value);
|
|
12
16
|
}
|
|
13
17
|
const bodyProperties = {
|
|
14
18
|
// additionalProperties:false makes the schema the real write whitelist — unknown keys are
|
package/dist/lib/sql-api.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ApiRequest } from '../types/request.js';
|
|
2
2
|
import { QueryClient } from './db.js';
|
|
3
3
|
import { type DialectName } from './dialect.js';
|
|
4
4
|
import type { DbTables, Queryable, FilterRecord, SearchCondition, Paginator, JoinGroupRequest, JoinRefFilter, JoinFetchRequest, SearchResult, GetResult, InsertResult, UpdateResult, DeleteResult, BulkUpsertItem, BulkUpsertResult, BulkDeleteResult, TenantId } from '../types.js';
|
|
@@ -30,7 +30,7 @@ export interface SqlApiUpdateParams {
|
|
|
30
30
|
}
|
|
31
31
|
export interface SqlApiOptions {
|
|
32
32
|
dialect?: DialectName;
|
|
33
|
-
getTenantId?: (request:
|
|
33
|
+
getTenantId?: (request: ApiRequest) => TenantId | TenantId[] | null | undefined | Promise<TenantId | TenantId[] | null | undefined>;
|
|
34
34
|
}
|
|
35
35
|
export declare class SqlApi {
|
|
36
36
|
private dbTables;
|
|
@@ -39,13 +39,13 @@ export declare class SqlApi {
|
|
|
39
39
|
constructor(dbOrPool: QueryClient | Queryable, dbTables: DbTables, options?: SqlApiOptions);
|
|
40
40
|
private getTableConf;
|
|
41
41
|
private getTenant;
|
|
42
|
-
search(tableName: string, params?: SqlApiSearchParams, request?:
|
|
43
|
-
get(tableName: string, id: string | number, request?:
|
|
44
|
-
insert(tableName: string, params: SqlApiInsertParams, request?:
|
|
45
|
-
update(tableName: string, params: SqlApiUpdateParams, request?:
|
|
46
|
-
delete(tableName: string, id: string | number, request?:
|
|
47
|
-
bulkUpsert(tableName: string, items: BulkUpsertItem[], request?:
|
|
48
|
-
bulkDelete(tableName: string, ids: (string | number)[], request?:
|
|
42
|
+
search(tableName: string, params?: SqlApiSearchParams, request?: ApiRequest): Promise<SearchResult>;
|
|
43
|
+
get(tableName: string, id: string | number, request?: ApiRequest): Promise<GetResult>;
|
|
44
|
+
insert(tableName: string, params: SqlApiInsertParams, request?: ApiRequest): Promise<InsertResult>;
|
|
45
|
+
update(tableName: string, params: SqlApiUpdateParams, request?: ApiRequest): Promise<UpdateResult>;
|
|
46
|
+
delete(tableName: string, id: string | number, request?: ApiRequest): Promise<DeleteResult>;
|
|
47
|
+
bulkUpsert(tableName: string, items: BulkUpsertItem[], request?: ApiRequest): Promise<BulkUpsertResult[]>;
|
|
48
|
+
bulkDelete(tableName: string, ids: (string | number)[], request?: ApiRequest): Promise<BulkDeleteResult[]>;
|
|
49
49
|
}
|
|
50
50
|
export declare function createSqlApi(dbOrPool: QueryClient | Queryable, dbTables: DbTables, options?: SqlApiOptions): SqlApi;
|
|
51
51
|
//# sourceMappingURL=sql-api.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sql-api.d.ts","sourceRoot":"","sources":["../../src/lib/sql-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,
|
|
1
|
+
{"version":3,"file":"sql-api.d.ts","sourceRoot":"","sources":["../../src/lib/sql-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAEtD,OAAO,EAAqB,WAAW,EAAE,MAAM,SAAS,CAAC;AACzD,OAAO,EAAc,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAU5D,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,YAAY,EACZ,eAAe,EACf,SAAS,EACT,gBAAgB,EAChB,aAAa,EACb,gBAAgB,EAChB,YAAY,EACZ,SAAS,EACT,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,cAAc,EACd,gBAAgB,EAChB,gBAAgB,EAChB,QAAQ,EAET,MAAM,aAAa,CAAC;AAIrB,MAAM,WAAW,kBAAkB;IACjC,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,UAAU,CAAC,EAAE,eAAe,EAAE,CAAC;IAC/B,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;IAC9C,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAChD,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC7C,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC5C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,2FAA2F;IAC3F,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC;CACzD;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC;IACxD,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC;CACvD;AAID,MAAM,WAAW,aAAa;IAC5B,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,UAAU,KAAK,QAAQ,GAAG,QAAQ,EAAE,GAAG,IAAI,GAAG,SAAS,GAC3E,OAAO,CAAC,QAAQ,GAAG,QAAQ,EAAE,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;CACvD;AAED,qBAAa,MAAM;IAKf,OAAO,CAAC,QAAQ;IAChB,OAAO,CAAC,OAAO;IALjB,OAAO,CAAC,EAAE,CAAc;gBAGtB,QAAQ,EAAE,WAAW,GAAG,SAAS,EACzB,QAAQ,EAAE,QAAQ,EAClB,OAAO,GAAE,aAAkB;IAgBrC,OAAO,CAAC,YAAY;YAMN,SAAS;IAUjB,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,GAAE,kBAAuB,EAAE,OAAO,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,YAAY,CAAC;IAyBvG,GAAG,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,EAAE,OAAO,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC;IAMrF,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,kBAAkB,EAAE,OAAO,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,YAAY,CAAC;IAclG,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,kBAAkB,EAAE,OAAO,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,YAAY,CAAC;IAelG,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,EAAE,OAAO,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,YAAY,CAAC;IAM3F,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,cAAc,EAAE,EAAE,OAAO,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;IAazG,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,EAAE,OAAO,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;CAKjH;AAED,wBAAgB,YAAY,CAAC,QAAQ,EAAE,WAAW,GAAG,SAAS,EAAE,QAAQ,EAAE,QAAQ,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,MAAM,CAEnH"}
|
package/dist/lib/sql-api.js
CHANGED
|
@@ -63,12 +63,13 @@ export class SqlApi {
|
|
|
63
63
|
selectComputed: params.selectComputed,
|
|
64
64
|
maxRows: params.maxRows,
|
|
65
65
|
tenant,
|
|
66
|
+
request,
|
|
66
67
|
});
|
|
67
68
|
}
|
|
68
69
|
async get(tableName, id, request) {
|
|
69
70
|
const tableConf = this.getTableConf(tableName);
|
|
70
71
|
const tenant = await this.getTenant(tableName, request);
|
|
71
|
-
return getEngine({ db: this.db, tableConf, id, tenant });
|
|
72
|
+
return getEngine({ db: this.db, tableConf, id, tenant, request });
|
|
72
73
|
}
|
|
73
74
|
async insert(tableName, params, request) {
|
|
74
75
|
const tableConf = this.getTableConf(tableName);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"table-helpers.d.ts","sourceRoot":"","sources":["../../src/lib/table-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,yBAAyB,EAAiC,MAAM,wBAAwB,CAAC;AACzH,OAAO,EAAQ,KAAK,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,MAAM,
|
|
1
|
+
{"version":3,"file":"table-helpers.d.ts","sourceRoot":"","sources":["../../src/lib/table-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,yBAAyB,EAAiC,MAAM,wBAAwB,CAAC;AACzH,OAAO,EAAQ,KAAK,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,MAAM,EAEP,MAAM,aAAa,CAAC;AAcrB,KAAK,UAAU,CAAC,EAAE,IAAI,EAAE,SAAS,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAExH,wBAAgB,eAAe,CAC7B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,EAAE,SAAS,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,EAErE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,YAAY,GAAE,EAAa,EAC3B,iBAAiB,CAAC,EAAE,CAClB,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,yBAAyB;CAAE,KAC3E,IAAI,GACR;IAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAAC,OAAO,EAAE,aAAa,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAsChG;AAED,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAChB,MAAM,CAAC,CAAC,CAAC,CAWX;AAgSD,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AAgDD,wBAAgB,aAAa,CAC3B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAEjC,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,EAClD,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,EAC3B,OAAO,CAAC,EAAE,oBAAoB,GAC7B,cAAc,CAWhB;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,OAAO,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,GAC5B,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAE9B;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,KAAK,EAAE,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,EAAE,GACvC,GAAG,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAEjC"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ConditionBuilder } from 'node-condition-builder';
|
|
2
2
|
import { Type } from '@sinclair/typebox';
|
|
3
3
|
import { getDialect } from './dialect.js';
|
|
4
|
+
import { hasOwnField } from './read-access.js';
|
|
4
5
|
// Quote an identifier using the given ConditionBuilder dialect (or the global
|
|
5
6
|
// default when not provided). Needed so that DB columns with uppercase letters
|
|
6
7
|
// (e.g. betterauth "userId") are preserved on PostgreSQL (which folds unquoted
|
|
@@ -49,8 +50,11 @@ export function defineTable(config) {
|
|
|
49
50
|
validateAliasUniqueness(config.allowedWriteJoins, 'allowedWriteJoins');
|
|
50
51
|
validateWriteJoinsUnrestricted(config.allowedWriteJoins);
|
|
51
52
|
validateComputedFields(config);
|
|
53
|
+
validatePrimary(config);
|
|
52
54
|
validateReadExclude(config);
|
|
55
|
+
validateWriteExclude(config);
|
|
53
56
|
validateTenantScope(config);
|
|
57
|
+
validateTenantColumnNames(config);
|
|
54
58
|
return config;
|
|
55
59
|
}
|
|
56
60
|
/**
|
|
@@ -59,9 +63,35 @@ export function defineTable(config) {
|
|
|
59
63
|
* entry reached through a parent FK would need its own join per entry — and an empty `anyOf`
|
|
60
64
|
* would fail closed on every row, which looks like a broken deployment rather than a rule.
|
|
61
65
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
66
|
+
* The columns it names are **real DB column names**, not camelCase schema fields — the one place
|
|
67
|
+
* in `defineTable` where the convention flips. That is not an oversight: a scope is applied to
|
|
68
|
+
* the SQL, after the payload has already been converted, and `through.foreignField` belongs to a
|
|
69
|
+
* parent table that may have no config, no `colMap`, or no generated schema at all. It is the
|
|
70
|
+
* same category as `db.*` and `extendedCondition`: names that go straight to the database.
|
|
71
|
+
*
|
|
72
|
+
* So existence cannot be checked — a hand-written schema may legitimately omit the column. What
|
|
73
|
+
* *is* checked is the mistake that exception invites: naming the schema **field** whose real
|
|
74
|
+
* column is something else. Left alone that surfaces as `column "organizationId" does not exist`
|
|
75
|
+
* on every request to the table — an opaque 500 (ADR 0006), failing closed but saying nothing.
|
|
76
|
+
*/
|
|
77
|
+
/**
|
|
78
|
+
* Reject a tenant column that is really a schema field name mapping to a different column.
|
|
79
|
+
*
|
|
80
|
+
* The check is deliberately narrow, and it self-adjusts: on a camelCase database `col()` is the
|
|
81
|
+
* identity, so a camelCase name raises nothing. A name the schema does not carry raises nothing
|
|
82
|
+
* either — that is the hand-written-schema case, and there is nothing to compare it against.
|
|
64
83
|
*/
|
|
84
|
+
function assertNamesColumn(schema, name, where, table) {
|
|
85
|
+
if (!hasOwnField(schema.fields, name))
|
|
86
|
+
return;
|
|
87
|
+
const column = schema.col(name);
|
|
88
|
+
if (column === name)
|
|
89
|
+
return;
|
|
90
|
+
throw new Error(`defineTable: tenantScope ${where} on table '${table}' names '${name}', which is a ` +
|
|
91
|
+
`schema field, not a column: its column is '${column}'. tenantScope names real DB ` +
|
|
92
|
+
`columns — the scope is applied to the SQL, after the payload has been converted — so ` +
|
|
93
|
+
`write '${column}' here. Clients keep sending '${name}'.`);
|
|
94
|
+
}
|
|
65
95
|
function validateTenantScope(config) {
|
|
66
96
|
const scope = config.tenantScope;
|
|
67
97
|
if (!scope)
|
|
@@ -100,6 +130,35 @@ function validateTenantScope(config) {
|
|
|
100
130
|
`A scope with no owner column would filter nothing.`);
|
|
101
131
|
}
|
|
102
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Every column a scope names, checked against the schema it belongs to. Runs after
|
|
135
|
+
* `validateTenantScope`, so the shape is already known to be one of the three valid forms.
|
|
136
|
+
*/
|
|
137
|
+
function validateTenantColumnNames(config) {
|
|
138
|
+
const scope = config.tenantScope;
|
|
139
|
+
if (!scope)
|
|
140
|
+
return;
|
|
141
|
+
const table = config.Schema.tableName;
|
|
142
|
+
if ('anyOf' in scope) {
|
|
143
|
+
for (const entry of scope.anyOf) {
|
|
144
|
+
assertNamesColumn(config.Schema, entry, "'anyOf'", table);
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const direct = scope;
|
|
149
|
+
assertNamesColumn(config.Schema, direct.column, "'column'", table);
|
|
150
|
+
// `through.localField` is a column of THIS table; `through.foreignField` one of the parent's,
|
|
151
|
+
// which is why the parent schema travels in the scope.
|
|
152
|
+
const through = direct.through;
|
|
153
|
+
if (!through || typeof through !== 'object')
|
|
154
|
+
return;
|
|
155
|
+
if (typeof through.localField === 'string') {
|
|
156
|
+
assertNamesColumn(config.Schema, through.localField, "'through.localField'", table);
|
|
157
|
+
}
|
|
158
|
+
if (typeof through.foreignField === 'string' && typeof through.schema?.col === 'function') {
|
|
159
|
+
assertNamesColumn(through.schema, through.foreignField, "'through.foreignField'", table);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
103
162
|
/**
|
|
104
163
|
* `fields` narrows a relation for reading; it has no meaning on a write join and would be
|
|
105
164
|
* actively harmful there. Write paths resolve the secondary's upsert rule by looking the
|
|
@@ -133,6 +192,68 @@ function validateReadExclude(config) {
|
|
|
133
192
|
}
|
|
134
193
|
}
|
|
135
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* Every operation but `search` addresses a row by its primary key, and a `primary` naming a
|
|
197
|
+
* field the schema does not have reaches the database as a column that may not exist — a raw
|
|
198
|
+
* SQL error, so a 500 (ADR 0006), on the first request instead of at startup. Views are where
|
|
199
|
+
* this bites: they carry no PRIMARY KEY constraint, so the generator writes a placeholder
|
|
200
|
+
* rather than inventing a key that would silently address the wrong rows.
|
|
201
|
+
*
|
|
202
|
+
* `search` is the deliberate exception: it reads `primary` only as the fallback for ordering,
|
|
203
|
+
* so a table that exposes nothing else and names its own `defaultOrder` never touches it. That
|
|
204
|
+
* is exactly the shape the generator emits for a view whose key it could not infer, and it is
|
|
205
|
+
* a working config — not a broken one waiting to be fixed.
|
|
206
|
+
*/
|
|
207
|
+
function validatePrimary(config) {
|
|
208
|
+
const schemaFields = Object.keys(config.Schema.fields);
|
|
209
|
+
const pkFields = Array.isArray(config.primary) ? config.primary : [config.primary];
|
|
210
|
+
const table = config.Schema.tableName;
|
|
211
|
+
const missing = pkFields.filter((f) => !schemaFields.includes(f));
|
|
212
|
+
if (missing.length === 0)
|
|
213
|
+
return;
|
|
214
|
+
const searchOnly = config.operations?.length === 1 && config.operations[0] === 'search';
|
|
215
|
+
if (searchOnly) {
|
|
216
|
+
if (config.defaultOrder)
|
|
217
|
+
return;
|
|
218
|
+
throw new Error(`defineTable: table '${table}' exposes only 'search' with an unresolved primary key ` +
|
|
219
|
+
`('${missing[0]}'), so it must declare a 'defaultOrder' — otherwise the search orders ` +
|
|
220
|
+
`by that missing column.`);
|
|
221
|
+
}
|
|
222
|
+
const exposed = config.operations
|
|
223
|
+
? `operations [${config.operations.join(', ')}]`
|
|
224
|
+
: 'every operation (no `operations` whitelist, so all routes are exposed)';
|
|
225
|
+
throw new Error(`defineTable: primary key field '${missing[0]}' is not a schema field on table ` +
|
|
226
|
+
`'${table}', and the table exposes ${exposed} — those address rows by the primary key. ` +
|
|
227
|
+
`Name a column that is unique in this relation, or restrict the table to ` +
|
|
228
|
+
`operations: ['search'] with an explicit defaultOrder.`);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* `writeExclude` names schema fields that no write may carry. A field outside the schema is a
|
|
232
|
+
* typo; the primary key is refused because the update body identifies the row with it; and a
|
|
233
|
+
* field that is also `readExclude`d would be neither readable nor writable, which is what
|
|
234
|
+
* removing it from the Schema means — keeping it there only grows the surface for nothing.
|
|
235
|
+
*/
|
|
236
|
+
function validateWriteExclude(config) {
|
|
237
|
+
const excluded = config.writeExclude;
|
|
238
|
+
if (!excluded?.length)
|
|
239
|
+
return;
|
|
240
|
+
const schemaFields = Object.keys(config.Schema.fields);
|
|
241
|
+
const pkFields = Array.isArray(config.primary) ? config.primary : [config.primary];
|
|
242
|
+
const table = config.Schema.tableName;
|
|
243
|
+
for (const field of excluded) {
|
|
244
|
+
if (!schemaFields.includes(field)) {
|
|
245
|
+
throw new Error(`defineTable: writeExclude field '${field}' is not a schema field on table '${table}'.`);
|
|
246
|
+
}
|
|
247
|
+
if (pkFields.includes(field)) {
|
|
248
|
+
throw new Error(`defineTable: writeExclude cannot cover the primary key field '${field}' on table ` +
|
|
249
|
+
`'${table}' — the update body identifies the row with it.`);
|
|
250
|
+
}
|
|
251
|
+
if (config.readExclude?.includes(field)) {
|
|
252
|
+
throw new Error(`defineTable: field '${field}' on table '${table}' is in both readExclude and ` +
|
|
253
|
+
`writeExclude, leaving it neither readable nor writable. Remove it from the Schema instead.`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
136
257
|
function validateComputedFields(config) {
|
|
137
258
|
const computed = config.computedFields;
|
|
138
259
|
if (!computed)
|
package/dist/lib/tenant.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ApiRequest } from '../types/request.js';
|
|
2
2
|
import type { QueryClient } from './db.js';
|
|
3
3
|
import { ConditionBuilder, type ConditionValue, type ConditionValueOrUndefined } from 'node-condition-builder';
|
|
4
4
|
import type { ITable, SqlApiPluginOptions, TenantId, TenantScope, TenantScopeDirect, TenantScopeIndirect, TenantScopeAnyOf, TenantContext } from '../types.js';
|
|
5
|
-
export declare function resolveTenant(options: SqlApiPluginOptions, tableConf: ITable, request:
|
|
5
|
+
export declare function resolveTenant(options: SqlApiPluginOptions, tableConf: ITable, request: ApiRequest): Promise<TenantContext | undefined>;
|
|
6
6
|
/**
|
|
7
7
|
* The read predicate for a scope, as a fragment every caller appends to its own WHERE.
|
|
8
8
|
*
|