@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.
Files changed (94) hide show
  1. package/AGENTS.md +4 -1
  2. package/agents/examples/compose-stacks.md +1 -2
  3. package/agents/examples/currencies-global.md +2 -3
  4. package/agents/examples/embedded.md +23 -37
  5. package/agents/examples/event-sourcing.md +6 -5
  6. package/agents/examples/legal-pages.md +5 -5
  7. package/agents/examples/reference-data.md +26 -21
  8. package/agents/examples/relations.md +2 -1
  9. package/agents/features.md +4 -4
  10. package/dist/api/entity-read-access.d.ts +2 -0
  11. package/dist/api/entity-read-access.js +27 -0
  12. package/dist/api/index.d.ts +1 -0
  13. package/dist/api/index.js +1 -0
  14. package/dist/api/server.js +10 -3
  15. package/dist/api/sse-route.d.ts +5 -0
  16. package/dist/api/sse-route.js +59 -9
  17. package/dist/db/entity-table-from-registry.d.ts +2 -1
  18. package/dist/db/entity-table-meta.d.ts +0 -2
  19. package/dist/db/entity-table-meta.js +1 -3
  20. package/dist/db/event-store-executor-write.js +21 -16
  21. package/dist/db/executor-managed-tables.d.ts +6 -0
  22. package/dist/db/executor-managed-tables.js +40 -0
  23. package/dist/db/index.d.ts +4 -2
  24. package/dist/db/index.js +4 -2
  25. package/dist/db/money.d.ts +1 -1
  26. package/dist/db/queries/backfill-pii.js +22 -12
  27. package/dist/db/queries/database-now.d.ts +5 -0
  28. package/dist/db/queries/database-now.js +12 -0
  29. package/dist/db/queries/event-store-orphan-rows.d.ts +8 -0
  30. package/dist/db/queries/event-store-orphan-rows.js +32 -0
  31. package/dist/db/query.js +28 -0
  32. package/dist/db/reference-data.d.ts +5 -4
  33. package/dist/db/reference-data.js +23 -21
  34. package/dist/db/table-builder.js +2 -0
  35. package/dist/db/tenant-db-runner.d.ts +2 -0
  36. package/dist/db/tenant-db-runner.js +7 -0
  37. package/dist/db/tenant-db.d.ts +3 -0
  38. package/dist/db/tenant-db.js +50 -18
  39. package/dist/engine/boot-validator/entity-handler.js +9 -4
  40. package/dist/engine/boot-validator/query-refs.js +8 -0
  41. package/dist/engine/boot-validator/screens.js +34 -9
  42. package/dist/engine/feature-ui-extensions.js +4 -0
  43. package/dist/engine/registry-state.js +2 -0
  44. package/dist/engine/registry-validate.js +4 -1
  45. package/dist/engine/schema-builder.js +18 -4
  46. package/dist/engine/screen-helpers.d.ts +1 -0
  47. package/dist/engine/screen-helpers.js +3 -0
  48. package/dist/engine/soft-delete-cleanup.js +53 -24
  49. package/dist/engine/steps/unsafe-projection-delete.js +2 -1
  50. package/dist/entrypoint/index.d.ts +1 -3
  51. package/dist/errors/reasons.d.ts +1 -0
  52. package/dist/errors/reasons.js +4 -1
  53. package/dist/errors/serialize.d.ts +1 -0
  54. package/dist/errors/serialize.js +9 -5
  55. package/dist/errors/write-error-info.js +3 -2
  56. package/dist/errors/zod-bridge.js +7 -0
  57. package/dist/event-store/index.d.ts +1 -0
  58. package/dist/event-store/index.js +1 -0
  59. package/dist/files/action-form-upload.d.ts +16 -0
  60. package/dist/files/action-form-upload.js +45 -0
  61. package/dist/files/bind-pending-file-refs.d.ts +14 -0
  62. package/dist/files/bind-pending-file-refs.js +44 -4
  63. package/dist/files/create-file-ref.d.ts +3 -0
  64. package/dist/files/create-file-ref.js +41 -13
  65. package/dist/files/file-routes.js +29 -2
  66. package/dist/files/index.d.ts +2 -1
  67. package/dist/files/index.js +1 -0
  68. package/dist/files/purge-expired-file-ref-bytes.d.ts +3 -2
  69. package/dist/files/purge-expired-file-ref-bytes.js +11 -5
  70. package/dist/internal/testing.d.ts +2 -0
  71. package/dist/internal/testing.js +15 -1
  72. package/dist/migrations/pii-event-backfill.js +14 -7
  73. package/dist/observability/escape-hatch-report.d.ts +1 -1
  74. package/dist/observability/escape-hatch-report.js +14 -3
  75. package/dist/pipeline/cascade-handler.js +79 -10
  76. package/dist/pipeline/dispatch-shared.js +1 -9
  77. package/dist/pipeline/msp-rebuild.js +5 -3
  78. package/dist/pipeline/projection-rebuild.js +4 -2
  79. package/dist/pipeline/projections-runner.js +2 -1
  80. package/dist/rate-limit/index.d.ts +1 -1
  81. package/dist/rate-limit/index.js +1 -1
  82. package/dist/rate-limit/middleware.d.ts +4 -0
  83. package/dist/rate-limit/middleware.js +5 -2
  84. package/dist/ui-types/index.d.ts +2 -2
  85. package/dist/ui-types/index.js +2 -2
  86. package/dist/ui-types/list-row-meta.d.ts +3 -0
  87. package/dist/ui-types/list-row-meta.js +12 -1
  88. package/kumiko-es-tables.json +6 -0
  89. package/package.json +4 -3
  90. package/src/changes.json +110 -0
  91. package/src/scripts/codemod/README.md +20 -0
  92. package/src/scripts/codemod/codemod-support.ts +1 -1
  93. package/src/scripts/codemod/feature-layout.ts +416 -0
  94. 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[], tables: ReadonlyMap<string, Table>, db: DbConnection): Promise<{
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, insertOne, updateMany } from "../db/query.js";
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 { tableColumnsOf } from "./table-introspection.js";
4
- function hasColumn(table, field) {
5
- const cols = tableColumnsOf(table);
6
- return cols !== undefined && field in cols;
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, tables, db) {
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 table = tables.get(def.entityName);
18
- if (!table)
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 = (await fetchOne(db, table, { [upsertKey]: keyValue }));
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 updateMany(db, table, changes, { [upsertKey]: keyValue });
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
- // Only add framework columns if the table actually has them.
50
- // Drizzle used to filter extra fields silently; bunInsertOne doesn't.
51
- const values = { ...row };
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
  }
@@ -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
  }
@@ -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 };
@@ -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 totalName of field.totals) {
663
- const totalSubField = field.schema[totalName];
664
- if (!totalSubField || !NUMERIC_EMBEDDED_SUB_TYPES.has(totalSubField.type)) {
665
- 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.`);
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.filter;
187
- if (filter === undefined) {
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(filter.id)) {
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
- `filter id is "${filter.id}" — none of the extracted keys match, so the filter would stay ` +
198
- `unset. Fix the params extractor to produce a "${filter.id}" key, or remove it.`);
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: filter present and the extractor's keys cover it — valid dashboard deep-link.
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
- return list;
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