@cosmicdrift/kumiko-framework 0.378.0 → 0.380.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 (116) 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/apply-entity-event.d.ts +3 -2
  18. package/dist/db/apply-entity-event.js +1 -1
  19. package/dist/db/dialect.d.ts +2 -1
  20. package/dist/db/dialect.js +2 -2
  21. package/dist/db/entity-table-from-registry.js +0 -2
  22. package/dist/db/entity-table-meta.d.ts +5 -3
  23. package/dist/db/entity-table-meta.js +6 -15
  24. package/dist/db/event-store-executor-write.js +21 -16
  25. package/dist/db/executor-managed-tables.d.ts +8 -0
  26. package/dist/db/executor-managed-tables.js +48 -0
  27. package/dist/db/index.d.ts +6 -4
  28. package/dist/db/index.js +4 -2
  29. package/dist/db/money.d.ts +1 -1
  30. package/dist/db/queries/backfill-pii.js +22 -12
  31. package/dist/db/queries/database-now.d.ts +5 -0
  32. package/dist/db/queries/database-now.js +12 -0
  33. package/dist/db/queries/event-store-orphan-rows.d.ts +8 -0
  34. package/dist/db/queries/event-store-orphan-rows.js +32 -0
  35. package/dist/db/query.d.ts +22 -11
  36. package/dist/db/query.js +41 -36
  37. package/dist/db/reference-data.d.ts +5 -4
  38. package/dist/db/reference-data.js +23 -21
  39. package/dist/db/table-builder.d.ts +2 -3
  40. package/dist/db/table-builder.js +2 -0
  41. package/dist/db/table-introspection.d.ts +2 -0
  42. package/dist/db/table-introspection.js +4 -0
  43. package/dist/db/tenant-db-runner.d.ts +2 -0
  44. package/dist/db/tenant-db-runner.js +7 -0
  45. package/dist/db/tenant-db.d.ts +9 -2
  46. package/dist/db/tenant-db.js +82 -25
  47. package/dist/engine/boot-validator/entity-handler.js +9 -4
  48. package/dist/engine/boot-validator/query-refs.js +8 -0
  49. package/dist/engine/boot-validator/screens.js +34 -9
  50. package/dist/engine/extensions/tenant-data.d.ts +2 -2
  51. package/dist/engine/feature-ui-extensions.js +4 -0
  52. package/dist/engine/projection-helpers.d.ts +3 -3
  53. package/dist/engine/registry-state.js +2 -0
  54. package/dist/engine/registry-validate.js +4 -1
  55. package/dist/engine/schema-builder.js +18 -4
  56. package/dist/engine/screen-helpers.d.ts +1 -0
  57. package/dist/engine/screen-helpers.js +3 -0
  58. package/dist/engine/soft-delete-cleanup.js +53 -24
  59. package/dist/engine/steps/unsafe-projection-delete.js +4 -2
  60. package/dist/entrypoint/index.d.ts +1 -3
  61. package/dist/errors/reasons.d.ts +1 -0
  62. package/dist/errors/reasons.js +4 -1
  63. package/dist/errors/serialize.d.ts +1 -0
  64. package/dist/errors/serialize.js +9 -5
  65. package/dist/errors/write-error-info.js +3 -2
  66. package/dist/errors/zod-bridge.js +7 -0
  67. package/dist/es-ops/operations-schema.d.ts +1 -1
  68. package/dist/event-store/archive.d.ts +1 -1
  69. package/dist/event-store/events-schema.d.ts +1 -1
  70. package/dist/event-store/index.d.ts +1 -0
  71. package/dist/event-store/index.js +1 -0
  72. package/dist/event-store/rebuild-dead-letter.d.ts +1 -1
  73. package/dist/event-store/snapshot.d.ts +1 -1
  74. package/dist/event-store/upcaster-dead-letter.d.ts +1 -1
  75. package/dist/files/action-form-upload.d.ts +16 -0
  76. package/dist/files/action-form-upload.js +45 -0
  77. package/dist/files/bind-pending-file-refs.d.ts +14 -0
  78. package/dist/files/bind-pending-file-refs.js +44 -4
  79. package/dist/files/create-file-ref.d.ts +3 -0
  80. package/dist/files/create-file-ref.js +41 -13
  81. package/dist/files/file-routes.js +29 -2
  82. package/dist/files/index.d.ts +2 -1
  83. package/dist/files/index.js +1 -0
  84. package/dist/files/purge-expired-file-ref-bytes.d.ts +3 -2
  85. package/dist/files/purge-expired-file-ref-bytes.js +11 -5
  86. package/dist/files/storage-tracking.d.ts +1 -1
  87. package/dist/files/upload-ledger.d.ts +2 -2
  88. package/dist/internal/testing.d.ts +7 -0
  89. package/dist/internal/testing.js +14 -1
  90. package/dist/migrations/pending-rebuilds.d.ts +1 -1
  91. package/dist/migrations/pii-event-backfill.d.ts +1 -1
  92. package/dist/migrations/pii-event-backfill.js +14 -7
  93. package/dist/observability/escape-hatch-report.d.ts +1 -1
  94. package/dist/observability/escape-hatch-report.js +14 -3
  95. package/dist/pipeline/cascade-handler.js +79 -10
  96. package/dist/pipeline/dispatch-shared.js +1 -9
  97. package/dist/pipeline/event-consumer-state.d.ts +1 -1
  98. package/dist/pipeline/msp-rebuild.js +5 -3
  99. package/dist/pipeline/projection-rebuild.js +4 -2
  100. package/dist/pipeline/projection-state.d.ts +1 -1
  101. package/dist/pipeline/projections-runner.js +2 -1
  102. package/dist/rate-limit/index.d.ts +1 -1
  103. package/dist/rate-limit/index.js +1 -1
  104. package/dist/rate-limit/middleware.d.ts +4 -0
  105. package/dist/rate-limit/middleware.js +5 -2
  106. package/dist/ui-types/index.d.ts +2 -2
  107. package/dist/ui-types/index.js +2 -2
  108. package/dist/ui-types/list-row-meta.d.ts +3 -0
  109. package/dist/ui-types/list-row-meta.js +12 -1
  110. package/kumiko-es-tables.json +6 -0
  111. package/package.json +4 -3
  112. package/src/changes.json +117 -0
  113. package/src/scripts/codemod/README.md +20 -0
  114. package/src/scripts/codemod/codemod-support.ts +1 -1
  115. package/src/scripts/codemod/feature-layout.ts +416 -0
  116. package/src/scripts/codemod/package-merge.ts +0 -11
@@ -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 { assertWritableTable, executorManagedWriteDenied, hasExecutorManagedWriteCapability, 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,35 @@ 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 (hasExecutorManagedWriteCapability(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
+ if (!hasExecutorManagedWriteCapability(capable)) {
83
+ throw new InternalError({
84
+ message: "boundRunner did not register the executor write capability",
85
+ });
86
+ }
87
+ capableRunnerByInner.set(runner, capable);
88
+ return capable;
89
+ }
90
+ // unsafeRaw must not hand out the executor-only write capability, even from a capable runner.
91
+ export function withoutExecutorManagedWrites(runner) {
92
+ return capableRunnerInners.get(runner) ?? runner;
93
+ }
65
94
  // The executor passes its entity so the check does not depend on the table-name lookup.
66
95
  export function assertPersonalDataWrite(db, tableName, keys, entity) {
67
96
  personalDataGates.get(db)?.(tableName, keys, entity);
@@ -134,8 +163,8 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
134
163
  }
135
164
  // Engine-forwarded steps carry their own declared reason; a caller-supplied one never reaches the audit trail.
136
165
  report("unsafe-raw", forwardedDeclaredStep ? reason : (gate?.grant?.reason ?? reason));
137
- const runner = tenantDbRunner(db);
138
- return boundRunner(runner, personalDataGates.get(db), projectionRegistryOf(db));
166
+ const runner = withoutExecutorManagedWrites(tenantDbRunner(db));
167
+ return boundRunner(runner, personalDataGates.get(db), projectionRegistryOf(db), false);
139
168
  }
140
169
  const uncheckedSystemDb = {
141
170
  [SYSTEM_SCOPE_CHECK_BRAND]: true,
@@ -327,6 +356,10 @@ export async function runInOwnTransaction(tenantDb, fn) {
327
356
  export function createTenantDb(options) {
328
357
  return buildTenantDb(options);
329
358
  }
359
+ // 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.
360
+ export function createTenantDataDestroyDb(options) {
361
+ return createTenantDb({ ...options, db: withExecutorManagedWrites(options.db), mode: "tenant" });
362
+ }
330
363
  // Untyped JS callers or an `as` cast can still hand in undefined; a scoped handle
331
364
  // without a tenant would bind `tenant_id = NULL` instead of failing loudly.
332
365
  function buildTenantDb(options) {
@@ -424,14 +457,6 @@ function buildTenantDb(options) {
424
457
  "writes through db.global().",
425
458
  });
426
459
  }
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
460
  function writesDenied(operation) {
436
461
  const reason = grants?.writesDeniedReason;
437
462
  return reason === undefined ? undefined : tenantDbWritesDeniedError(operation, reason);
@@ -468,6 +493,26 @@ function buildTenantDb(options) {
468
493
  "use db.global(table) with escapeHatch instead.",
469
494
  });
470
495
  }
496
+ // Callers ran executorManagedWriteDenied first: either db holds the capability or the table is
497
+ // unmanaged. The split only proves to the compiler which overload of the free writers applies.
498
+ function insertRow(table, values) {
499
+ if (hasExecutorManagedWriteCapability(db))
500
+ return bunInsertOne(db, table, values);
501
+ assertWritableTable(table);
502
+ return bunInsertOne(db, table, values);
503
+ }
504
+ function updateRows(table, set, where) {
505
+ if (hasExecutorManagedWriteCapability(db))
506
+ return bunUpdateMany(db, table, set, where);
507
+ assertWritableTable(table);
508
+ return bunUpdateMany(db, table, set, where);
509
+ }
510
+ function deleteRows(table, where) {
511
+ if (hasExecutorManagedWriteCapability(db))
512
+ return bunDeleteMany(db, table, where);
513
+ assertWritableTable(table);
514
+ return bunDeleteMany(db, table, where);
515
+ }
471
516
  function globalTable(table) {
472
517
  const meta = asEntityTableMeta(table);
473
518
  if (meta?.tenancy !== "global") {
@@ -485,18 +530,18 @@ function buildTenantDb(options) {
485
530
  insertOne(values) {
486
531
  const denied = writesDenied("ctx.db.global().insertOne") ??
487
532
  missingEscapeHatch(table) ??
488
- executorManagedWriteDenied(table) ??
533
+ executorManagedWriteDenied(db, table, "db.global().insertOne") ??
489
534
  foreignTenantOnGlobalWrite(table, values["tenantId"]) ??
490
535
  personalDataDenied(table, Object.keys(values));
491
536
  if (denied)
492
537
  return Promise.reject(denied);
493
538
  report("global-write", globalWriteReason());
494
- return withDbSpan("insert", table, async () => bunInsertOne(db, table, values));
539
+ return withDbSpan("insert", table, async () => insertRow(table, values));
495
540
  },
496
541
  updateMany(set, where) {
497
542
  const denied = writesDenied("ctx.db.global().updateMany") ??
498
543
  missingEscapeHatch(table) ??
499
- executorManagedWriteDenied(table) ??
544
+ executorManagedWriteDenied(db, table, "db.global().updateMany") ??
500
545
  foreignTenantOnGlobalWrite(table, set["tenantId"]) ??
501
546
  personalDataDenied(table, Object.keys(set));
502
547
  if (denied)
@@ -505,12 +550,12 @@ function buildTenantDb(options) {
505
550
  return Promise.reject(new Error("db.global().updateMany without where would mass-update every tenant's rows. Pass at least one where condition."));
506
551
  }
507
552
  report("global-write", globalWriteReason());
508
- return withDbSpan("update", table, async () => bunUpdateMany(db, table, set, where));
553
+ return withDbSpan("update", table, async () => updateRows(table, set, where));
509
554
  },
510
555
  deleteMany(where) {
511
556
  const denied = writesDenied("ctx.db.global().deleteMany") ??
512
557
  missingEscapeHatch(table) ??
513
- executorManagedWriteDenied(table);
558
+ executorManagedWriteDenied(db, table, "db.global().deleteMany");
514
559
  if (denied)
515
560
  return Promise.reject(denied);
516
561
  if (!where || Object.keys(where).length === 0) {
@@ -518,10 +563,10 @@ function buildTenantDb(options) {
518
563
  }
519
564
  report("global-write", globalWriteReason());
520
565
  return withDbSpan("delete", table, async () => {
521
- await bunDeleteMany(db, table, where);
566
+ await deleteRows(table, where);
522
567
  });
523
568
  },
524
- // @cast-boundary phantom-brand — GlobalWrites is only present in the type when TTable is not ExecutorOnly; the object above always carries the methods.
569
+ // @cast-boundary phantom-brand — GlobalWrites is only present in the type when TTable carries Writable; the object above always carries the methods.
525
570
  };
526
571
  }
527
572
  function grantedUnsafeRawRunner(declaredStepReason) {
@@ -537,7 +582,7 @@ function buildTenantDb(options) {
537
582
  }
538
583
  // Engine-forwarded steps carry their own declared reason; otherwise the grant's reason is audited.
539
584
  report("unsafe-raw", declaredStepReason ?? grants?.unsafeRaw?.reason ?? "");
540
- return boundRunner(db, personalDataGate, projectionRegistry);
585
+ return boundRunner(withoutExecutorManagedWrites(db), personalDataGate, projectionRegistry, false);
541
586
  }
542
587
  const tenantDb = {
543
588
  tenantId,
@@ -559,6 +604,9 @@ function buildTenantDb(options) {
559
604
  advisoryXactLock(namespace, key) {
560
605
  return acquireNamespacedAdvisoryLock(db, namespace, `${tenantId}:${key}`);
561
606
  },
607
+ databaseNow() {
608
+ return queryDatabaseNow(db);
609
+ },
562
610
  aggregate(table, spec, where) {
563
611
  const filter = readWhere(table, where);
564
612
  return withDbSpan("select", table, async () => bunAggregateWhere(db, table, spec, filter));
@@ -578,8 +626,11 @@ function buildTenantDb(options) {
578
626
  const personalDenied = personalDataDenied(table, Object.keys(values));
579
627
  if (personalDenied)
580
628
  return Promise.reject(personalDenied);
629
+ const managedDenied = executorManagedWriteDenied(db, table, "ctx.db.insertOne");
630
+ if (managedDenied)
631
+ return Promise.reject(managedDenied);
581
632
  const data = insertValues(table, values);
582
- return withDbSpan("insert", table, async () => bunInsertOne(db, table, data));
633
+ return withDbSpan("insert", table, async () => insertRow(table, data));
583
634
  },
584
635
  updateMany(table, set, where) {
585
636
  const writeDenied = writesDenied("ctx.db.updateMany");
@@ -594,8 +645,11 @@ function buildTenantDb(options) {
594
645
  const personalDenied = personalDataDenied(table, Object.keys(set));
595
646
  if (personalDenied)
596
647
  return Promise.reject(personalDenied);
648
+ const managedDenied = executorManagedWriteDenied(db, table, "ctx.db.updateMany");
649
+ if (managedDenied)
650
+ return Promise.reject(managedDenied);
597
651
  const filter = writeWhere(table, where);
598
- return withDbSpan("update", table, async () => bunUpdateMany(db, table, set, filter));
652
+ return withDbSpan("update", table, async () => updateRows(table, set, filter));
599
653
  },
600
654
  deleteMany(table, where) {
601
655
  const writeDenied = writesDenied("ctx.db.deleteMany");
@@ -607,8 +661,11 @@ function buildTenantDb(options) {
607
661
  const globalDenied = tenantWriteOnGlobalTable(table, "deleteMany");
608
662
  if (globalDenied)
609
663
  return Promise.reject(globalDenied);
664
+ const managedDenied = executorManagedWriteDenied(db, table, "ctx.db.deleteMany");
665
+ if (managedDenied)
666
+ return Promise.reject(managedDenied);
610
667
  const filter = writeWhere(table, where);
611
- return withDbSpan("delete", table, async () => bunDeleteMany(db, table, filter));
668
+ return withDbSpan("delete", table, async () => deleteRows(table, filter));
612
669
  },
613
670
  };
614
671
  declaredUnsafeRawRunners.set(tenantDb, grantedUnsafeRawRunner);
@@ -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,9 +1,9 @@
1
1
  import type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
2
- import type { TenantDb } from "../../db/tenant-db.ts";
2
+ import type { ExecutorManagedTenantDb } from "../../db/tenant-db.ts";
3
3
  import type { Registry, TenantId } from "../types/index.ts";
4
4
  import type { TenantDestroyHookResult } from "./tenant-resource.ts";
5
5
  export interface TenantDataHookCtx {
6
- readonly db: TenantDb;
6
+ readonly db: ExecutorManagedTenantDb;
7
7
  readonly registry: Registry;
8
8
  readonly tenantId: TenantId;
9
9
  readonly deadlineAt?: number;
@@ -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,7 +1,7 @@
1
- import type { DbRunner } from "../db/connection.ts";
1
+ import type { ExecutorManagedRunner } from "@cosmicdrift/kumiko-types/executor-brand";
2
2
  import type { StoredEvent } from "../event-store/event-store.ts";
3
3
  import type { MultiStreamApplyContext } from "../pipeline/multi-stream-apply-context.ts";
4
4
  import type { MultiStreamApplyFn, ProjectionTable, SingleStreamApplyFn } from "./types/projection.ts";
5
- export declare function defineApply<TPayload = Record<string, unknown>>(fn: (event: StoredEvent<TPayload>, tx: DbRunner, table: ProjectionTable) => Promise<void>): SingleStreamApplyFn;
6
- export declare function defineMspApply<TPayload = Record<string, unknown>>(fn: (event: StoredEvent<TPayload>, tx: DbRunner, ctx: MultiStreamApplyContext) => Promise<void>): MultiStreamApplyFn;
5
+ export declare function defineApply<TPayload = Record<string, unknown>>(fn: (event: StoredEvent<TPayload>, tx: ExecutorManagedRunner, table: ProjectionTable) => Promise<void>): SingleStreamApplyFn;
6
+ export declare function defineMspApply<TPayload = Record<string, unknown>>(fn: (event: StoredEvent<TPayload>, tx: ExecutorManagedRunner, ctx: MultiStreamApplyContext) => Promise<void>): MultiStreamApplyFn;
7
7
  export declare function setFields(table: ProjectionTable, fields: Record<string, unknown> | ((event: Parameters<SingleStreamApplyFn>[0]) => Record<string, unknown>)): SingleStreamApplyFn;
@@ -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
@@ -1,5 +1,6 @@
1
1
  import type { FieldDefinition, SelectFieldDef } from "./types/fields.ts";
2
2
  import type { ActionFormScreenDefinition, ConfigEditScreenDefinition, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, FieldCondition, FormatSpec, ListColumnSpec, ProjectionDetailScreenDefinition, ScreenDefinition, SecretMintScreenDefinition } from "./types/screen.ts";
3
+ export declare const ACTION_FORM_PSEUDO_ENTITY = "__action-form__";
3
4
  export declare function explicitListScreenId(screen: ScreenDefinition): string | undefined;
4
5
  export declare function resolveNavParentScreen(screens: readonly ScreenDefinition[], detail: ScreenDefinition, getId: (screen: ScreenDefinition) => string): ScreenDefinition | undefined;
5
6
  export type EditLayoutScreen = ProjectionDetailScreenDefinition | EntityEditScreenDefinition | ActionFormScreenDefinition | ConfigEditScreenDefinition | SecretMintScreenDefinition;
@@ -1,3 +1,6 @@
1
+ // Entity name the renderer gives the synthesized entity of an actionForm, secretMint or confirm
2
+ // step. It is not registered, so nothing server-side may treat it as a real entity.
3
+ export const ACTION_FORM_PSEUDO_ENTITY = "__action-form__";
1
4
  // Every screen type carrying `listScreenId` declares it identically; centralizing
2
5
  // here keeps the switch the only place that grows for a new screen type.
3
6
  export function explicitListScreenId(screen) {
@@ -5,16 +5,18 @@
5
5
  // true buys an entity restore + trash (ctx.includeDeleted) + this cleanup, with
6
6
  // no feature to wire.
7
7
  //
8
- // Hard-deleting the projection row leaves the event stream intact (source of
9
- // truth) — a full projection rebuild would replay created+deleted and
10
- // resurrect the row as isDeleted=true. That's acceptable: cleanup bounds LIVE
11
- // table growth; irreversible event-log purging is data-retention's job
12
- // (pruneEvents), a separate, consumer-lag-guarded path.
13
- import { asEntityTableMeta, deleteMany } from "../db/query.js";
8
+ // Each expired row is purged through the executor's forget verb: the `<entity>.forgotten`
9
+ // event replays on a projection rebuild, so the purged row stays gone. The earlier events stay
10
+ // in the log; irreversible event-log purging is data-retention's job (pruneEvents), a separate,
11
+ // consumer-lag-guarded path.
12
+ import { createEventStoreExecutor } from "../db/event-store-executor.js";
13
+ import { asEntityTableMeta, selectMany } from "../db/query.js";
14
14
  import { hasFieldProperty } from "../db/table-introspection.js";
15
+ import { reraiseAsKumikoError } from "../errors/index.js";
15
16
  import { purgeExpiredFileRefsWithBytes } from "../files/purge-expired-file-ref-bytes.js";
16
17
  import { FILE_REF_ENTITY_NAME } from "../files/types.js";
17
- import { SYSTEM_USER_ID } from "./system-user.js";
18
+ import { createSystemUser, SYSTEM_USER_ID } from "./system-user.js";
19
+ import { SYSTEM_TENANT_ID } from "./types/identifiers.js";
18
20
  // qualifyEntityName convention (feature:type:kebab-name) with a reserved
19
21
  // "soft-delete" owner — no real feature owns these; the framework synthesizes
20
22
  // them. The job-runner keys cron scheduling off the name, the config-resolver
@@ -46,6 +48,10 @@ export function isSystemScopedSoftDeleteTable(table, entity) {
46
48
  function isGlobalTenancyTable(table) {
47
49
  return asEntityTableMeta(table)?.tenancy === "global";
48
50
  }
51
+ async function resolveGraceDays(ctx, tenantId, db) {
52
+ const resolved = await ctx.configResolver?.get(SOFT_DELETE_GRACE_DAYS_KEY, softDeleteGraceDaysConfig, tenantId, SYSTEM_USER_ID, db);
53
+ return typeof resolved === "number" && resolved >= 1 ? resolved : DEFAULT_GRACE_DAYS;
54
+ }
49
55
  export const softDeleteCleanupJob = async (_payload, ctx) => {
50
56
  const { db, registry } = ctx;
51
57
  if (!db || !registry) {
@@ -61,10 +67,7 @@ export const softDeleteCleanupJob = async (_payload, ctx) => {
61
67
  // skip: cron fired without a perTenant fan-out tenant — nothing scoped to purge
62
68
  return;
63
69
  }
64
- const resolved = ctx.configResolver
65
- ? await ctx.configResolver.get(SOFT_DELETE_GRACE_DAYS_KEY, softDeleteGraceDaysConfig, tenantId, SYSTEM_USER_ID, db)
66
- : undefined;
67
- const graceDays = typeof resolved === "number" && resolved >= 1 ? resolved : DEFAULT_GRACE_DAYS;
70
+ const graceDays = await resolveGraceDays(ctx, tenantId, db);
68
71
  const cutoff = Temporal.Now.instant().subtract({ hours: graceDays * 24 });
69
72
  for (const proj of registry.getAllProjections().values()) {
70
73
  if (proj.isImplicit !== true || typeof proj.source !== "string" || !proj.table)
@@ -80,23 +83,49 @@ export const softDeleteCleanupJob = async (_payload, ctx) => {
80
83
  if (isSystemScopedSoftDeleteTable(proj.table, entity))
81
84
  continue;
82
85
  const where = { isDeleted: true, deletedAt: { lt: cutoff }, tenantId };
83
- await purgeExpiredRows(db, proj.source, proj.table, where, cutoff, ctx);
86
+ const executor = createEventStoreExecutor(proj.table, entity, { entityName: proj.source });
87
+ await purgeExpiredRows({ db, executor, user: ctx.systemUser ?? createSystemUser(tenantId) }, proj.source, proj.table, where, cutoff, ctx);
84
88
  }
85
89
  };
86
- async function purgeExpiredRows(db, source, table, where, cutoff, ctx) {
90
+ const PURGE_PAGE_SIZE = 500;
91
+ async function forgetRow(writer, id) {
92
+ // ponytail: one append per expired row, batch when purge volume needs it
93
+ const result = await writer.executor.forget({ id }, writer.user, writer.db);
94
+ if (!result.isSuccess)
95
+ throw reraiseAsKumikoError(result.error);
96
+ }
97
+ async function purgeExpiredRows(writer, source, table, where, cutoff, ctx, readDb = writer.db) {
87
98
  if (source === FILE_REF_ENTITY_NAME) {
88
99
  // The row is the only record of the storageKey, so its bytes go first.
89
- await purgeExpiredFileRefsWithBytes({
90
- db,
100
+ return await purgeExpiredFileRefsWithBytes({
101
+ db: writer.db,
91
102
  table,
92
103
  where,
93
104
  expiredBefore: cutoff,
94
105
  files: ctx.files,
95
106
  log: ctx.log,
107
+ forgetRow: (id) => forgetRow(writer, id),
96
108
  });
97
109
  }
98
- else {
99
- await deleteMany(db, table, where);
110
+ await forgetExpiredPages(writer, source, table, where, ctx, readDb);
111
+ }
112
+ // Keyset on id: a row whose forget fails is logged and skipped, it must not hide the rows behind it.
113
+ async function forgetExpiredPages(writer, source, table, where, ctx, readDb) {
114
+ let afterId;
115
+ for (;;) {
116
+ const page = await selectMany(readDb, table, { ...where, ...(afterId === undefined ? {} : { id: { gt: afterId } }) }, { orderBy: { col: "id", direction: "asc" }, limit: PURGE_PAGE_SIZE });
117
+ const last = page[page.length - 1];
118
+ if (last === undefined)
119
+ break;
120
+ for (const row of page) {
121
+ try {
122
+ await forgetRow(writer, row.id);
123
+ }
124
+ catch (err) {
125
+ ctx.log?.warn(`[soft-delete-cleanup] ${source} ${row.id} not purged: ${err instanceof Error ? err.message : String(err)}`);
126
+ }
127
+ }
128
+ afterId = last.id;
100
129
  }
101
130
  }
102
131
  // System-global soft-deleted entities (tenancy "global" or no tenantId column) can't take a
@@ -119,13 +148,13 @@ export const softDeleteCleanupSystemJob = async (_payload, ctx) => {
119
148
  if (!isSystemScopedSoftDeleteTable(proj.table, entity))
120
149
  continue;
121
150
  const where = { isDeleted: true, deletedAt: { lt: cutoff } };
122
- if (isGlobalTenancyTable(proj.table)) {
123
- // db.global() rejects writes on executor-managed tables; a retention purge has no event to append.
124
- await deleteMany(db.unsafeRaw(), proj.table, where);
125
- }
126
- else {
127
- await deleteMany(db, proj.table, where);
128
- }
151
+ const writer = {
152
+ db,
153
+ executor: createEventStoreExecutor(proj.table, entity, { entityName: proj.source }),
154
+ user: ctx.systemUser ?? createSystemUser(SYSTEM_TENANT_ID),
155
+ };
156
+ // A global-tenancy table is invisible to a tenant-mode ctx.db, so its candidates are read unscoped.
157
+ await purgeExpiredRows(writer, proj.source, proj.table, where, cutoff, ctx, isGlobalTenancyTable(proj.table) ? db.unsafeRaw() : db);
129
158
  }
130
159
  };
131
160
  export function buildSoftDeleteCleanupJob() {
@@ -16,6 +16,8 @@
16
16
  // must commit in the same TX as the aggregate-mutation that triggered
17
17
  // it (stronger consistency than an async projection). Reviewer judges.
18
18
  import { deleteMany } from "../../db/query.js";
19
+ import { requireEntityTableMeta } from "../../db/table-introspection.js";
20
+ import { withExecutorManagedWrites } from "../../db/tenant-db.js";
19
21
  import { tenantDbWriteRunner } from "../../db/tenant-db-runner.js";
20
22
  import { defineStep } from "../define-step.js";
21
23
  import { resolveRequired } from "./_resolver-utils.js";
@@ -26,8 +28,8 @@ defineStep({
26
28
  const where = resolveRequired(args.where, ctx);
27
29
  // The `unsafe*`-step IS the sanctioned direct projection write — it exists
28
30
  // to bypass the executor deliberately (boot-validated allowlist + reviewer
29
- // gate). The brand-strip cast makes that explicit at the one blessed seam.
30
- await deleteMany(tenantDbWriteRunner(ctx.db, "unsafe projection delete"), args.table, where);
31
+ // gate).
32
+ await deleteMany(withExecutorManagedWrites(tenantDbWriteRunner(ctx.db, "unsafe projection delete")), requireEntityTableMeta(args.table, "unsafeProjectionDelete"), where);
31
33
  },
32
34
  });
33
35
  export function buildUnsafeProjectionDeleteStep(args) {