@cosmicdrift/kumiko-framework 0.377.0 → 0.379.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 +4 -1
- package/agents/examples/compose-stacks.md +1 -2
- package/agents/examples/currencies-global.md +2 -3
- package/agents/examples/delivery-notifications.md +25 -2
- package/agents/examples/embedded.md +177 -0
- package/agents/examples/event-sourcing.md +6 -5
- package/agents/examples/legal-pages.md +5 -5
- package/agents/examples/reference-data.md +26 -21
- package/agents/examples/relations.md +2 -1
- package/agents/features.md +5 -5
- package/agents/widgets.md +1 -0
- package/dist/api/entity-read-access.d.ts +2 -0
- package/dist/api/entity-read-access.js +27 -0
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/server.js +10 -3
- package/dist/api/sse-route.d.ts +5 -0
- package/dist/api/sse-route.js +59 -9
- package/dist/db/entity-table-from-registry.d.ts +2 -1
- package/dist/db/entity-table-meta.d.ts +0 -2
- package/dist/db/entity-table-meta.js +1 -3
- package/dist/db/event-store-executor-write.js +21 -16
- package/dist/db/executor-managed-tables.d.ts +6 -0
- package/dist/db/executor-managed-tables.js +40 -0
- package/dist/db/index.d.ts +4 -2
- package/dist/db/index.js +4 -2
- package/dist/db/money.d.ts +3 -2
- package/dist/db/money.js +2 -7
- package/dist/db/queries/backfill-pii.js +22 -12
- package/dist/db/queries/database-now.d.ts +5 -0
- package/dist/db/queries/database-now.js +12 -0
- package/dist/db/queries/event-store-orphan-rows.d.ts +8 -0
- package/dist/db/queries/event-store-orphan-rows.js +32 -0
- package/dist/db/query.js +28 -0
- package/dist/db/reference-data.d.ts +5 -4
- package/dist/db/reference-data.js +23 -21
- package/dist/db/table-builder.js +2 -0
- package/dist/db/tenant-db-runner.d.ts +2 -0
- package/dist/db/tenant-db-runner.js +7 -0
- package/dist/db/tenant-db.d.ts +3 -0
- package/dist/db/tenant-db.js +54 -18
- package/dist/engine/boot-validator/entity-handler.js +12 -4
- package/dist/engine/boot-validator/form-derivations.d.ts +4 -0
- package/dist/engine/boot-validator/form-derivations.js +82 -0
- package/dist/engine/boot-validator/query-refs.js +8 -0
- package/dist/engine/boot-validator/screens.js +50 -12
- package/dist/engine/build-config-feature-schema.js +33 -10
- package/dist/engine/embedded-derived.d.ts +1 -1
- package/dist/engine/embedded-derived.js +7 -1
- package/dist/engine/feature-ui-extensions.js +4 -0
- package/dist/engine/form-derivations.d.ts +21 -0
- package/dist/engine/form-derivations.js +175 -0
- package/dist/engine/index.d.ts +4 -1
- package/dist/engine/index.js +2 -0
- package/dist/engine/minor-units.d.ts +2 -0
- package/dist/engine/minor-units.js +8 -0
- package/dist/engine/registry-state.js +2 -0
- package/dist/engine/registry-validate.js +4 -1
- package/dist/engine/schema-builder.js +18 -4
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +3 -0
- package/dist/engine/soft-delete-cleanup.js +53 -24
- package/dist/engine/steps/unsafe-projection-delete.js +2 -1
- package/dist/engine/template-variables.d.ts +2 -1
- package/dist/engine/template-variables.js +4 -0
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/with-form-derivations.d.ts +9 -0
- package/dist/engine/with-form-derivations.js +16 -0
- package/dist/entrypoint/index.d.ts +1 -3
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +4 -1
- package/dist/errors/serialize.d.ts +1 -0
- package/dist/errors/serialize.js +9 -5
- package/dist/errors/write-error-info.js +3 -2
- package/dist/errors/zod-bridge.js +7 -0
- package/dist/event-store/index.d.ts +1 -0
- package/dist/event-store/index.js +1 -0
- package/dist/files/action-form-upload.d.ts +16 -0
- package/dist/files/action-form-upload.js +45 -0
- package/dist/files/bind-pending-file-refs.d.ts +14 -0
- package/dist/files/bind-pending-file-refs.js +44 -4
- package/dist/files/create-file-ref.d.ts +3 -0
- package/dist/files/create-file-ref.js +41 -13
- package/dist/files/file-routes.js +29 -2
- package/dist/files/index.d.ts +2 -1
- package/dist/files/index.js +1 -0
- package/dist/files/purge-expired-file-ref-bytes.d.ts +3 -2
- package/dist/files/purge-expired-file-ref-bytes.js +11 -5
- package/dist/i18n/settings-hub-keys.js +1 -0
- package/dist/internal/testing.d.ts +2 -0
- package/dist/internal/testing.js +15 -1
- package/dist/migrations/pii-event-backfill.js +14 -7
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +14 -3
- package/dist/pipeline/cascade-handler.js +79 -10
- package/dist/pipeline/dispatch-shared.js +1 -9
- package/dist/pipeline/msp-rebuild.js +5 -3
- package/dist/pipeline/projection-rebuild.js +4 -2
- package/dist/pipeline/projections-runner.js +2 -1
- package/dist/rate-limit/index.d.ts +1 -1
- package/dist/rate-limit/index.js +1 -1
- package/dist/rate-limit/middleware.d.ts +4 -0
- package/dist/rate-limit/middleware.js +5 -2
- package/dist/search/configure-tenant-index.d.ts +4 -0
- package/dist/search/configure-tenant-index.js +8 -0
- package/dist/search/index.d.ts +1 -0
- package/dist/search/index.js +1 -0
- package/dist/ui-types/index.d.ts +4 -2
- package/dist/ui-types/index.js +3 -2
- package/dist/ui-types/list-row-meta.d.ts +3 -0
- package/dist/ui-types/list-row-meta.js +12 -1
- package/kumiko-es-tables.json +6 -0
- package/package.json +4 -3
- package/src/changes.json +142 -0
- package/src/scripts/codemod/README.md +20 -0
- package/src/scripts/codemod/codemod-support.ts +1 -1
- package/src/scripts/codemod/feature-layout.ts +416 -0
- package/src/scripts/codemod/package-merge.ts +0 -11
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { asRawClient } from "../query.js";
|
|
2
|
+
import { tableNameOf } from "../table-introspection.js";
|
|
3
|
+
import { isBoundTenantDb, tenantDbWriteRunner } from "../tenant-db-runner.js";
|
|
4
|
+
import { toPostgresArrayLiteral } from "./postgres-array-literal.js";
|
|
5
|
+
import { quoteTableIdent } from "./table-ops.js";
|
|
6
|
+
// Deletes only rows a projection rebuild would not recreate: the row's stream has no events, or is archived.
|
|
7
|
+
// One statement, so a stream cannot gain events between check and delete.
|
|
8
|
+
export async function deleteRowsWithoutReplayableStream(db, args) {
|
|
9
|
+
if (args.ids.length === 0)
|
|
10
|
+
return [];
|
|
11
|
+
const tenantBound = isBoundTenantDb(db);
|
|
12
|
+
const tenantId = tenantBound ? db.tenantId : args.tenantId;
|
|
13
|
+
const runner = tenantBound ? tenantDbWriteRunner(db, "deleteRowsWithoutReplayableStream") : db;
|
|
14
|
+
const params = [toPostgresArrayLiteral(args.ids)];
|
|
15
|
+
let tenantFilter = "";
|
|
16
|
+
if (tenantId !== undefined) {
|
|
17
|
+
params.push(tenantId);
|
|
18
|
+
tenantFilter = `AND t."tenant_id" = $2`;
|
|
19
|
+
}
|
|
20
|
+
const rows = await asRawClient(runner).unsafe(`DELETE FROM ${quoteTableIdent(tableNameOf(args.table))} t
|
|
21
|
+
WHERE t."id" = ANY($1::uuid[]) ${tenantFilter}
|
|
22
|
+
AND NOT EXISTS (
|
|
23
|
+
SELECT 1 FROM "kumiko_events" e
|
|
24
|
+
WHERE e."tenant_id" = t."tenant_id" AND e."aggregate_id" = t."id"
|
|
25
|
+
AND NOT EXISTS (
|
|
26
|
+
SELECT 1 FROM "kumiko_archived_streams" a
|
|
27
|
+
WHERE a."tenant_id" = e."tenant_id" AND a."aggregate_id" = e."aggregate_id"
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
RETURNING t."id"`, params);
|
|
31
|
+
return rows.map((row) => row.id);
|
|
32
|
+
}
|
package/dist/db/query.js
CHANGED
|
@@ -24,6 +24,7 @@ import { InternalError } from "../errors/index.js";
|
|
|
24
24
|
import { createFallbackLogger } from "../logging/index.js";
|
|
25
25
|
import { parseJsonSafe } from "../utils/safe-json.js";
|
|
26
26
|
import { SQL_EXPR_BRAND } from "./dialect.js";
|
|
27
|
+
import { executorManagedWriteDenied } from "./executor-managed-tables.js";
|
|
27
28
|
import { extractPgError } from "./pg-error.js";
|
|
28
29
|
import { toSnakeCase } from "./table-builder.js";
|
|
29
30
|
import { asEntityTableMeta, requireEntityTableMeta } from "./table-introspection.js";
|
|
@@ -987,6 +988,9 @@ export async function insertMany(db, table, rows) {
|
|
|
987
988
|
if (rows.length === 0)
|
|
988
989
|
return [];
|
|
989
990
|
assertNotTenantScoped(db, "insertMany");
|
|
991
|
+
const managedDenied = executorManagedWriteDenied(db, table, "insertMany");
|
|
992
|
+
if (managedDenied)
|
|
993
|
+
throw managedDenied;
|
|
990
994
|
const info = extractTableInfo(table);
|
|
991
995
|
// Use the column-set from the first row; assume all rows share keys.
|
|
992
996
|
const firstRow = rows[0];
|
|
@@ -1028,6 +1032,9 @@ export async function insertOne(db, table, values) {
|
|
|
1028
1032
|
if (scoped) {
|
|
1029
1033
|
return scoped.insertOne(table, values);
|
|
1030
1034
|
}
|
|
1035
|
+
const managedDenied = executorManagedWriteDenied(db, table, "insertOne");
|
|
1036
|
+
if (managedDenied)
|
|
1037
|
+
throw managedDenied;
|
|
1031
1038
|
const info = extractTableInfo(table);
|
|
1032
1039
|
const entries = Object.entries(values)
|
|
1033
1040
|
.filter(([k]) => info.hasColumn(k))
|
|
@@ -1064,6 +1071,9 @@ export async function updateMany(db, table, set, where) {
|
|
|
1064
1071
|
if (scoped) {
|
|
1065
1072
|
return scoped.updateMany(table, set, where);
|
|
1066
1073
|
}
|
|
1074
|
+
const managedDenied = executorManagedWriteDenied(db, table, "updateMany");
|
|
1075
|
+
if (managedDenied)
|
|
1076
|
+
throw managedDenied;
|
|
1067
1077
|
const info = extractTableInfo(table);
|
|
1068
1078
|
const setEntries = Object.entries(set).map(([k, v]) => {
|
|
1069
1079
|
const col = info.columnOf(k);
|
|
@@ -1103,6 +1113,9 @@ export async function deleteMany(db, table, where) {
|
|
|
1103
1113
|
if (scoped) {
|
|
1104
1114
|
return scoped.deleteMany(table, where);
|
|
1105
1115
|
}
|
|
1116
|
+
const managedDenied = executorManagedWriteDenied(db, table, "deleteMany");
|
|
1117
|
+
if (managedDenied)
|
|
1118
|
+
throw managedDenied;
|
|
1106
1119
|
const info = extractTableInfo(table);
|
|
1107
1120
|
const w = buildWhereClause(info, where, 1);
|
|
1108
1121
|
let sqlText = `DELETE FROM ${quoteIdent(info.name)}`;
|
|
@@ -1280,6 +1293,9 @@ export async function aggregateWhere(db, table, spec, where = {}) {
|
|
|
1280
1293
|
// biome-ignore lint/suspicious/noExplicitAny: see selectMany default
|
|
1281
1294
|
export async function upsertOnConflict(db, table, values, options) {
|
|
1282
1295
|
assertNotTenantScoped(db, "upsertOnConflict");
|
|
1296
|
+
const managedDenied = executorManagedWriteDenied(db, table, "upsertOnConflict");
|
|
1297
|
+
if (managedDenied)
|
|
1298
|
+
throw managedDenied;
|
|
1283
1299
|
const info = extractTableInfo(table);
|
|
1284
1300
|
const entries = insertEntries(info, values);
|
|
1285
1301
|
const conflictCols = resolveConflictColumns(table, info, options.conflictKeys);
|
|
@@ -1323,6 +1339,9 @@ export async function upsertOnConflict(db, table, values, options) {
|
|
|
1323
1339
|
}
|
|
1324
1340
|
// biome-ignore lint/suspicious/noExplicitAny: see selectMany default
|
|
1325
1341
|
export async function upsertByPk(db, table, values, updateOnConflict) {
|
|
1342
|
+
const managedDenied = executorManagedWriteDenied(db, table, "upsertByPk");
|
|
1343
|
+
if (managedDenied)
|
|
1344
|
+
throw managedDenied;
|
|
1326
1345
|
const info = extractTableInfo(table);
|
|
1327
1346
|
const conflictKeys = resolveConflictColumns(table, info, undefined);
|
|
1328
1347
|
const fieldKeys = conflictKeys.map((col) => info.fieldOf(col));
|
|
@@ -1334,6 +1353,9 @@ export async function upsertByPk(db, table, values, updateOnConflict) {
|
|
|
1334
1353
|
// biome-ignore lint/suspicious/noExplicitAny: see selectMany default
|
|
1335
1354
|
export async function insertOnConflictDoNothing(db, table, values, options) {
|
|
1336
1355
|
assertNotTenantScoped(db, "insertOnConflictDoNothing");
|
|
1356
|
+
const managedDenied = executorManagedWriteDenied(db, table, "insertOnConflictDoNothing");
|
|
1357
|
+
if (managedDenied)
|
|
1358
|
+
throw managedDenied;
|
|
1337
1359
|
const info = extractTableInfo(table);
|
|
1338
1360
|
const entries = insertEntries(info, values);
|
|
1339
1361
|
const conflictCols = resolveConflictColumns(table, info, options?.conflictKeys);
|
|
@@ -1352,6 +1374,9 @@ export async function insertOnConflictDoNothing(db, table, values, options) {
|
|
|
1352
1374
|
// biome-ignore lint/suspicious/noExplicitAny: see selectMany default
|
|
1353
1375
|
export async function incrementCounter(db, table, values, increments, options = {}) {
|
|
1354
1376
|
assertNotTenantScoped(db, "incrementCounter");
|
|
1377
|
+
const managedDenied = executorManagedWriteDenied(db, table, "incrementCounter");
|
|
1378
|
+
if (managedDenied)
|
|
1379
|
+
throw managedDenied;
|
|
1355
1380
|
const info = extractTableInfo(table);
|
|
1356
1381
|
const entries = insertEntries(info, values);
|
|
1357
1382
|
const conflictCols = resolveConflictColumns(table, info, options.conflictKeys);
|
|
@@ -1402,6 +1427,9 @@ export async function incrementCounter(db, table, values, increments, options =
|
|
|
1402
1427
|
return coerceRow(first, info);
|
|
1403
1428
|
}
|
|
1404
1429
|
export async function deleteManyBatched(db, table, where, options) {
|
|
1430
|
+
const managedDenied = executorManagedWriteDenied(db, table, "deleteManyBatched");
|
|
1431
|
+
if (managedDenied)
|
|
1432
|
+
throw managedDenied;
|
|
1405
1433
|
const info = extractTableInfo(table);
|
|
1406
1434
|
const w = buildWhereClause(info, where, 1);
|
|
1407
1435
|
if (!w.sqlText) {
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
+
import type { Registry } from "../engine/index.ts";
|
|
1
2
|
import type { ReferenceDataDef } from "../engine/types/index.ts";
|
|
2
3
|
import type { DbConnection } from "./connection.ts";
|
|
3
|
-
import type { TableColumns } from "./dialect.ts";
|
|
4
|
-
type Table = TableColumns<any>;
|
|
5
4
|
/**
|
|
6
5
|
* Seed reference data at boot time.
|
|
7
6
|
* For each ReferenceDataDef: upsert rows (insert missing, update changed, never delete).
|
|
8
7
|
* Upsert key defaults to the first field in the data object.
|
|
8
|
+
*
|
|
9
|
+
* Rows go through the entity's event-store executor as the system user in the
|
|
10
|
+
* SYSTEM_TENANT_ID stream, so the seed is replayable on a projection rebuild.
|
|
9
11
|
*/
|
|
10
|
-
export declare function seedReferenceData(defs: readonly ReferenceDataDef[],
|
|
12
|
+
export declare function seedReferenceData(defs: readonly ReferenceDataDef[], registry: Registry, db: DbConnection): Promise<{
|
|
11
13
|
inserted: number;
|
|
12
14
|
updated: number;
|
|
13
15
|
}>;
|
|
14
|
-
export {};
|
|
@@ -1,21 +1,26 @@
|
|
|
1
|
-
import { fetchOne
|
|
1
|
+
import { fetchOne } from "../db/query.js";
|
|
2
|
+
import { createSystemUser } from "../engine/system-user.js";
|
|
2
3
|
import { SYSTEM_TENANT_ID } from "../engine/types/index.js";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
}
|
|
4
|
+
import { reraiseAsKumikoError } from "../errors/index.js";
|
|
5
|
+
import { entityTableFromRegistry } from "./entity-table-from-registry.js";
|
|
6
|
+
import { createEventStoreExecutor } from "./event-store-executor.js";
|
|
7
|
+
import { createTenantDb } from "./tenant-db.js";
|
|
8
8
|
/**
|
|
9
9
|
* Seed reference data at boot time.
|
|
10
10
|
* For each ReferenceDataDef: upsert rows (insert missing, update changed, never delete).
|
|
11
11
|
* Upsert key defaults to the first field in the data object.
|
|
12
|
+
*
|
|
13
|
+
* Rows go through the entity's event-store executor as the system user in the
|
|
14
|
+
* SYSTEM_TENANT_ID stream, so the seed is replayable on a projection rebuild.
|
|
12
15
|
*/
|
|
13
|
-
export async function seedReferenceData(defs,
|
|
16
|
+
export async function seedReferenceData(defs, registry, db) {
|
|
14
17
|
let inserted = 0;
|
|
15
18
|
let updated = 0;
|
|
19
|
+
const by = createSystemUser(SYSTEM_TENANT_ID);
|
|
20
|
+
const tdb = createTenantDb({ db, tenantId: SYSTEM_TENANT_ID, mode: "system" });
|
|
16
21
|
for (const def of defs) {
|
|
17
|
-
const
|
|
18
|
-
if (!
|
|
22
|
+
const entity = registry.getEntity(def.entityName);
|
|
23
|
+
if (!entity)
|
|
19
24
|
continue;
|
|
20
25
|
if (def.data.length === 0)
|
|
21
26
|
continue;
|
|
@@ -26,11 +31,13 @@ export async function seedReferenceData(defs, tables, db) {
|
|
|
26
31
|
if (!firstKey)
|
|
27
32
|
continue;
|
|
28
33
|
const upsertKey = def.upsertKey ?? firstKey;
|
|
34
|
+
const table = entityTableFromRegistry(registry, def.entityName, entity);
|
|
35
|
+
const executor = createEventStoreExecutor(table, entity, { entityName: def.entityName });
|
|
29
36
|
for (const row of def.data) {
|
|
30
37
|
const keyValue = row[upsertKey];
|
|
31
38
|
if (keyValue === undefined)
|
|
32
39
|
continue;
|
|
33
|
-
const existing =
|
|
40
|
+
const existing = await fetchOne(db, table, { [upsertKey]: keyValue });
|
|
34
41
|
if (existing) {
|
|
35
42
|
const changes = {};
|
|
36
43
|
for (const [field, value] of Object.entries(row)) {
|
|
@@ -41,21 +48,16 @@ export async function seedReferenceData(defs, tables, db) {
|
|
|
41
48
|
}
|
|
42
49
|
}
|
|
43
50
|
if (Object.keys(changes).length > 0) {
|
|
44
|
-
await
|
|
51
|
+
const result = await executor.update({ id: existing.id, version: existing.version, changes }, by, tdb);
|
|
52
|
+
if (!result.isSuccess)
|
|
53
|
+
throw reraiseAsKumikoError(result.error);
|
|
45
54
|
updated++;
|
|
46
55
|
}
|
|
47
56
|
}
|
|
48
57
|
else {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (hasColumn(table, "tenantId"))
|
|
53
|
-
values["tenantId"] = SYSTEM_TENANT_ID;
|
|
54
|
-
if (hasColumn(table, "version"))
|
|
55
|
-
values["version"] = 1;
|
|
56
|
-
if (hasColumn(table, "insertedAt"))
|
|
57
|
-
values["insertedAt"] = Temporal.Now.instant();
|
|
58
|
-
await insertOne(db, table, values);
|
|
58
|
+
const result = await executor.create({ ...row, tenantId: SYSTEM_TENANT_ID }, by, tdb);
|
|
59
|
+
if (!result.isSuccess)
|
|
60
|
+
throw reraiseAsKumikoError(result.error);
|
|
59
61
|
inserted++;
|
|
60
62
|
}
|
|
61
63
|
}
|
package/dist/db/table-builder.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { KUMIKO_META_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
2
|
import { toSnakeCase } from "../utils/case.js";
|
|
3
3
|
import { assertUnreachable } from "../utils/index.js";
|
|
4
|
+
import { markExecutorManagedTable } from "./executor-managed-tables.js";
|
|
4
5
|
import { columnHandleName } from "./table-introspection.js";
|
|
5
6
|
export { toSnakeCase } from "../utils/case.js";
|
|
6
7
|
import { bigint, boolean, decimalColumn, doublePrecision, index, instant, integer, isSqlExpression, jsonb, moneyAmount, table as pgTable, plainDate, serial, sql, text, uniqueIndex, uuid, } from "./dialect.js";
|
|
@@ -420,5 +421,6 @@ export function buildEntityTable(entityName, entity, options) {
|
|
|
420
421
|
return indexes;
|
|
421
422
|
});
|
|
422
423
|
stampEntityTableMeta(built, entity);
|
|
424
|
+
markExecutorManagedTable(built);
|
|
423
425
|
return built;
|
|
424
426
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
2
2
|
import { InternalError } from "../errors/index.ts";
|
|
3
3
|
import type { DbRunner } from "./connection.ts";
|
|
4
|
+
export declare function isBoundTenantDb(value: unknown): value is TenantDb;
|
|
5
|
+
export declare function isDbRunner(value: unknown): value is DbRunner;
|
|
4
6
|
export declare function bindTenantDbRunner(tenantDb: TenantDb, runner: DbRunner): void;
|
|
5
7
|
export declare function tenantDbRunner(tenantDb: TenantDb): DbRunner;
|
|
6
8
|
export declare function denyTenantDbWrites(tenantDb: TenantDb, reason: string): void;
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
1
2
|
import { InternalError } from "../errors/index.js";
|
|
2
3
|
const tenantDbRunners = new WeakMap();
|
|
4
|
+
export function isBoundTenantDb(value) {
|
|
5
|
+
return typeof value === "object" && value !== null && tenantDbRunners.has(value);
|
|
6
|
+
}
|
|
7
|
+
export function isDbRunner(value) {
|
|
8
|
+
return typeof propertyOf(value, "unsafe") === "function";
|
|
9
|
+
}
|
|
3
10
|
export function bindTenantDbRunner(tenantDb, runner) {
|
|
4
11
|
tenantDbRunners.set(tenantDb, runner);
|
|
5
12
|
}
|
package/dist/db/tenant-db.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ import type { DbRunner } from "./connection.ts";
|
|
|
11
11
|
import { tableNameOf } from "./table-introspection.ts";
|
|
12
12
|
type Table = SchemaTable;
|
|
13
13
|
export { SYSTEM_SCOPE_CHECK_BRAND, type TenantDb, type TenantDbMode, type UncheckedSystemDb, } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
14
|
+
export declare function withExecutorManagedWrites(runner: DbRunner): DbRunner;
|
|
15
|
+
export declare function withoutExecutorManagedWrites(runner: DbRunner): DbRunner;
|
|
14
16
|
export declare function assertPersonalDataWrite(db: TenantDb, tableName: string, keys: readonly string[], entity: EntityDefinition): void;
|
|
15
17
|
export declare function unsafeRawForDeclaredStep(holder: TenantDb | UncheckedSystemDb, reason: string): DbRunner;
|
|
16
18
|
export declare function withSystemDbUnsafeRawGrant(systemDb: UncheckedSystemDb, grant: EscapeHatchDeclaration | undefined, deniedCallerLabel: string): UncheckedSystemDb;
|
|
@@ -54,5 +56,6 @@ export type CreateTenantDbOptions = {
|
|
|
54
56
|
readonly grants?: TenantDbGrants;
|
|
55
57
|
};
|
|
56
58
|
export declare function createTenantDb(options: CreateTenantDbOptions): TenantDb;
|
|
59
|
+
export declare function createTenantDataDestroyDb(options: Omit<CreateTenantDbOptions, "mode">): TenantDb;
|
|
57
60
|
export declare function createProjectingTenantDb(db: DbRunner, tenantId: TenantId, projectionRegistry: Registry | undefined): TenantDb;
|
|
58
61
|
export { asRawClient };
|
package/dist/db/tenant-db.js
CHANGED
|
@@ -4,7 +4,10 @@ import { asEntityTableMeta, asRawClient, aggregateWhere as bunAggregateWhere, co
|
|
|
4
4
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
5
5
|
import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied, } from "../errors/index.js";
|
|
6
6
|
import { emitDbQuery, fallbackEscapeHatchReporter, registerStandardMetrics, } from "../observability/index.js";
|
|
7
|
+
import { executorManagedWriteDenied, registerExecutorManagedWriteRunner, } from "./executor-managed-tables.js";
|
|
7
8
|
import { bindProjectionRegistry, projectionRegistryOf } from "./projection-registry-binding.js";
|
|
9
|
+
import { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
|
|
10
|
+
import { queryDatabaseNow } from "./queries/database-now.js";
|
|
8
11
|
import { hasFieldProperty, tableNameOf } from "./table-introspection.js";
|
|
9
12
|
import { bindTenantDbRunner, denyTenantDbWrites, tenantDbRunner, tenantDbWritesDeniedError, } from "./tenant-db-runner.js";
|
|
10
13
|
export { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
@@ -16,8 +19,8 @@ const personalDataGates = new WeakMap();
|
|
|
16
19
|
// Lets createTenantDb(ctx.db.unsafeRaw(), ...) inherit the gate and the projection registry.
|
|
17
20
|
// Keyed by a per-grant proxy, never the shared pool/tx: tagging that would leak onto every sibling TenantDb.
|
|
18
21
|
const runnerPersonalDataGates = new WeakMap();
|
|
19
|
-
function boundRunner(runner, gate, projectionRegistry) {
|
|
20
|
-
if (!gate && !projectionRegistry)
|
|
22
|
+
function boundRunner(runner, gate, projectionRegistry, executorManagedWrites) {
|
|
23
|
+
if (!gate && !projectionRegistry && !executorManagedWrites)
|
|
21
24
|
return runner;
|
|
22
25
|
const proxy = new Proxy(runner, {
|
|
23
26
|
// Tagged-template calls need the real driver object as `this`.
|
|
@@ -32,7 +35,7 @@ function boundRunner(runner, gate, projectionRegistry) {
|
|
|
32
35
|
// Bun.SQL and postgres.js hand back a raw connection; re-wrap it so the gate survives.
|
|
33
36
|
return async (...args) => {
|
|
34
37
|
const reserved = await Reflect.apply(value, target, args);
|
|
35
|
-
return boundRunner(reserved, gate, projectionRegistry);
|
|
38
|
+
return boundRunner(reserved, gate, projectionRegistry, executorManagedWrites);
|
|
36
39
|
};
|
|
37
40
|
}
|
|
38
41
|
if (prop === "begin" ||
|
|
@@ -47,7 +50,7 @@ function boundRunner(runner, gate, projectionRegistry) {
|
|
|
47
50
|
}
|
|
48
51
|
const gatedArgs = [
|
|
49
52
|
...args.slice(0, -1),
|
|
50
|
-
(tx) => callback(boundRunner(tx, gate, projectionRegistry)),
|
|
53
|
+
(tx) => callback(boundRunner(tx, gate, projectionRegistry, executorManagedWrites)),
|
|
51
54
|
];
|
|
52
55
|
return Reflect.apply(value, target, gatedArgs);
|
|
53
56
|
};
|
|
@@ -59,8 +62,30 @@ function boundRunner(runner, gate, projectionRegistry) {
|
|
|
59
62
|
runnerPersonalDataGates.set(proxy, gate);
|
|
60
63
|
if (projectionRegistry)
|
|
61
64
|
bindProjectionRegistry(proxy, projectionRegistry);
|
|
65
|
+
if (executorManagedWrites) {
|
|
66
|
+
registerExecutorManagedWriteRunner(proxy);
|
|
67
|
+
capableRunnerInners.set(proxy, runner);
|
|
68
|
+
}
|
|
62
69
|
return proxy;
|
|
63
70
|
}
|
|
71
|
+
const capableRunnerInners = new WeakMap();
|
|
72
|
+
const capableRunnerByInner = new WeakMap();
|
|
73
|
+
// Framework-private (not re-exported from db/index.ts): the only way to obtain a runner that may
|
|
74
|
+
// write executor-managed tables. Only the framework's own event-apply paths call it.
|
|
75
|
+
export function withExecutorManagedWrites(runner) {
|
|
76
|
+
if (capableRunnerInners.has(runner))
|
|
77
|
+
return runner;
|
|
78
|
+
const cached = capableRunnerByInner.get(runner);
|
|
79
|
+
if (cached)
|
|
80
|
+
return cached;
|
|
81
|
+
const capable = boundRunner(runner, runnerPersonalDataGates.get(runner), projectionRegistryOf(runner), true);
|
|
82
|
+
capableRunnerByInner.set(runner, capable);
|
|
83
|
+
return capable;
|
|
84
|
+
}
|
|
85
|
+
// unsafeRaw must not hand out the executor-only write capability, even from a capable runner.
|
|
86
|
+
export function withoutExecutorManagedWrites(runner) {
|
|
87
|
+
return capableRunnerInners.get(runner) ?? runner;
|
|
88
|
+
}
|
|
64
89
|
// The executor passes its entity so the check does not depend on the table-name lookup.
|
|
65
90
|
export function assertPersonalDataWrite(db, tableName, keys, entity) {
|
|
66
91
|
personalDataGates.get(db)?.(tableName, keys, entity);
|
|
@@ -133,8 +158,8 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
|
|
|
133
158
|
}
|
|
134
159
|
// Engine-forwarded steps carry their own declared reason; a caller-supplied one never reaches the audit trail.
|
|
135
160
|
report("unsafe-raw", forwardedDeclaredStep ? reason : (gate?.grant?.reason ?? reason));
|
|
136
|
-
const runner = tenantDbRunner(db);
|
|
137
|
-
return boundRunner(runner, personalDataGates.get(db), projectionRegistryOf(db));
|
|
161
|
+
const runner = withoutExecutorManagedWrites(tenantDbRunner(db));
|
|
162
|
+
return boundRunner(runner, personalDataGates.get(db), projectionRegistryOf(db), false);
|
|
138
163
|
}
|
|
139
164
|
const uncheckedSystemDb = {
|
|
140
165
|
[SYSTEM_SCOPE_CHECK_BRAND]: true,
|
|
@@ -326,6 +351,10 @@ export async function runInOwnTransaction(tenantDb, fn) {
|
|
|
326
351
|
export function createTenantDb(options) {
|
|
327
352
|
return buildTenantDb(options);
|
|
328
353
|
}
|
|
354
|
+
// Tenant-destroy hooks purge their own tenant's event-fed rows; the pinned tenant is the only reach and unsafeRaw stays without the write capability.
|
|
355
|
+
export function createTenantDataDestroyDb(options) {
|
|
356
|
+
return buildTenantDb({ ...options, db: withExecutorManagedWrites(options.db), mode: "tenant" });
|
|
357
|
+
}
|
|
329
358
|
// Untyped JS callers or an `as` cast can still hand in undefined; a scoped handle
|
|
330
359
|
// without a tenant would bind `tenant_id = NULL` instead of failing loudly.
|
|
331
360
|
function buildTenantDb(options) {
|
|
@@ -423,14 +452,6 @@ function buildTenantDb(options) {
|
|
|
423
452
|
"writes through db.global().",
|
|
424
453
|
});
|
|
425
454
|
}
|
|
426
|
-
// The ExecutorOnly brand is type-only; a type-erased table must still not reach the read model without an event.
|
|
427
|
-
function executorManagedWriteDenied(table) {
|
|
428
|
-
if (asEntityTableMeta(table)?.source === "unmanaged")
|
|
429
|
-
return undefined;
|
|
430
|
-
return new AccessDeniedError({
|
|
431
|
-
message: `db.global(${tableNameOf(table)}): writes on executor-managed entity tables must go through the entity executor`,
|
|
432
|
-
});
|
|
433
|
-
}
|
|
434
455
|
function writesDenied(operation) {
|
|
435
456
|
const reason = grants?.writesDeniedReason;
|
|
436
457
|
return reason === undefined ? undefined : tenantDbWritesDeniedError(operation, reason);
|
|
@@ -484,7 +505,7 @@ function buildTenantDb(options) {
|
|
|
484
505
|
insertOne(values) {
|
|
485
506
|
const denied = writesDenied("ctx.db.global().insertOne") ??
|
|
486
507
|
missingEscapeHatch(table) ??
|
|
487
|
-
executorManagedWriteDenied(table) ??
|
|
508
|
+
executorManagedWriteDenied(db, table, "db.global().insertOne") ??
|
|
488
509
|
foreignTenantOnGlobalWrite(table, values["tenantId"]) ??
|
|
489
510
|
personalDataDenied(table, Object.keys(values));
|
|
490
511
|
if (denied)
|
|
@@ -495,7 +516,7 @@ function buildTenantDb(options) {
|
|
|
495
516
|
updateMany(set, where) {
|
|
496
517
|
const denied = writesDenied("ctx.db.global().updateMany") ??
|
|
497
518
|
missingEscapeHatch(table) ??
|
|
498
|
-
executorManagedWriteDenied(table) ??
|
|
519
|
+
executorManagedWriteDenied(db, table, "db.global().updateMany") ??
|
|
499
520
|
foreignTenantOnGlobalWrite(table, set["tenantId"]) ??
|
|
500
521
|
personalDataDenied(table, Object.keys(set));
|
|
501
522
|
if (denied)
|
|
@@ -509,7 +530,7 @@ function buildTenantDb(options) {
|
|
|
509
530
|
deleteMany(where) {
|
|
510
531
|
const denied = writesDenied("ctx.db.global().deleteMany") ??
|
|
511
532
|
missingEscapeHatch(table) ??
|
|
512
|
-
executorManagedWriteDenied(table);
|
|
533
|
+
executorManagedWriteDenied(db, table, "db.global().deleteMany");
|
|
513
534
|
if (denied)
|
|
514
535
|
return Promise.reject(denied);
|
|
515
536
|
if (!where || Object.keys(where).length === 0) {
|
|
@@ -536,7 +557,7 @@ function buildTenantDb(options) {
|
|
|
536
557
|
}
|
|
537
558
|
// Engine-forwarded steps carry their own declared reason; otherwise the grant's reason is audited.
|
|
538
559
|
report("unsafe-raw", declaredStepReason ?? grants?.unsafeRaw?.reason ?? "");
|
|
539
|
-
return boundRunner(db, personalDataGate, projectionRegistry);
|
|
560
|
+
return boundRunner(withoutExecutorManagedWrites(db), personalDataGate, projectionRegistry, false);
|
|
540
561
|
}
|
|
541
562
|
const tenantDb = {
|
|
542
563
|
tenantId,
|
|
@@ -555,6 +576,12 @@ function buildTenantDb(options) {
|
|
|
555
576
|
const filter = readWhere(table, where);
|
|
556
577
|
return withDbSpan("select", table, async () => bunCountWhere(db, table, filter));
|
|
557
578
|
},
|
|
579
|
+
advisoryXactLock(namespace, key) {
|
|
580
|
+
return acquireNamespacedAdvisoryLock(db, namespace, `${tenantId}:${key}`);
|
|
581
|
+
},
|
|
582
|
+
databaseNow() {
|
|
583
|
+
return queryDatabaseNow(db);
|
|
584
|
+
},
|
|
558
585
|
aggregate(table, spec, where) {
|
|
559
586
|
const filter = readWhere(table, where);
|
|
560
587
|
return withDbSpan("select", table, async () => bunAggregateWhere(db, table, spec, filter));
|
|
@@ -574,6 +601,9 @@ function buildTenantDb(options) {
|
|
|
574
601
|
const personalDenied = personalDataDenied(table, Object.keys(values));
|
|
575
602
|
if (personalDenied)
|
|
576
603
|
return Promise.reject(personalDenied);
|
|
604
|
+
const managedDenied = executorManagedWriteDenied(db, table, "ctx.db.insertOne");
|
|
605
|
+
if (managedDenied)
|
|
606
|
+
return Promise.reject(managedDenied);
|
|
577
607
|
const data = insertValues(table, values);
|
|
578
608
|
return withDbSpan("insert", table, async () => bunInsertOne(db, table, data));
|
|
579
609
|
},
|
|
@@ -590,6 +620,9 @@ function buildTenantDb(options) {
|
|
|
590
620
|
const personalDenied = personalDataDenied(table, Object.keys(set));
|
|
591
621
|
if (personalDenied)
|
|
592
622
|
return Promise.reject(personalDenied);
|
|
623
|
+
const managedDenied = executorManagedWriteDenied(db, table, "ctx.db.updateMany");
|
|
624
|
+
if (managedDenied)
|
|
625
|
+
return Promise.reject(managedDenied);
|
|
593
626
|
const filter = writeWhere(table, where);
|
|
594
627
|
return withDbSpan("update", table, async () => bunUpdateMany(db, table, set, filter));
|
|
595
628
|
},
|
|
@@ -603,6 +636,9 @@ function buildTenantDb(options) {
|
|
|
603
636
|
const globalDenied = tenantWriteOnGlobalTable(table, "deleteMany");
|
|
604
637
|
if (globalDenied)
|
|
605
638
|
return Promise.reject(globalDenied);
|
|
639
|
+
const managedDenied = executorManagedWriteDenied(db, table, "ctx.db.deleteMany");
|
|
640
|
+
if (managedDenied)
|
|
641
|
+
return Promise.reject(managedDenied);
|
|
606
642
|
const filter = writeWhere(table, where);
|
|
607
643
|
return withDbSpan("delete", table, async () => bunDeleteMany(db, table, filter));
|
|
608
644
|
},
|
|
@@ -642,6 +642,9 @@ function validateEmbeddedDerivedCells(fieldName, entityName, field) {
|
|
|
642
642
|
if (!(derivedName in field.schema)) {
|
|
643
643
|
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" that is not a sub-field in its schema. Add the sub-field to the schema or remove the derived cell.`);
|
|
644
644
|
}
|
|
645
|
+
if (derivedDef.op === "percent" && derivedDef.from.length !== 2) {
|
|
646
|
+
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" with op "percent", which needs exactly two sources (base, rate).`);
|
|
647
|
+
}
|
|
645
648
|
for (const sourceName of derivedDef.from) {
|
|
646
649
|
if (!(sourceName in field.schema)) {
|
|
647
650
|
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" reading unknown sub-field "${sourceName}". Read only sub-fields that exist in the schema.`);
|
|
@@ -656,10 +659,15 @@ function validateEmbeddedTotalsColumns(fieldName, entityName, field) {
|
|
|
656
659
|
// skip: no totals columns declared — nothing to validate
|
|
657
660
|
if (field.totals === undefined)
|
|
658
661
|
return;
|
|
659
|
-
for (const
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
662
|
+
for (const total of field.totals) {
|
|
663
|
+
if (typeof total !== "string" && total.from.length === 0) {
|
|
664
|
+
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a totals entry "${total.label}" with an empty "from". List at least one number, money or decimal sub-field.`);
|
|
665
|
+
}
|
|
666
|
+
for (const totalName of typeof total === "string" ? [total] : total.from) {
|
|
667
|
+
const totalSubField = field.schema[totalName];
|
|
668
|
+
if (!totalSubField || !NUMERIC_EMBEDDED_SUB_TYPES.has(totalSubField.type)) {
|
|
669
|
+
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" lists "${totalName}" in totals, but it is not a number/money/decimal sub-field. List only number, money or decimal sub-fields in totals.`);
|
|
670
|
+
}
|
|
663
671
|
}
|
|
664
672
|
}
|
|
665
673
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { FeatureDefinition } from "../types/feature.ts";
|
|
2
|
+
import type { ActionFormScreenDefinition } from "../types/screen.ts";
|
|
3
|
+
export declare function validateFormDerivations(featureName: string, screenId: string, screen: ActionFormScreenDefinition): void;
|
|
4
|
+
export declare function validateFormDerivationsEnforced(featureName: string, screenId: string, screen: ActionFormScreenDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { orderFormDerivations } from "../form-derivations.js";
|
|
2
|
+
import { parseQn } from "../qualified-name.js";
|
|
3
|
+
import { formDerivationsOfSchema } from "../with-form-derivations.js";
|
|
4
|
+
const NUMERIC_FORM_FIELD_TYPES = ["money", "number", "decimal"];
|
|
5
|
+
const NUMERIC_SUB_FIELD_TYPES = ["money", "number", "decimal"];
|
|
6
|
+
function validateFromList({ prefix, screen, target }, derivation) {
|
|
7
|
+
if (derivation.op === "percent" && derivation.from.length !== 2) {
|
|
8
|
+
throw new Error(`${prefix}.${target}: "percent" needs exactly two sources (base, rate).`);
|
|
9
|
+
}
|
|
10
|
+
for (const source of derivation.from) {
|
|
11
|
+
if (!(source in screen.fields)) {
|
|
12
|
+
throw new Error(`${prefix}.${target}: unknown source field "${source}". Name a field of this screen.`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function validateListSum(check, derivation) {
|
|
17
|
+
const { prefix, screen, target } = check;
|
|
18
|
+
const listField = screen.fields[derivation.list];
|
|
19
|
+
if (listField === undefined) {
|
|
20
|
+
throw new Error(`${prefix}.${target}: unknown list field "${derivation.list}".`);
|
|
21
|
+
}
|
|
22
|
+
if (listField.type !== "embedded" || listField.multiple !== true) {
|
|
23
|
+
throw new Error(`${prefix}.${target}: "${derivation.list}" must be an embedded list field (multiple: true).`);
|
|
24
|
+
}
|
|
25
|
+
const cellType = listField.schema[derivation.cell]?.type;
|
|
26
|
+
if (cellType === undefined || !NUMERIC_SUB_FIELD_TYPES.includes(cellType)) {
|
|
27
|
+
throw new Error(`${prefix}.${target}: "${derivation.cell}" must be a money, number or decimal sub-field of "${derivation.list}".`);
|
|
28
|
+
}
|
|
29
|
+
const { whenEmpty } = derivation;
|
|
30
|
+
// skip: whenEmpty is optional
|
|
31
|
+
if (whenEmpty === undefined)
|
|
32
|
+
return;
|
|
33
|
+
// Type forbids a nested listSum; JS/cast callers can still pass one.
|
|
34
|
+
if (!("from" in whenEmpty)) {
|
|
35
|
+
throw new Error(`${prefix}.${target}: whenEmpty must not be a listSum.`);
|
|
36
|
+
}
|
|
37
|
+
validateFromList(check, whenEmpty);
|
|
38
|
+
}
|
|
39
|
+
// actionForm `derive`: every name must resolve, targets must be numeric, a
|
|
40
|
+
// listSum must point at a numeric cell of an embedded list, no cycles.
|
|
41
|
+
export function validateFormDerivations(featureName, screenId, screen) {
|
|
42
|
+
const derive = screen.derive;
|
|
43
|
+
// skip: the screen derives nothing
|
|
44
|
+
if (derive === undefined)
|
|
45
|
+
return;
|
|
46
|
+
const prefix = `[Feature ${featureName}] Screen "${screenId}" (actionForm) derive`;
|
|
47
|
+
for (const [target, derivation] of Object.entries(derive)) {
|
|
48
|
+
const targetField = screen.fields[target];
|
|
49
|
+
if (targetField === undefined) {
|
|
50
|
+
throw new Error(`${prefix}: key "${target}" is not a field of this screen.`);
|
|
51
|
+
}
|
|
52
|
+
if (!NUMERIC_FORM_FIELD_TYPES.includes(targetField.type)) {
|
|
53
|
+
throw new Error(`${prefix}.${target}: a derived field must be money, number or decimal, not "${targetField.type}".`);
|
|
54
|
+
}
|
|
55
|
+
const check = { prefix, screen, target };
|
|
56
|
+
if (derivation.op === "listSum")
|
|
57
|
+
validateListSum(check, derivation);
|
|
58
|
+
else
|
|
59
|
+
validateFromList(check, derivation);
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
orderFormDerivations(derive);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
throw new Error(`${prefix}: ${error instanceof Error ? error.message : String(error)}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Secure by default: a derive the handler schema doesn't recompute would let a
|
|
69
|
+
// client send its own totals, so the handler must wrap with this very screen.
|
|
70
|
+
export function validateFormDerivationsEnforced(featureName, screenId, screen, featureMap) {
|
|
71
|
+
// skip: the screen derives nothing
|
|
72
|
+
if (screen.derive === undefined)
|
|
73
|
+
return;
|
|
74
|
+
const { scope, name } = parseQn(screen.handler);
|
|
75
|
+
const schema = featureMap.get(scope)?.writeHandlers[name]?.schema;
|
|
76
|
+
// skip: wrapped with this screen
|
|
77
|
+
if (formDerivationsOfSchema(schema) === screen.derive)
|
|
78
|
+
return;
|
|
79
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (actionForm) declares derive, but handler ` +
|
|
80
|
+
`"${screen.handler}" does not recompute it on the server. Wrap the handler schema: ` +
|
|
81
|
+
`withFormDerivations(schema, screen) with this screen object.`);
|
|
82
|
+
}
|
|
@@ -86,6 +86,14 @@ function checkEditFormScreenQueryRefs(queryHandlers, featureName, screenId, scre
|
|
|
86
86
|
checkEditLayoutQueryRefs(queryHandlers, featureName, screenId, screen.type, screen.layout);
|
|
87
87
|
if (screen.type === "actionForm" && screen.load !== undefined) {
|
|
88
88
|
checkQueryRef(queryHandlers, screen.load.query, () => `[Feature ${featureName}] Screen "${screenId}" (actionForm) load`);
|
|
89
|
+
// A singleton load sends no id, so a query that requires input would fail on every open.
|
|
90
|
+
const loadHandler = queryHandlers.get(screen.load.query);
|
|
91
|
+
if (screen.load.singleton === true &&
|
|
92
|
+
loadHandler !== undefined &&
|
|
93
|
+
!loadHandler.schema.safeParse({}).success) {
|
|
94
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (actionForm) load.singleton query "${screen.load.query}" ` +
|
|
95
|
+
"requires input; a singleton load runs with an empty payload. Make the query's input fields optional.");
|
|
96
|
+
}
|
|
89
97
|
}
|
|
90
98
|
if (screen.type !== "entityEdit") {
|
|
91
99
|
checkFormFieldQueryRefs(queryHandlers, featureName, screenId, screen.type, screen.fields);
|