fastify-auto-sqlapi 0.2.4 → 0.4.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.md +24 -111
- package/AGENTS_BACKEND.md +282 -1332
- package/AGENTS_FRONTEND.md +70 -47
- package/BREAKING_CHANGES.md +131 -0
- package/CHANGELOG.md +139 -0
- package/README.md +1 -1
- package/dist/bin/generate-schema.js +2 -2
- package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
- package/dist/lib/cli/mysql-introspect.js +24 -1
- package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
- package/dist/lib/cli/pg-introspect.js +21 -2
- package/dist/lib/cli/schema-codegen.d.ts +1 -1
- package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
- package/dist/lib/cli/schema-codegen.js +31 -3
- package/dist/lib/db.d.ts.map +1 -1
- package/dist/lib/db.js +187 -60
- package/dist/lib/dialect.d.ts +15 -10
- package/dist/lib/dialect.d.ts.map +1 -1
- package/dist/lib/dialect.js +12 -31
- package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-upsert.js +2 -1
- package/dist/lib/engine/rest/insert.d.ts.map +1 -1
- package/dist/lib/engine/rest/insert.js +2 -1
- package/dist/lib/engine/write-helpers.d.ts +13 -1
- package/dist/lib/engine/write-helpers.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.js +35 -0
- package/dist/lib/schema/helpers.d.ts +7 -0
- package/dist/lib/schema/helpers.d.ts.map +1 -1
- package/dist/lib/schema/helpers.js +18 -0
- package/dist/lib/schema/insert.d.ts.map +1 -1
- package/dist/lib/schema/insert.js +3 -1
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +41 -0
- package/dist/lib/tenant.d.ts +6 -0
- package/dist/lib/tenant.d.ts.map +1 -1
- package/dist/lib/tenant.js +16 -0
- package/dist/routes/auto/bulk-delete.routes.js +1 -1
- package/dist/routes/auto/bulk-upsert.routes.js +1 -1
- package/dist/routes/auto/delete.routes.js +1 -1
- package/dist/routes/auto/get.routes.js +1 -1
- package/dist/routes/auto/insert.routes.js +1 -1
- package/dist/routes/auto/route-helpers.d.ts +5 -2
- package/dist/routes/auto/route-helpers.d.ts.map +1 -1
- package/dist/routes/auto/route-helpers.js +1 -1
- package/dist/routes/auto/search.routes.js +3 -3
- package/dist/routes/auto/update.routes.js +1 -1
- package/dist/types/cli.d.ts +11 -0
- package/dist/types/cli.d.ts.map +1 -1
- package/dist/types/schema.d.ts +7 -0
- package/dist/types/schema.d.ts.map +1 -1
- package/docs/adr/0020-no-conditional-or-mass-update-route.md +68 -0
- package/docs/adr/0021-upsert-targets-a-verified-unique-key.md +153 -0
- package/docs/adr/README.md +2 -0
- package/package.json +4 -4
|
@@ -1,7 +1,41 @@
|
|
|
1
1
|
import { camelcaseObject, snakecaseRecord } from '../naming.js';
|
|
2
2
|
import { runValidation } from './validate.js';
|
|
3
3
|
import { removeWriteExcluded } from '../write-access.js';
|
|
4
|
+
import { httpError } from '../errors.js';
|
|
4
5
|
import { tenantForTable, buildTenantRowGuard, enforceTenantOnWrites, assertTenantOwnsConflicts, tenantImmutableColumns, } from '../tenant.js';
|
|
6
|
+
/**
|
|
7
|
+
* Every upserted record must carry its key (ADR 0021), checked on the record as it reaches the
|
|
8
|
+
* database — after hooks, tenant injection and the FK auto-fill of secondaries:
|
|
9
|
+
* - a natural key needs every field present and not null: a NULL never conflicts, so the row
|
|
10
|
+
* could only be inserted and never found again by its key;
|
|
11
|
+
* - on the primary key, a record naming no PK field at all is a plain insert with a generated
|
|
12
|
+
* key; one naming it only in part, or as null, is refused like a natural key;
|
|
13
|
+
* - two records of one request with the same key are refused: PostgreSQL cannot affect a row
|
|
14
|
+
* twice in one statement, MySQL would let the last one win.
|
|
15
|
+
* Throws 400 before any SQL runs. Column names and record keys are DB column names.
|
|
16
|
+
*/
|
|
17
|
+
export function assertUpsertKeys(schema, conflictCols, pkCols, records) {
|
|
18
|
+
const isPrimaryKey = conflictCols.length === pkCols.length
|
|
19
|
+
&& conflictCols.every((c) => pkCols.includes(c));
|
|
20
|
+
const fieldOf = (col) => Object.keys(schema.fields).find((f) => schema.col(f) === col) ?? col;
|
|
21
|
+
const rule = `Upsert on "${schema.tableName}" by (${conflictCols.map(fieldOf).join(', ')})`;
|
|
22
|
+
const seen = new Map();
|
|
23
|
+
records.forEach((record, i) => {
|
|
24
|
+
if (isPrimaryKey && conflictCols.every((c) => record[c] === undefined))
|
|
25
|
+
return;
|
|
26
|
+
const missing = conflictCols.filter((c) => record[c] == null);
|
|
27
|
+
if (missing.length) {
|
|
28
|
+
throw httpError(400, `${rule}: the row at index ${i} has no value for ${missing.map(fieldOf).join(', ')} — ` +
|
|
29
|
+
`an upserted row must carry its whole key`);
|
|
30
|
+
}
|
|
31
|
+
const key = JSON.stringify(conflictCols.map((c) => record[c]));
|
|
32
|
+
const first = seen.get(key);
|
|
33
|
+
if (first !== undefined) {
|
|
34
|
+
throw httpError(400, `${rule}: the rows at index ${first} and ${i} have the same key`);
|
|
35
|
+
}
|
|
36
|
+
seen.set(key, i);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
5
39
|
/**
|
|
6
40
|
* Resolve the write join an alias names, or undefined when the table does not declare it.
|
|
7
41
|
*
|
|
@@ -144,6 +178,7 @@ export function processSecondaries(pass, secondaries) {
|
|
|
144
178
|
let pkRows;
|
|
145
179
|
if (upsertKeys) {
|
|
146
180
|
const conflictCols = upsertKeys.map((k) => joinSchema.col(k));
|
|
181
|
+
assertUpsertKeys(joinSchema, conflictCols, (joinSchema.primaryKey ?? [childPk].flat()).map((f) => joinSchema.col(f)), preparedRecords);
|
|
147
182
|
// A conflicting upsert must not overwrite — or re-parent — a child row owned by
|
|
148
183
|
// another tenant, which the ON CONFLICT key alone would happily match.
|
|
149
184
|
await assertTenantOwnsConflicts(db, childTenant, joinSchema.tableName, conflictCols, preparedRecords);
|
|
@@ -5,6 +5,13 @@ 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
|
+
* Make the tenant column(s) of a scoped table Optional in a write body. The engine injects the
|
|
10
|
+
* value for a single-tenant caller and validates the one a multi-tenant or `anyOf` caller sends,
|
|
11
|
+
* so the body must offer the field without demanding it — the same reason a secondary's FK is
|
|
12
|
+
* Optional. `tenantScope` names real DB columns; the schema field is found through `col()`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function relaxTenantColumns(fields: Record<string, TSchema>, tableConf: ITable | undefined, schema: SchemaDefinition): void;
|
|
8
15
|
/** The same schema without the Optional modifier, i.e. as a required property. */
|
|
9
16
|
export declare function asRequired(schema: TSchema): TSchema;
|
|
10
17
|
/**
|
|
@@ -1 +1 @@
|
|
|
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;
|
|
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;AAMzF;;;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;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,gBAAgB,GACvB,IAAI,CAMN;AAoCD,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,6 +1,7 @@
|
|
|
1
1
|
import { Type, OptionalKind } from '@sinclair/typebox';
|
|
2
2
|
import { readableFieldNames } from '../read-access.js';
|
|
3
3
|
import { writableFields } from '../write-access.js';
|
|
4
|
+
import { tenantOwnColumns } from '../tenant.js';
|
|
4
5
|
import { findSecondaryTableConf } from '../engine/write-helpers.js';
|
|
5
6
|
/**
|
|
6
7
|
* Build a PK-only schema for response shapes (single or composite PK).
|
|
@@ -15,10 +16,26 @@ export function pkSchema(tableConf, schema, fallback) {
|
|
|
15
16
|
}
|
|
16
17
|
return result;
|
|
17
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Make the tenant column(s) of a scoped table Optional in a write body. The engine injects the
|
|
21
|
+
* value for a single-tenant caller and validates the one a multi-tenant or `anyOf` caller sends,
|
|
22
|
+
* so the body must offer the field without demanding it — the same reason a secondary's FK is
|
|
23
|
+
* Optional. `tenantScope` names real DB columns; the schema field is found through `col()`.
|
|
24
|
+
*/
|
|
25
|
+
export function relaxTenantColumns(fields, tableConf, schema) {
|
|
26
|
+
const columns = tenantOwnColumns(tableConf?.tenantScope);
|
|
27
|
+
if (columns.length === 0)
|
|
28
|
+
return;
|
|
29
|
+
for (const field of Object.keys(fields)) {
|
|
30
|
+
if (columns.includes(schema.col(field)))
|
|
31
|
+
fields[field] = Type.Optional(fields[field]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
18
34
|
/**
|
|
19
35
|
* Build secondary table fields for body schemas:
|
|
20
36
|
* - FK field becomes Optional (auto-filled from main)
|
|
21
37
|
* - excludeFromCreation fields become Optional
|
|
38
|
+
* - tenant column(s) become Optional (injected or validated by the engine)
|
|
22
39
|
*/
|
|
23
40
|
function buildSecondaryFields(joinSchema, joinField, secondaryTableConf) {
|
|
24
41
|
const fields = writableFields({ ...joinSchema.fields }, secondaryTableConf, joinSchema);
|
|
@@ -32,6 +49,7 @@ function buildSecondaryFields(joinSchema, joinField, secondaryTableConf) {
|
|
|
32
49
|
}
|
|
33
50
|
}
|
|
34
51
|
}
|
|
52
|
+
relaxTenantColumns(fields, secondaryTableConf, joinSchema);
|
|
35
53
|
return fields;
|
|
36
54
|
}
|
|
37
55
|
/** The same schema without the Optional modifier, i.e. as a required property. */
|
|
@@ -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;AAW/C,wBAAgB,eAAe,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAiC9E;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAalF"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Type } from '@sinclair/typebox';
|
|
2
2
|
import { primaryAsString } from '../../types.js';
|
|
3
3
|
import { writableFields } from '../write-access.js';
|
|
4
|
-
import { pkSchema, applySchemaOverrides, attachWriteJoinSections, writeJoinBodyFields, writeJoinResponseFields, } from './helpers.js';
|
|
4
|
+
import { pkSchema, applySchemaOverrides, attachWriteJoinSections, relaxTenantColumns, writeJoinBodyFields, writeJoinResponseFields, } from './helpers.js';
|
|
5
5
|
export function InsertTableBody(dbTables, tableName) {
|
|
6
6
|
const tableConf = dbTables[tableName];
|
|
7
7
|
const schema = tableConf.Schema;
|
|
@@ -15,6 +15,8 @@ export function InsertTableBody(dbTables, tableName) {
|
|
|
15
15
|
mainFields[field] = Type.Optional(mainFields[field]);
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
+
// The tenant column is the engine's to fill or check, never the client's to be forced to send.
|
|
19
|
+
relaxTenantColumns(mainFields, tableConf, schema);
|
|
18
20
|
const bodyProperties = {
|
|
19
21
|
// additionalProperties:false makes the schema the real write whitelist — unknown keys are
|
|
20
22
|
// rejected (400) instead of silently reaching the INSERT as columns (mass assignment).
|
|
@@ -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,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,
|
|
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,CAYX;AAmVD,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"}
|
|
@@ -55,6 +55,7 @@ export function defineTable(config) {
|
|
|
55
55
|
validateWriteExclude(config);
|
|
56
56
|
validateTenantScope(config);
|
|
57
57
|
validateTenantColumnNames(config);
|
|
58
|
+
validateUpsertMap(config);
|
|
58
59
|
return config;
|
|
59
60
|
}
|
|
60
61
|
/**
|
|
@@ -255,6 +256,46 @@ function validateWriteExclude(config) {
|
|
|
255
256
|
}
|
|
256
257
|
}
|
|
257
258
|
}
|
|
259
|
+
const formatKey = (fields) => `(${fields.join(', ')})`;
|
|
260
|
+
const sameFieldSet = (a, b) => a.length === b.length && a.every((f) => b.includes(f));
|
|
261
|
+
/**
|
|
262
|
+
* An `upsertMap` rule must name a unique key the database enforces — the Schema's
|
|
263
|
+
* `primaryKey` or one of its `uniqueKeys`, as a set: same fields, any order, nothing extra
|
|
264
|
+
* (ADR 0021). Anything else fails every write on PostgreSQL, and on MySQL duplicates rows or
|
|
265
|
+
* updates a row matched through another key, in silence.
|
|
266
|
+
*
|
|
267
|
+
* Fail-closed: a rule the Schema cannot vouch for is refused. The Schema is what is read, not
|
|
268
|
+
* a table config, so a child written only through a write join needs no `defineTable`.
|
|
269
|
+
*/
|
|
270
|
+
function validateUpsertMap(config) {
|
|
271
|
+
for (const [schema, keys] of config.upsertMap ?? []) {
|
|
272
|
+
const problem = upsertKeyProblem(schema, keys);
|
|
273
|
+
if (problem) {
|
|
274
|
+
throw new Error(`defineTable: upsertMap rule on '${schema.tableName}' ${formatKey(keys)} ${problem} (ADR 0021).`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/** Why `keys` is not a verified unique key of `schema`, or null when it is one. */
|
|
279
|
+
function upsertKeyProblem(schema, keys) {
|
|
280
|
+
if (schema.isView)
|
|
281
|
+
return 'targets a view — a view has no unique key to upsert on';
|
|
282
|
+
const primaryKey = schema.primaryKey ?? [];
|
|
283
|
+
if (primaryKey.length && sameFieldSet(keys, primaryKey))
|
|
284
|
+
return null;
|
|
285
|
+
if (schema.uniqueKeys === undefined) {
|
|
286
|
+
return (`cannot be verified: the Schema of '${schema.tableName}' carries no uniqueKeys` +
|
|
287
|
+
`${primaryKey.length ? '' : ' and no primaryKey'} — regenerate the schemas with ` +
|
|
288
|
+
`sqlapi-generate-schema, an upsert must target a unique key the database enforces`);
|
|
289
|
+
}
|
|
290
|
+
if (schema.uniqueKeys.some((unique) => sameFieldSet(keys, unique)))
|
|
291
|
+
return null;
|
|
292
|
+
const available = [
|
|
293
|
+
...(primaryKey.length ? [`${formatKey(primaryKey)} primary key`] : []),
|
|
294
|
+
...schema.uniqueKeys.map(formatKey),
|
|
295
|
+
];
|
|
296
|
+
return (`is not a unique key of the table. An upsert must target exactly one: ` +
|
|
297
|
+
`${available.length ? available.join(', ') : 'the table has none'}`);
|
|
298
|
+
}
|
|
258
299
|
function validateComputedFields(config) {
|
|
259
300
|
const computed = config.computedFields;
|
|
260
301
|
if (!computed)
|
package/dist/lib/tenant.d.ts
CHANGED
|
@@ -2,6 +2,12 @@ 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
|
+
/**
|
|
6
|
+
* The columns of a scoped table that the engine fills or validates on insert, i.e. the ones a
|
|
7
|
+
* write body must offer without requiring: the own column of a direct scope, every column of an
|
|
8
|
+
* `anyOf` scope. An indirect scope names no column of its own — its FK is an ordinary field.
|
|
9
|
+
*/
|
|
10
|
+
export declare function tenantOwnColumns(scope: TenantScope | undefined): string[];
|
|
5
11
|
export declare function resolveTenant(options: SqlApiPluginOptions, tableConf: ITable, request: ApiRequest): Promise<TenantContext | undefined>;
|
|
6
12
|
/**
|
|
7
13
|
* The read predicate for a scope, as a fragment every caller appends to its own WHERE.
|
package/dist/lib/tenant.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tenant.d.ts","sourceRoot":"","sources":["../../src/lib/tenant.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC3C,OAAO,EAAE,gBAAgB,EAAE,KAAK,cAAc,EAAE,KAAK,yBAAyB,EAAE,MAAM,wBAAwB,CAAC;AAE/G,OAAO,KAAK,EAEV,MAAM,EACN,mBAAmB,EACnB,QAAQ,EACR,WAAW,EACX,iBAAiB,EACjB,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,EACd,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"tenant.d.ts","sourceRoot":"","sources":["../../src/lib/tenant.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC3C,OAAO,EAAE,gBAAgB,EAAE,KAAK,cAAc,EAAE,KAAK,yBAAyB,EAAE,MAAM,wBAAwB,CAAC;AAE/G,OAAO,KAAK,EAEV,MAAM,EACN,mBAAmB,EACnB,QAAQ,EACR,WAAW,EACX,iBAAiB,EACjB,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,EACd,MAAM,aAAa,CAAC;AAmBrB;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,WAAW,GAAG,SAAS,GAAG,MAAM,EAAE,CAGzE;AAuBD,wBAAsB,aAAa,CACjC,OAAO,EAAE,mBAAmB,EAC5B,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,UAAU,GAClB,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC,CAiBpC;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAClC,EAAE,EAAE,WAAW,EACf,KAAK,EAAE,WAAW,EAClB,SAAS,EAAE,QAAQ,EAAE,EACrB,QAAQ,EAAE,MAAM,GACf,gBAAgB,CAgBlB;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,aAAa,GAAG,SAAS,EACjC,SAAS,EAAE,MAAM,GAAG,SAAS,GAC5B,aAAa,GAAG,SAAS,CAI3B;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CACjC,EAAE,EAAE,WAAW,EACf,MAAM,EAAE,aAAa,GAAG,SAAS,EACjC,QAAQ,EAAE,MAAM,GACf,gBAAgB,GAAG,SAAS,CAoB9B;AAED,wBAAgB,eAAe,CAC7B,EAAE,EAAE,WAAW,EACf,KAAK,EAAE,mBAAmB,EAC1B,aAAa,EAAE,MAAM,GACpB,MAAM,CAMR;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,KAAK,EAAE,gBAAgB,EACvB,SAAS,EAAE,QAAQ,EAAE,GACpB,IAAI,CAYN;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,KAAK,EAAE,iBAAiB,GAAG,mBAAmB,EAC9C,SAAS,EAAE,QAAQ,EAAE,GACpB,IAAI,CAiBN;AAED;;GAEG;AACH,wBAAsB,gBAAgB,CACpC,EAAE,EAAE,WAAW,EACf,KAAK,EAAE,mBAAmB,EAC1B,SAAS,EAAE,QAAQ,EAAE,EACrB,QAAQ,EAAE,OAAO,EAAE,GAClB,OAAO,CAAC,IAAI,CAAC,CAyBf;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,EAAE,EAAE,WAAW,EACf,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,yBAAyB,GAAG,cAAc,EAAE,EACrD,MAAM,EAAE,aAAa,GACpB;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,EAAE,CAAA;CAAE,CAwBtC;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,yBAAyB,CAC7C,EAAE,EAAE,WAAW,EACf,MAAM,EAAE,aAAa,GAAG,SAAS,EACjC,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,MAAM,EAAE,EACtB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GACjC,OAAO,CAAC,IAAI,CAAC,CAkFf;AAED;;;;;;;;GAQG;AACH,wBAAsB,uBAAuB,CAC3C,EAAE,EAAE,WAAW,EACf,MAAM,EAAE,aAAa,GAAG,SAAS,EACjC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACpC,OAAO,CAAC,IAAI,CAAC,CAQf;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,KAAK,EAAE,WAAW,GACjB,IAAI,CAGN;AAED;;;;;GAKG;AACH,wBAAsB,qBAAqB,CACzC,EAAE,EAAE,WAAW,EACf,MAAM,EAAE,aAAa,GAAG,SAAS,EACjC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GACjC,OAAO,CAAC,IAAI,CAAC,CAaf;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,aAAa,GAAG,SAAS,GAAG,MAAM,EAAE,CAGlF;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,EAAE,EAAE,WAAW,EACf,MAAM,EAAE,aAAa,GAAG,SAAS,GAChC,gBAAgB,GAAG,SAAS,CAU9B;AAED;;;;GAIG;AACH,wBAAsB,qBAAqB,CACzC,EAAE,EAAE,WAAW,EACf,MAAM,EAAE,aAAa,GAAG,SAAS,EACjC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,EACxB,OAAO,EAAE,cAAc,GAAG,cAAc,EAAE,GACzC,OAAO,CAAC,IAAI,CAAC,CAaf;AAED;;;;;GAKG;AACH,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,WAAW,EACf,MAAM,EAAE,aAAa,GAAG,SAAS,EACjC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,cAAc,EAAE,GACpB,OAAO,CAAC,IAAI,CAAC,CAiBf"}
|
package/dist/lib/tenant.js
CHANGED
|
@@ -14,6 +14,16 @@ function isAnyOf(scope) {
|
|
|
14
14
|
function ownColumns(scope) {
|
|
15
15
|
return isAnyOf(scope) ? scope.anyOf : [scope.column];
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* The columns of a scoped table that the engine fills or validates on insert, i.e. the ones a
|
|
19
|
+
* write body must offer without requiring: the own column of a direct scope, every column of an
|
|
20
|
+
* `anyOf` scope. An indirect scope names no column of its own — its FK is an ordinary field.
|
|
21
|
+
*/
|
|
22
|
+
export function tenantOwnColumns(scope) {
|
|
23
|
+
if (!scope || isIndirect(scope))
|
|
24
|
+
return [];
|
|
25
|
+
return ownColumns(scope);
|
|
26
|
+
}
|
|
17
27
|
/**
|
|
18
28
|
* Whether this request could touch a scoped row at all: the addressed table is scoped, or one of
|
|
19
29
|
* the tables it can reach through a declared relation is.
|
|
@@ -44,6 +54,12 @@ export async function resolveTenant(options, tableConf, request) {
|
|
|
44
54
|
if (raw == null)
|
|
45
55
|
return undefined; // admin
|
|
46
56
|
const ids = Array.isArray(raw) ? raw : [raw];
|
|
57
|
+
// An empty list is a caller with no tenant at all — not an admin. Every consumer of `ids`
|
|
58
|
+
// would emit `IN ()` (a syntax error on both dialects) or, worse, a builder could drop the
|
|
59
|
+
// empty predicate and open the table. Refuse here, once, before any SQL exists.
|
|
60
|
+
if (ids.length === 0) {
|
|
61
|
+
throw httpError(403, 'Tenant required: the caller belongs to no tenant');
|
|
62
|
+
}
|
|
47
63
|
// `scope` stays undefined for an unscoped host: the ids still travel, so the tables it reaches
|
|
48
64
|
// enforce their own scopes, while the host itself keeps no filter.
|
|
49
65
|
return { ids, scope: tableConf.tenantScope };
|
|
@@ -7,7 +7,7 @@ export default async function bulkDeleteRoutes(fastify, options) {
|
|
|
7
7
|
operation: 'bulkDelete',
|
|
8
8
|
singlePkOnly: true,
|
|
9
9
|
method: 'POST',
|
|
10
|
-
url: (
|
|
10
|
+
url: (tableName) => `/bulk/${tableName}/delete`,
|
|
11
11
|
successStatus: 200,
|
|
12
12
|
schemas: (db, table) => ({
|
|
13
13
|
body: BulkDeleteTableBody(db, table, options.maxBulkItems ?? DEFAULT_MAX_BULK_ITEMS),
|
|
@@ -5,7 +5,7 @@ export default async function bulkUpsertRoutes(fastify, options) {
|
|
|
5
5
|
await registerForAllTables(fastify, options, {
|
|
6
6
|
operation: 'bulkUpsert',
|
|
7
7
|
method: 'PUT',
|
|
8
|
-
url: (
|
|
8
|
+
url: (tableName) => `/bulk/${tableName}`,
|
|
9
9
|
successStatus: 200,
|
|
10
10
|
schemas: (db, table) => ({
|
|
11
11
|
body: BulkUpsertTableBody(db, table, options.maxBulkItems ?? DEFAULT_MAX_BULK_ITEMS),
|
|
@@ -6,7 +6,7 @@ export default async function deleteRoutes(fastify, options) {
|
|
|
6
6
|
operation: 'delete',
|
|
7
7
|
singlePkOnly: true,
|
|
8
8
|
method: 'DELETE',
|
|
9
|
-
url: (
|
|
9
|
+
url: (tableName) => `/rest/${tableName}/:id`,
|
|
10
10
|
successStatus: 200,
|
|
11
11
|
schemas: (_db, _table, tc) => {
|
|
12
12
|
const pkField = primaryAsString(tc.primary);
|
|
@@ -6,7 +6,7 @@ export default async function getRoutes(fastify, options) {
|
|
|
6
6
|
operation: 'get',
|
|
7
7
|
singlePkOnly: true,
|
|
8
8
|
method: 'GET',
|
|
9
|
-
url: (
|
|
9
|
+
url: (tableName) => `/rest/${tableName}/:id`,
|
|
10
10
|
successStatus: 200,
|
|
11
11
|
schemas: (db, table) => ({
|
|
12
12
|
params: Type.Object({ id: Type.String() }),
|
|
@@ -4,7 +4,7 @@ export default async function insertRoutes(fastify, options) {
|
|
|
4
4
|
await registerForAllTables(fastify, options, {
|
|
5
5
|
operation: 'insert',
|
|
6
6
|
method: 'POST',
|
|
7
|
-
url: (
|
|
7
|
+
url: (tableName) => `/rest/${tableName}`,
|
|
8
8
|
successStatus: 201,
|
|
9
9
|
schemas: (db, table) => ({
|
|
10
10
|
body: InsertTableBody(db, table),
|
|
@@ -18,8 +18,11 @@ export interface AutoRouteSpec {
|
|
|
18
18
|
*/
|
|
19
19
|
singlePkOnly?: boolean;
|
|
20
20
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
21
|
-
/**
|
|
22
|
-
|
|
21
|
+
/**
|
|
22
|
+
* URL builder receiving the `DbTables` key — never `Schema.tableName`: several table
|
|
23
|
+
* configs may expose the same physical table, and each key needs its own route.
|
|
24
|
+
*/
|
|
25
|
+
url: (tableName: string) => string;
|
|
23
26
|
successStatus: 200 | 201;
|
|
24
27
|
/** Build per-table schemas (body, params, querystring, response). Response is wrapped with `{ [successStatus]: ... }` automatically. */
|
|
25
28
|
schemas: (dbTables: DbTables, tableName: string, tableConf: ITable) => RouteSchema;
|
|
@@ -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;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
|
|
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;;;OAGG;IACH,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"}
|
|
@@ -72,7 +72,7 @@ export async function registerForAllTables(fastify, options, spec) {
|
|
|
72
72
|
const schemas = spec.schemas(DbTables, tableName, tableConf);
|
|
73
73
|
fastify.route({
|
|
74
74
|
method: spec.method,
|
|
75
|
-
url: spec.url(
|
|
75
|
+
url: spec.url(tableName),
|
|
76
76
|
schema: buildRouteSchema(tableName, tableConf, spec, schemas),
|
|
77
77
|
onRequest: mergeOnRequests(options, tableConf),
|
|
78
78
|
handler: async (request, reply) => {
|
|
@@ -6,7 +6,7 @@ export default async function searchRoutes(fastify, options) {
|
|
|
6
6
|
await registerForAllTables(fastify, options, {
|
|
7
7
|
operation: 'search',
|
|
8
8
|
method: 'POST',
|
|
9
|
-
url: (
|
|
9
|
+
url: (tableName) => `/search/${tableName}`,
|
|
10
10
|
successStatus: 200,
|
|
11
11
|
schemas: (db, table) => ({
|
|
12
12
|
body: SearchTableBodyPost(db, table),
|
|
@@ -24,7 +24,7 @@ export default async function searchRoutes(fastify, options) {
|
|
|
24
24
|
leftAliases.length && `Available joinLeft aliases: ${leftAliases.join(', ')}`,
|
|
25
25
|
].filter(Boolean).join('. ');
|
|
26
26
|
},
|
|
27
|
-
handle: async (fastify, tableName,
|
|
27
|
+
handle: async (fastify, tableName, _tc, request) => {
|
|
28
28
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
29
|
const body = request.body;
|
|
30
30
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -52,7 +52,7 @@ export default async function searchRoutes(fastify, options) {
|
|
|
52
52
|
computeAvg: query.computeAvg,
|
|
53
53
|
maxRows,
|
|
54
54
|
}, request);
|
|
55
|
-
return { table:
|
|
55
|
+
return { table: tableName, ...result };
|
|
56
56
|
},
|
|
57
57
|
});
|
|
58
58
|
}
|
|
@@ -4,7 +4,7 @@ export default async function updateRoutes(fastify, options) {
|
|
|
4
4
|
await registerForAllTables(fastify, options, {
|
|
5
5
|
operation: 'update',
|
|
6
6
|
method: 'PUT',
|
|
7
|
-
url: (
|
|
7
|
+
url: (tableName) => `/rest/${tableName}`,
|
|
8
8
|
successStatus: 200,
|
|
9
9
|
schemas: (db, table) => ({
|
|
10
10
|
body: UpdateTableBody(db, table),
|
package/dist/types/cli.d.ts
CHANGED
|
@@ -53,6 +53,12 @@ export interface ColumnInfo {
|
|
|
53
53
|
* simple enough for the engine to make updatable accepts a write.
|
|
54
54
|
*/
|
|
55
55
|
is_view?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Names of the unique indexes (constraints included) this column belongs to, the primary key
|
|
58
|
+
* excluded. Only indexes over plain columns with no predicate: a partial or expression index
|
|
59
|
+
* cannot be named by a column list, so it cannot be an upsert key (ADR 0021).
|
|
60
|
+
*/
|
|
61
|
+
unique_keys?: string[];
|
|
56
62
|
}
|
|
57
63
|
export interface TableMap {
|
|
58
64
|
[schemaName: string]: {
|
|
@@ -65,6 +71,11 @@ export interface TableMap {
|
|
|
65
71
|
generated: string[];
|
|
66
72
|
/** True when this relation is a view (or a materialized view), not a base table. */
|
|
67
73
|
isView: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Unique keys other than the primary key, each as its fields (camelCase). Always set by
|
|
76
|
+
* `buildTableMap`; absent only on a map built by hand.
|
|
77
|
+
*/
|
|
78
|
+
uniqueKeys?: string[][];
|
|
68
79
|
};
|
|
69
80
|
}
|
|
70
81
|
//# sourceMappingURL=cli.d.ts.map
|
package/dist/types/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/types/cli.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,yFAAyF;IACzF,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;;;;OAQG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/types/cli.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,yFAAyF;IACzF,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;;;;OAQG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,MAAM,WAAW,QAAQ;IACvB,CAAC,UAAU,EAAE,MAAM,GAAG;QACpB,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC/B,iFAAiF;QACjF,OAAO,EAAE,MAAM,EAAE,CAAC;QAClB,mFAAmF;QACnF,SAAS,EAAE,MAAM,EAAE,CAAC;QACpB,oFAAoF;QACpF,MAAM,EAAE,OAAO,CAAC;QAChB;;;WAGG;QACH,UAAU,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC;KACzB,CAAC;CACH"}
|
package/dist/types/schema.d.ts
CHANGED
|
@@ -15,6 +15,13 @@ export interface SchemaDefinition<T = Record<string, TSchema>> {
|
|
|
15
15
|
* turn a client mistake into a driver error.
|
|
16
16
|
*/
|
|
17
17
|
generatedFields?: string[];
|
|
18
|
+
/**
|
|
19
|
+
* The table's unique keys other than the primary key, each as its fields (camelCase), as
|
|
20
|
+
* introspected from the DB. Set by generated schemas — always, even when empty, so an absent
|
|
21
|
+
* property means "not known" rather than "none". `defineTable` verifies every `upsertMap`
|
|
22
|
+
* rule against `primaryKey` and these (ADR 0021).
|
|
23
|
+
*/
|
|
24
|
+
uniqueKeys?: string[][];
|
|
18
25
|
/**
|
|
19
26
|
* True when the underlying relation is a view. Set by generated schemas.
|
|
20
27
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/types/schema.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAE1D,MAAM,WAAW,gBAAgB,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAC3D,GAAG,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,MAAM,EAAE,CAAC,CAAC;IACV,UAAU,EAAE,OAAO,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,iBAAiB,EAAE,OAAO,CAAC;IAC3B,4FAA4F;IAC5F,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB"}
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/types/schema.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAE1D,MAAM,WAAW,gBAAgB,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAC3D,GAAG,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,MAAM,EAAE,CAAC,CAAC;IACV,UAAU,EAAE,OAAO,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,iBAAiB,EAAE,OAAO,CAAC;IAC3B,4FAA4F;IAC5F,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC;IACxB;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# 0020. No conditional or mass update route — a predicate write is a custom route
|
|
2
|
+
|
|
3
|
+
- **Status**: accepted
|
|
4
|
+
- **Date**: 2026-09-24
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
The `update` route addresses exactly one row: its `WHERE` is every primary key column plus
|
|
9
|
+
the tenant scope, and nothing the client sends can add to it. Two real needs fall outside
|
|
10
|
+
that shape:
|
|
11
|
+
|
|
12
|
+
- **Compare-and-set.** Claiming a row so that two clients cannot both claim it
|
|
13
|
+
(`UPDATE … SET in_progress_since = now() WHERE id = ? AND in_progress_since IS NULL`),
|
|
14
|
+
guarded state transitions (`… AND status = 'draft'`), optimistic locking on a `version`
|
|
15
|
+
column. Correctness depends on the check and the write being one statement; a
|
|
16
|
+
`beforeUpdate` that reads first and writes after is exactly the race it is meant to
|
|
17
|
+
prevent.
|
|
18
|
+
- **Mass update by predicate.** Setting a value on every row matching a filter.
|
|
19
|
+
|
|
20
|
+
A route was designed to cover both: a new `updateWhere` operation, opt-in (registered only
|
|
21
|
+
when listed in `operations`), `PUT {p}/update/{key}` with `{ where: { filters, conditions },
|
|
22
|
+
fields }` reusing the search grammar, the tenant guard injected, `validate`/`beforeUpdate`
|
|
23
|
+
run on `fields`, `{ affected }` in response. Compare-and-set is then a `where` that includes
|
|
24
|
+
the primary key.
|
|
25
|
+
|
|
26
|
+
## Decision
|
|
27
|
+
|
|
28
|
+
The plugin does not expose a conditional or mass update route, and the single-row `update`
|
|
29
|
+
does not accept a precondition. A write whose target is a predicate is built as a custom
|
|
30
|
+
route on `QueryClient.update(table, fields, where, extraCondition)`, which already takes a
|
|
31
|
+
`ConditionBuilder` and returns the number of rows it changed.
|
|
32
|
+
|
|
33
|
+
The design was sound; the cost was not worth it. It is the first route that writes N rows
|
|
34
|
+
chosen by the client, so a defect in it is destructive and tenant-crossing rather than a
|
|
35
|
+
wrong answer. Building it touches several security-sensitive pieces at once:
|
|
36
|
+
|
|
37
|
+
- `QueryClient.update`, which today assumes an equality `where` and would emit `WHERE AND …`
|
|
38
|
+
for a condition-only one;
|
|
39
|
+
- the search condition builder, shared with every read;
|
|
40
|
+
- the tenant guards, which on update are keyed on a primary key (`assertTenantOwnership`)
|
|
41
|
+
and would need the row-guard form for indirect scopes instead;
|
|
42
|
+
- the meaning of `validate`, `beforeUpdate` and `afterUpdate`, whose `record` would no longer
|
|
43
|
+
carry a primary key — breaking any existing hook that reads one.
|
|
44
|
+
|
|
45
|
+
Against that, the need is narrow and the custom route is a few lines, owned by the consumer
|
|
46
|
+
who knows the predicate it must enforce.
|
|
47
|
+
|
|
48
|
+
## Alternatives considered
|
|
49
|
+
|
|
50
|
+
- **`updateWhere` operation, opt-in** — the design above. Rejected for the risk and blast
|
|
51
|
+
radius described in the decision, not for a flaw in its shape.
|
|
52
|
+
- **`expect` on the single-row `update`** (a precondition next to the primary key, `409` when
|
|
53
|
+
it fails) — rejected: it narrows the write instead of widening it, so the blast radius is
|
|
54
|
+
small, but it still reopens the update `WHERE` to client-supplied predicates, and it needs
|
|
55
|
+
a readability rule to avoid an oracle (see Consequences). One custom route covers it.
|
|
56
|
+
- **Read-then-write in `beforeUpdate`** — not an alternative: two concurrent requests both
|
|
57
|
+
pass the read.
|
|
58
|
+
|
|
59
|
+
## Consequences
|
|
60
|
+
|
|
61
|
+
- Compare-and-set and predicate updates are custom routes. [AGENTS_BACKEND.md](../../AGENTS_BACKEND.md)
|
|
62
|
+
carries the pattern ("Conditional update — claim a row atomically").
|
|
63
|
+
- Everything the auto routes do for free is the custom route's job there: `QueryClient` runs
|
|
64
|
+
no hooks, no validation and **no tenant scoping**, so the route must add its own tenant
|
|
65
|
+
predicate and must not let a client-chosen predicate reach a column the caller cannot read —
|
|
66
|
+
otherwise `affected: 0 | 1` becomes an oracle on its value.
|
|
67
|
+
- Revisit only with a concrete, repeated need that a custom route serves badly; the design
|
|
68
|
+
notes above are the starting point.
|