@cosmicdrift/kumiko-framework 0.378.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/embedded.md +23 -37
- 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 +4 -4
- 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 +1 -1
- 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 +50 -18
- package/dist/engine/boot-validator/entity-handler.js +9 -4
- package/dist/engine/boot-validator/query-refs.js +8 -0
- package/dist/engine/boot-validator/screens.js +34 -9
- package/dist/engine/feature-ui-extensions.js +4 -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/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/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/ui-types/index.d.ts +2 -2
- package/dist/ui-types/index.js +2 -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 +110 -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
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,8 +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";
|
|
8
9
|
import { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
|
|
10
|
+
import { queryDatabaseNow } from "./queries/database-now.js";
|
|
9
11
|
import { hasFieldProperty, tableNameOf } from "./table-introspection.js";
|
|
10
12
|
import { bindTenantDbRunner, denyTenantDbWrites, tenantDbRunner, tenantDbWritesDeniedError, } from "./tenant-db-runner.js";
|
|
11
13
|
export { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
@@ -17,8 +19,8 @@ const personalDataGates = new WeakMap();
|
|
|
17
19
|
// Lets createTenantDb(ctx.db.unsafeRaw(), ...) inherit the gate and the projection registry.
|
|
18
20
|
// Keyed by a per-grant proxy, never the shared pool/tx: tagging that would leak onto every sibling TenantDb.
|
|
19
21
|
const runnerPersonalDataGates = new WeakMap();
|
|
20
|
-
function boundRunner(runner, gate, projectionRegistry) {
|
|
21
|
-
if (!gate && !projectionRegistry)
|
|
22
|
+
function boundRunner(runner, gate, projectionRegistry, executorManagedWrites) {
|
|
23
|
+
if (!gate && !projectionRegistry && !executorManagedWrites)
|
|
22
24
|
return runner;
|
|
23
25
|
const proxy = new Proxy(runner, {
|
|
24
26
|
// Tagged-template calls need the real driver object as `this`.
|
|
@@ -33,7 +35,7 @@ function boundRunner(runner, gate, projectionRegistry) {
|
|
|
33
35
|
// Bun.SQL and postgres.js hand back a raw connection; re-wrap it so the gate survives.
|
|
34
36
|
return async (...args) => {
|
|
35
37
|
const reserved = await Reflect.apply(value, target, args);
|
|
36
|
-
return boundRunner(reserved, gate, projectionRegistry);
|
|
38
|
+
return boundRunner(reserved, gate, projectionRegistry, executorManagedWrites);
|
|
37
39
|
};
|
|
38
40
|
}
|
|
39
41
|
if (prop === "begin" ||
|
|
@@ -48,7 +50,7 @@ function boundRunner(runner, gate, projectionRegistry) {
|
|
|
48
50
|
}
|
|
49
51
|
const gatedArgs = [
|
|
50
52
|
...args.slice(0, -1),
|
|
51
|
-
(tx) => callback(boundRunner(tx, gate, projectionRegistry)),
|
|
53
|
+
(tx) => callback(boundRunner(tx, gate, projectionRegistry, executorManagedWrites)),
|
|
52
54
|
];
|
|
53
55
|
return Reflect.apply(value, target, gatedArgs);
|
|
54
56
|
};
|
|
@@ -60,8 +62,30 @@ function boundRunner(runner, gate, projectionRegistry) {
|
|
|
60
62
|
runnerPersonalDataGates.set(proxy, gate);
|
|
61
63
|
if (projectionRegistry)
|
|
62
64
|
bindProjectionRegistry(proxy, projectionRegistry);
|
|
65
|
+
if (executorManagedWrites) {
|
|
66
|
+
registerExecutorManagedWriteRunner(proxy);
|
|
67
|
+
capableRunnerInners.set(proxy, runner);
|
|
68
|
+
}
|
|
63
69
|
return proxy;
|
|
64
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
|
+
}
|
|
65
89
|
// The executor passes its entity so the check does not depend on the table-name lookup.
|
|
66
90
|
export function assertPersonalDataWrite(db, tableName, keys, entity) {
|
|
67
91
|
personalDataGates.get(db)?.(tableName, keys, entity);
|
|
@@ -134,8 +158,8 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
|
|
|
134
158
|
}
|
|
135
159
|
// Engine-forwarded steps carry their own declared reason; a caller-supplied one never reaches the audit trail.
|
|
136
160
|
report("unsafe-raw", forwardedDeclaredStep ? reason : (gate?.grant?.reason ?? reason));
|
|
137
|
-
const runner = tenantDbRunner(db);
|
|
138
|
-
return boundRunner(runner, personalDataGates.get(db), projectionRegistryOf(db));
|
|
161
|
+
const runner = withoutExecutorManagedWrites(tenantDbRunner(db));
|
|
162
|
+
return boundRunner(runner, personalDataGates.get(db), projectionRegistryOf(db), false);
|
|
139
163
|
}
|
|
140
164
|
const uncheckedSystemDb = {
|
|
141
165
|
[SYSTEM_SCOPE_CHECK_BRAND]: true,
|
|
@@ -327,6 +351,10 @@ export async function runInOwnTransaction(tenantDb, fn) {
|
|
|
327
351
|
export function createTenantDb(options) {
|
|
328
352
|
return buildTenantDb(options);
|
|
329
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
|
+
}
|
|
330
358
|
// Untyped JS callers or an `as` cast can still hand in undefined; a scoped handle
|
|
331
359
|
// without a tenant would bind `tenant_id = NULL` instead of failing loudly.
|
|
332
360
|
function buildTenantDb(options) {
|
|
@@ -424,14 +452,6 @@ function buildTenantDb(options) {
|
|
|
424
452
|
"writes through db.global().",
|
|
425
453
|
});
|
|
426
454
|
}
|
|
427
|
-
// The ExecutorOnly brand is type-only; a type-erased table must still not reach the read model without an event.
|
|
428
|
-
function executorManagedWriteDenied(table) {
|
|
429
|
-
if (asEntityTableMeta(table)?.source === "unmanaged")
|
|
430
|
-
return undefined;
|
|
431
|
-
return new AccessDeniedError({
|
|
432
|
-
message: `db.global(${tableNameOf(table)}): writes on executor-managed entity tables must go through the entity executor`,
|
|
433
|
-
});
|
|
434
|
-
}
|
|
435
455
|
function writesDenied(operation) {
|
|
436
456
|
const reason = grants?.writesDeniedReason;
|
|
437
457
|
return reason === undefined ? undefined : tenantDbWritesDeniedError(operation, reason);
|
|
@@ -485,7 +505,7 @@ function buildTenantDb(options) {
|
|
|
485
505
|
insertOne(values) {
|
|
486
506
|
const denied = writesDenied("ctx.db.global().insertOne") ??
|
|
487
507
|
missingEscapeHatch(table) ??
|
|
488
|
-
executorManagedWriteDenied(table) ??
|
|
508
|
+
executorManagedWriteDenied(db, table, "db.global().insertOne") ??
|
|
489
509
|
foreignTenantOnGlobalWrite(table, values["tenantId"]) ??
|
|
490
510
|
personalDataDenied(table, Object.keys(values));
|
|
491
511
|
if (denied)
|
|
@@ -496,7 +516,7 @@ function buildTenantDb(options) {
|
|
|
496
516
|
updateMany(set, where) {
|
|
497
517
|
const denied = writesDenied("ctx.db.global().updateMany") ??
|
|
498
518
|
missingEscapeHatch(table) ??
|
|
499
|
-
executorManagedWriteDenied(table) ??
|
|
519
|
+
executorManagedWriteDenied(db, table, "db.global().updateMany") ??
|
|
500
520
|
foreignTenantOnGlobalWrite(table, set["tenantId"]) ??
|
|
501
521
|
personalDataDenied(table, Object.keys(set));
|
|
502
522
|
if (denied)
|
|
@@ -510,7 +530,7 @@ function buildTenantDb(options) {
|
|
|
510
530
|
deleteMany(where) {
|
|
511
531
|
const denied = writesDenied("ctx.db.global().deleteMany") ??
|
|
512
532
|
missingEscapeHatch(table) ??
|
|
513
|
-
executorManagedWriteDenied(table);
|
|
533
|
+
executorManagedWriteDenied(db, table, "db.global().deleteMany");
|
|
514
534
|
if (denied)
|
|
515
535
|
return Promise.reject(denied);
|
|
516
536
|
if (!where || Object.keys(where).length === 0) {
|
|
@@ -537,7 +557,7 @@ function buildTenantDb(options) {
|
|
|
537
557
|
}
|
|
538
558
|
// Engine-forwarded steps carry their own declared reason; otherwise the grant's reason is audited.
|
|
539
559
|
report("unsafe-raw", declaredStepReason ?? grants?.unsafeRaw?.reason ?? "");
|
|
540
|
-
return boundRunner(db, personalDataGate, projectionRegistry);
|
|
560
|
+
return boundRunner(withoutExecutorManagedWrites(db), personalDataGate, projectionRegistry, false);
|
|
541
561
|
}
|
|
542
562
|
const tenantDb = {
|
|
543
563
|
tenantId,
|
|
@@ -559,6 +579,9 @@ function buildTenantDb(options) {
|
|
|
559
579
|
advisoryXactLock(namespace, key) {
|
|
560
580
|
return acquireNamespacedAdvisoryLock(db, namespace, `${tenantId}:${key}`);
|
|
561
581
|
},
|
|
582
|
+
databaseNow() {
|
|
583
|
+
return queryDatabaseNow(db);
|
|
584
|
+
},
|
|
562
585
|
aggregate(table, spec, where) {
|
|
563
586
|
const filter = readWhere(table, where);
|
|
564
587
|
return withDbSpan("select", table, async () => bunAggregateWhere(db, table, spec, filter));
|
|
@@ -578,6 +601,9 @@ function buildTenantDb(options) {
|
|
|
578
601
|
const personalDenied = personalDataDenied(table, Object.keys(values));
|
|
579
602
|
if (personalDenied)
|
|
580
603
|
return Promise.reject(personalDenied);
|
|
604
|
+
const managedDenied = executorManagedWriteDenied(db, table, "ctx.db.insertOne");
|
|
605
|
+
if (managedDenied)
|
|
606
|
+
return Promise.reject(managedDenied);
|
|
581
607
|
const data = insertValues(table, values);
|
|
582
608
|
return withDbSpan("insert", table, async () => bunInsertOne(db, table, data));
|
|
583
609
|
},
|
|
@@ -594,6 +620,9 @@ function buildTenantDb(options) {
|
|
|
594
620
|
const personalDenied = personalDataDenied(table, Object.keys(set));
|
|
595
621
|
if (personalDenied)
|
|
596
622
|
return Promise.reject(personalDenied);
|
|
623
|
+
const managedDenied = executorManagedWriteDenied(db, table, "ctx.db.updateMany");
|
|
624
|
+
if (managedDenied)
|
|
625
|
+
return Promise.reject(managedDenied);
|
|
597
626
|
const filter = writeWhere(table, where);
|
|
598
627
|
return withDbSpan("update", table, async () => bunUpdateMany(db, table, set, filter));
|
|
599
628
|
},
|
|
@@ -607,6 +636,9 @@ function buildTenantDb(options) {
|
|
|
607
636
|
const globalDenied = tenantWriteOnGlobalTable(table, "deleteMany");
|
|
608
637
|
if (globalDenied)
|
|
609
638
|
return Promise.reject(globalDenied);
|
|
639
|
+
const managedDenied = executorManagedWriteDenied(db, table, "ctx.db.deleteMany");
|
|
640
|
+
if (managedDenied)
|
|
641
|
+
return Promise.reject(managedDenied);
|
|
610
642
|
const filter = writeWhere(table, where);
|
|
611
643
|
return withDbSpan("delete", table, async () => bunDeleteMany(db, table, filter));
|
|
612
644
|
},
|
|
@@ -659,10 +659,15 @@ function validateEmbeddedTotalsColumns(fieldName, entityName, field) {
|
|
|
659
659
|
// skip: no totals columns declared — nothing to validate
|
|
660
660
|
if (field.totals === undefined)
|
|
661
661
|
return;
|
|
662
|
-
for (const
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
+
}
|
|
666
671
|
}
|
|
667
672
|
}
|
|
668
673
|
}
|
|
@@ -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);
|
|
@@ -183,21 +183,25 @@ function validateRowActionNavigateParams(featureName, screenId, screenType, scre
|
|
|
183
183
|
return;
|
|
184
184
|
if (target.screen.type === "dashboard") {
|
|
185
185
|
const targetDescriptor = action.screen !== undefined ? `"${action.screen}"` : `entity "${action.entity}"`;
|
|
186
|
-
const filter = target.screen
|
|
187
|
-
|
|
186
|
+
const { filter, record, timeRange } = target.screen;
|
|
187
|
+
const readIds = [filter?.id, record?.param, timeRange?.id].filter((id) => id !== undefined);
|
|
188
|
+
if (readIds.length === 0) {
|
|
188
189
|
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" sets ` +
|
|
189
190
|
`params on navigate-target ${targetDescriptor} (dashboard) — target dashboard declares no ` +
|
|
190
|
-
`filter — params would be a no-op. Remove the params extractor, or add a \`filter\` to the ` +
|
|
191
|
+
`filter, time range or record binding — params would be a no-op. Remove the params extractor, or add a \`filter\` (or a \`record\` binding) to the ` +
|
|
191
192
|
`target dashboard so it has somewhere to read the value from.`);
|
|
192
193
|
}
|
|
193
194
|
const extractedKeys = rowFieldExtractorKeys(action.params);
|
|
194
|
-
if (!extractedKeys.includes(
|
|
195
|
+
if (!readIds.some((id) => extractedKeys.includes(id))) {
|
|
196
|
+
const readDescriptor = filter !== undefined && readIds.length === 1
|
|
197
|
+
? `filter id is "${filter.id}"`
|
|
198
|
+
: `URL params are [${readIds.join(", ")}]`;
|
|
195
199
|
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" sets ` +
|
|
196
200
|
`params [${extractedKeys.join(", ")}] on navigate-target ${targetDescriptor} (dashboard) whose ` +
|
|
197
|
-
|
|
198
|
-
`unset. Fix the params extractor to produce
|
|
201
|
+
`${readDescriptor} — none of the extracted keys match, so the value would stay ` +
|
|
202
|
+
`unset. Fix the params extractor to produce one of these keys, or remove it.`);
|
|
199
203
|
}
|
|
200
|
-
// skip:
|
|
204
|
+
// skip: a read id is present and the extractor's keys cover it — valid dashboard deep-link.
|
|
201
205
|
return;
|
|
202
206
|
}
|
|
203
207
|
if (!readsNavigateParamsAsFormPrefill(target.screen, action.entityId, screenEntity)) {
|
|
@@ -950,6 +954,13 @@ function validateInlineFormScreen(feature, screenId, screen, kind, allWriteHandl
|
|
|
950
954
|
validateFormDerivations(feature.name, screenId, screen);
|
|
951
955
|
validateFormDerivationsEnforced(feature.name, screenId, screen, featureMap);
|
|
952
956
|
}
|
|
957
|
+
if (screen.type === "actionForm" &&
|
|
958
|
+
screen.load?.singleton === true &&
|
|
959
|
+
screen.load.idParam !== undefined) {
|
|
960
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (actionForm) sets both load.singleton: true ` +
|
|
961
|
+
`and load.idParam — singleton means the query picks the row from the caller's context, so ` +
|
|
962
|
+
`there is no record id to send under idParam. Remove idParam.`);
|
|
963
|
+
}
|
|
953
964
|
validateInlineFormNavTargets(feature, screenId, screen, kind, lookups);
|
|
954
965
|
}
|
|
955
966
|
// SecretMintConfirmStep (fw#2838): an optional proof-of-receipt form rendered
|
|
@@ -2079,7 +2090,7 @@ function validateDashboardScreen(featureName, screenId, screen, featureMap) {
|
|
|
2079
2090
|
validateDashboardScreenPanel(featureName, screenId, panel, featureMap);
|
|
2080
2091
|
}
|
|
2081
2092
|
else if (panel.kind === "text") {
|
|
2082
|
-
validateDashboardTextPanel(featureName, screenId, panel);
|
|
2093
|
+
validateDashboardTextPanel(featureName, screenId, panel, featureMap);
|
|
2083
2094
|
}
|
|
2084
2095
|
else {
|
|
2085
2096
|
validateDashboardQueryPanel(featureName, screenId, panel, featureMap);
|
|
@@ -2263,7 +2274,7 @@ export function validateDashboardScreenPanel(featureName, screenId, panel, featu
|
|
|
2263
2274
|
throw new Error(`${context} fallback "${panel.fallback}" has its own visibleWhen; a fallback must always render.`);
|
|
2264
2275
|
}
|
|
2265
2276
|
}
|
|
2266
|
-
function validateDashboardTextPanel(featureName, screenId, panel) {
|
|
2277
|
+
function validateDashboardTextPanel(featureName, screenId, panel, featureMap) {
|
|
2267
2278
|
const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) text-panel "${panel.id}"`;
|
|
2268
2279
|
if (panel.blocks.length === 0) {
|
|
2269
2280
|
throw new Error(`${context} has an empty blocks list. Add at least one block.`);
|
|
@@ -2274,6 +2285,20 @@ function validateDashboardTextPanel(featureName, screenId, panel) {
|
|
|
2274
2285
|
if (block.kind === "list" && block.items.length === 0) {
|
|
2275
2286
|
throw new Error(`${context} has a list block without items. Add items or remove the block.`);
|
|
2276
2287
|
}
|
|
2288
|
+
if (block.kind === "link")
|
|
2289
|
+
validateDashboardTextLinkBlock(context, block, featureName, featureMap);
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
function validateDashboardTextLinkBlock(context, block, featureName, featureMap) {
|
|
2293
|
+
const targetQn = resolveScreenTargetQn(featureName, block.screen);
|
|
2294
|
+
const [targetFeatureName = "", , targetShortId = ""] = targetQn.split(":");
|
|
2295
|
+
if (featureMap.get(targetFeatureName)?.screens[targetShortId] === undefined) {
|
|
2296
|
+
throw new Error(`${context} link block screen "${block.screen}" does not resolve to a registered screen ` +
|
|
2297
|
+
`(checked "${targetQn}"). Use a same-feature short id or a cross-feature QN "<feature>:screen:<id>".`);
|
|
2298
|
+
}
|
|
2299
|
+
if (block.roles?.length === 0) {
|
|
2300
|
+
throw new Error(`${context} link block "${block.screen}" has an empty roles list, which would hide it from everyone. ` +
|
|
2301
|
+
`List the roles or omit roles.`);
|
|
2277
2302
|
}
|
|
2278
2303
|
}
|
|
2279
2304
|
function validateDashboardPanelVisibleWhen(context, visibleWhen) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { escapeHatchGrantsProblem } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
2
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
3
3
|
import { WEBSOCKET_MAX_CONNECTIONS_PER_USER_LIMIT, WEBSOCKET_MAX_PAYLOAD_BYTES, WEBSOCKET_ROUTE_PATH_PREFIX, } from "@cosmicdrift/kumiko-types/websocket-route";
|
|
4
|
+
import { markExecutorManagedTable } from "../db/executor-managed-tables.js";
|
|
4
5
|
import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch.js";
|
|
5
6
|
import { LifecycleHookTypes } from "./constants.js";
|
|
6
7
|
import { resolveName } from "./handler-helpers.js";
|
|
@@ -298,6 +299,9 @@ export function buildUiExtensionsMethods(state, name) {
|
|
|
298
299
|
throw new Error(`[Feature ${name}] Projection "${definition.name}" already registered. ` +
|
|
299
300
|
`Projection names must be unique per feature.`);
|
|
300
301
|
}
|
|
302
|
+
if (typeof definition.table === "object" && definition.table !== null) {
|
|
303
|
+
markExecutorManagedTable(definition.table);
|
|
304
|
+
}
|
|
301
305
|
state.projections[definition.name] = definition;
|
|
302
306
|
},
|
|
303
307
|
multiStreamProjection(definition) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { applyEntityEvent } from "../db/apply-entity-event.js";
|
|
2
2
|
import { assertBackingTableSuperset, deriveEntityTableMeta } from "../db/entity-table-meta.js";
|
|
3
|
+
import { markExecutorManagedTable } from "../db/executor-managed-tables.js";
|
|
3
4
|
import { asEntityTableMeta } from "../db/query.js";
|
|
4
5
|
import { buildEntityTable } from "../db/table-builder.js";
|
|
5
6
|
import { qualifyEntityName } from "./qualified-name.js";
|
|
@@ -20,6 +21,7 @@ export function buildImplicitProjection(featureName, entityName, entity, qualify
|
|
|
20
21
|
const drizzleTable = backingTable !== undefined
|
|
21
22
|
? resolveBackingTable(entityName, entity, backingTable)
|
|
22
23
|
: buildEntityTable(entityName, entity);
|
|
24
|
+
markExecutorManagedTable(drizzleTable);
|
|
23
25
|
// applyEntityEvent gibt ApplyResult zurück; SingleStreamApplyFn erwartet
|
|
24
26
|
// Promise<void>. Im rebuild-Pfad ist die Row irrelevant — wir discarden.
|
|
25
27
|
const handler = async (event, tx) => {
|
|
@@ -476,8 +476,11 @@ export function validateProjectionApplyKeys(state) {
|
|
|
476
476
|
// or `jobRun` (BullMQ-callback-driven lifecycle, no executor).
|
|
477
477
|
// A "Shape-Anchor"-entity is no longer needed for this case.
|
|
478
478
|
const isEventsOnlySource = !sources.every((src) => state.entityMap.has(src));
|
|
479
|
+
// An extension source may be a stream whose events the executor writes under a
|
|
480
|
+
// name that is no registered entity (document-extract); its auto-verbs count.
|
|
481
|
+
const extensionSources = projDef.extraSources ?? [];
|
|
479
482
|
for (const src of rebuildSources) {
|
|
480
|
-
if (state.entityMap.has(src)) {
|
|
483
|
+
if (state.entityMap.has(src) || extensionSources.includes(src)) {
|
|
481
484
|
for (const verb of ENTITY_LIFECYCLE_VERBS)
|
|
482
485
|
validEventTypes.add(`${src}.${verb}`);
|
|
483
486
|
}
|
|
@@ -236,12 +236,26 @@ export function fieldToZod(field, currencies, opts = {}) {
|
|
|
236
236
|
// whether the key may be omitted at all is decided by buildInsertSchema
|
|
237
237
|
// off the same flag. `minItems` overrides that default when set.
|
|
238
238
|
let list = z.array(row);
|
|
239
|
-
const min = field.minItems ?? (field.required === true ? 1 : undefined);
|
|
240
|
-
if (min !== undefined)
|
|
241
|
-
list = list.min(min);
|
|
242
239
|
if (field.maxItems !== undefined)
|
|
243
240
|
list = list.max(field.maxItems);
|
|
244
|
-
|
|
241
|
+
const min = field.minItems ?? (field.required === true ? 1 : undefined);
|
|
242
|
+
if (min === undefined)
|
|
243
|
+
return list;
|
|
244
|
+
// A custom issue rather than min(): other arrays (multiSelect) share zod's
|
|
245
|
+
// too_small and must not read "rows"; the bridge honours params.i18nKey.
|
|
246
|
+
return list.superRefine((rows, ctx) => {
|
|
247
|
+
// skip: the minimum is met, nothing to report
|
|
248
|
+
if (rows.length >= min)
|
|
249
|
+
return;
|
|
250
|
+
ctx.addIssue({
|
|
251
|
+
code: "custom",
|
|
252
|
+
input: rows,
|
|
253
|
+
params: {
|
|
254
|
+
i18nKey: `errors.validation.list_min_rows.${min === 1 ? "one" : "other"}`,
|
|
255
|
+
minimum: min,
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
});
|
|
245
259
|
}
|
|
246
260
|
case "jsonb": {
|
|
247
261
|
// Free-form jsonb — keys sind tenant-/runtime-defined. Validation
|