@cosmicdrift/kumiko-framework 0.288.0 → 0.290.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 (75) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/field-access.integration.test.ts +7 -3
  3. package/src/__tests__/ownership-where-write-path.integration.test.ts +1 -1
  4. package/src/__tests__/ownership.integration.test.ts +1 -1
  5. package/src/api/__tests__/server-boot-guards.test.ts +42 -0
  6. package/src/api/request-context.ts +28 -0
  7. package/src/api/server.ts +30 -4
  8. package/src/changes.json +75 -0
  9. package/src/compliance/__tests__/sub-processors.test.ts +10 -0
  10. package/src/compliance/sub-processors.ts +14 -1
  11. package/src/crypto/__tests__/blind-index.test.ts +1 -1
  12. package/src/crypto/__tests__/kek-source.test.ts +298 -0
  13. package/src/crypto/__tests__/pii-field-encryption.test.ts +2 -2
  14. package/src/crypto/__tests__/subject-resolver.test.ts +2 -2
  15. package/src/crypto/index.ts +3 -0
  16. package/src/crypto/kek-source.ts +189 -0
  17. package/src/crypto/kms-wiring.ts +20 -0
  18. package/src/db/__tests__/blind-index.integration.test.ts +1 -1
  19. package/src/db/__tests__/eagerload.integration.test.ts +12 -2
  20. package/src/db/__tests__/entity-field-encryption.test.ts +2 -2
  21. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
  22. package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +7 -2
  23. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +194 -1
  24. package/src/db/__tests__/event-store-executor.integration.test.ts +15 -5
  25. package/src/db/__tests__/list-filter-field-access.integration.test.ts +6 -2
  26. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +60 -1
  27. package/src/db/event-store-executor-context.ts +79 -0
  28. package/src/db/event-store-executor-write.ts +42 -5
  29. package/src/db/index.ts +1 -0
  30. package/src/db/tenant-db.ts +8 -1
  31. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +32 -0
  32. package/src/engine/__tests__/boot-validator-boot-check.test.ts +1 -1
  33. package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +56 -0
  34. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +26 -15
  35. package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +6 -2
  36. package/src/engine/__tests__/factories-long-text.test.ts +6 -1
  37. package/src/engine/__tests__/required-surface-keys.test.ts +196 -0
  38. package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +63 -0
  39. package/src/engine/boot-validator/__tests__/record-owned.test.ts +12 -2
  40. package/src/engine/boot-validator/action-wiring.ts +2 -1
  41. package/src/engine/boot-validator/entity-handler.ts +10 -4
  42. package/src/engine/boot-validator/screens.ts +4 -12
  43. package/src/engine/extensions/storage-provider.ts +17 -2
  44. package/src/engine/extensions/tenant-data.ts +9 -0
  45. package/src/engine/factories.ts +2 -0
  46. package/src/engine/index.ts +1 -0
  47. package/src/engine/qualified-name.ts +9 -0
  48. package/src/engine/screen-helpers.ts +17 -0
  49. package/src/errors/classes.ts +24 -0
  50. package/src/errors/index.ts +3 -0
  51. package/src/errors/member-resolution.ts +12 -0
  52. package/src/event-store/__tests__/backfill-pii.integration.test.ts +2 -2
  53. package/src/event-store/__tests__/event-attribution.integration.test.ts +186 -0
  54. package/src/event-store/__tests__/provenance-append.integration.test.ts +33 -1
  55. package/src/event-store/event-store.ts +23 -2
  56. package/src/event-store/provenance-append.ts +9 -1
  57. package/src/files/__tests__/file-handle.test.ts +28 -1
  58. package/src/files/file-handle.ts +26 -7
  59. package/src/files/index.ts +1 -1
  60. package/src/i18n/required-surface-keys.ts +24 -12
  61. package/src/jobs/job-runner.ts +10 -2
  62. package/src/pipeline/__tests__/member-resolution-read-only.test.ts +79 -0
  63. package/src/pipeline/active-membership.ts +10 -4
  64. package/src/pipeline/append-event-core.ts +2 -11
  65. package/src/pipeline/dispatch-shared.ts +31 -18
  66. package/src/pipeline/dispatch-stream.ts +8 -3
  67. package/src/pipeline/dispatch-write.ts +3 -2
  68. package/src/pipeline/event-dispatcher-delivery.ts +15 -3
  69. package/src/pipeline/event-dispatcher.ts +18 -5
  70. package/src/pipeline/member-read-only-transaction.ts +2 -6
  71. package/src/stack/__tests__/ownership-boot-guard.integration.test.ts +1 -1
  72. package/src/stack/table-helpers.ts +6 -0
  73. package/src/testing/__tests__/e2e-generator.test.ts +50 -0
  74. package/src/testing/e2e-generator.ts +4 -3
  75. package/src/ui-types/index.ts +2 -0
@@ -0,0 +1,79 @@
1
+ // createUncheckedSystemDb's grantedUnsafeRawRunner (db/tenant-db.ts) hands out a
2
+ // raw runner without a grant check. Under a resolved member principal that door
3
+ // is closed only because applyMemberResolutionReadOnly drops systemDb and
4
+ // dbOutsideTransaction — this pins that, plus the write surfaces it neutralizes.
5
+ import { describe, expect, test } from "bun:test";
6
+ import type { HandlerContext } from "../../engine/types";
7
+ import { AccessDeniedError, FrameworkReasons } from "../../errors";
8
+ import { applyMemberResolutionReadOnly } from "../dispatch-shared";
9
+
10
+ const DROPPED_SURFACES = ["systemDb", "dbOutsideTransaction", "runPreSave", "files", "derivatives"];
11
+
12
+ const DENIED_CALLS = [
13
+ "write",
14
+ "writeAs",
15
+ "queryAs",
16
+ "appendEvent",
17
+ "unsafeAppendEvent",
18
+ "tryAppendEvent",
19
+ "fetchForWriting",
20
+ "archiveStream",
21
+ "restoreStream",
22
+ "snapshotAggregate",
23
+ "queryAsMember",
24
+ "resolveActiveMembership",
25
+ "notify",
26
+ ];
27
+
28
+ function readOnlyContext(): Record<string, unknown> {
29
+ const wired = Object.fromEntries(
30
+ [...DROPPED_SURFACES, ...DENIED_CALLS, "scheduleAfterCommit"].map((key) => [
31
+ key,
32
+ () => "escalated",
33
+ ]),
34
+ );
35
+ // @cast-boundary test stub — applyMemberResolutionReadOnly only spreads and overwrites.
36
+ const ctx = { ...wired, jobRunner: { handleEvent: () => "ran" } } as unknown as HandlerContext;
37
+ return applyMemberResolutionReadOnly(ctx) as unknown as Record<string, unknown>;
38
+ }
39
+
40
+ function surface(readOnly: Record<string, unknown>, key: string): unknown {
41
+ return readOnly[key];
42
+ }
43
+
44
+ describe("applyMemberResolutionReadOnly", () => {
45
+ test("drops every surface that could reach an unchecked system-scope db", () => {
46
+ const readOnly = readOnlyContext();
47
+
48
+ for (const key of DROPPED_SURFACES) {
49
+ expect(surface(readOnly, key)).toBeUndefined();
50
+ }
51
+ });
52
+
53
+ test.each(DENIED_CALLS)("%s rejects with the member-resolution reason", async (call) => {
54
+ // @cast-boundary test stub — every denied surface is a call returning a promise.
55
+ const denied = surface(readOnlyContext(), call) as () => Promise<unknown>;
56
+
57
+ const error = await denied().then(
58
+ () => undefined,
59
+ (e: unknown) => e,
60
+ );
61
+
62
+ expect(error).toBeInstanceOf(AccessDeniedError);
63
+ // @cast-boundary narrowed by the assertion above.
64
+ expect((error as AccessDeniedError).details).toMatchObject({
65
+ reason: FrameworkReasons.memberResolutionReadOnly,
66
+ });
67
+ });
68
+
69
+ test("scheduleAfterCommit and the jobRunner proxy throw synchronously", () => {
70
+ const readOnly = readOnlyContext();
71
+ // @cast-boundary test stub — sync surfaces, not promise-returning.
72
+ const scheduleAfterCommit = surface(readOnly, "scheduleAfterCommit") as () => void;
73
+ // @cast-boundary test stub — denyingJobRunnerProxy throws on any property read.
74
+ const jobRunner = surface(readOnly, "jobRunner") as { handleEvent: unknown };
75
+
76
+ expect(() => scheduleAfterCommit()).toThrow(AccessDeniedError);
77
+ expect(() => jobRunner.handleEvent).toThrow(AccessDeniedError);
78
+ });
79
+ });
@@ -97,18 +97,21 @@ export function resolvePrincipalPlugin(registry: Registry): PrincipalStatusPlugi
97
97
  return usage.options;
98
98
  }
99
99
 
100
- function resolveLifecyclePlugin(registry: Registry): TenantLifecycleStatusPlugin | undefined {
100
+ export function resolveTenantLifecyclePlugin(
101
+ registry: Registry,
102
+ caller: string,
103
+ ): TenantLifecycleStatusPlugin | undefined {
101
104
  const usages = registry.getExtensionUsages(EXT_TENANT_LIFECYCLE_STATUS);
102
105
  if (usages.length > 1) {
103
106
  throw new InternalError({
104
- message: `dispatcher.resolveActiveMembership: multiple "${EXT_TENANT_LIFECYCLE_STATUS}" providers registered — exactly one (or zero) is expected.`,
107
+ message: `${caller}: multiple "${EXT_TENANT_LIFECYCLE_STATUS}" providers registered — exactly one (or zero) is expected.`,
105
108
  });
106
109
  }
107
110
  const usage = usages[0];
108
111
  if (!usage) return undefined;
109
112
  if (!isTenantLifecycleStatusPlugin(usage.options)) {
110
113
  throw new InternalError({
111
- message: `dispatcher.resolveActiveMembership: "${usage.entityName}" registered under "${EXT_TENANT_LIFECYCLE_STATUS}" without a resolveStatus(tenantId, {db}) — extension options must be a TenantLifecycleStatusPlugin.`,
114
+ message: `${caller}: "${usage.entityName}" registered under "${EXT_TENANT_LIFECYCLE_STATUS}" without a resolveStatus(tenantId, {db}) — extension options must be a TenantLifecycleStatusPlugin.`,
112
115
  });
113
116
  }
114
117
  return usage.options;
@@ -160,7 +163,10 @@ export async function resolveActiveMembershipFn(
160
163
  return { kind: "rejected", reason: "principal_blocked" };
161
164
  }
162
165
 
163
- const lifecyclePlugin = resolveLifecyclePlugin(registry);
166
+ const lifecyclePlugin = resolveTenantLifecyclePlugin(
167
+ registry,
168
+ "dispatcher.resolveActiveMembership",
169
+ );
164
170
  if (lifecyclePlugin) {
165
171
  const lifecycle = await lifecyclePlugin.resolveStatus(tenantId, { db });
166
172
  if (isTeardownRejected(lifecycle, policy)) {
@@ -1,6 +1,6 @@
1
1
  import { requestContext } from "../api/request-context";
2
2
  import type { DbRunner } from "../db/connection";
3
- import { toKebab } from "../engine/qualified-name";
3
+ import { qnScope, toKebab } from "../engine/qualified-name";
4
4
  import type { AppendEventArgs, Registry, TenantId } from "../engine/types";
5
5
  import { InternalError, validationErrorFromZod } from "../errors";
6
6
  import { isStreamArchived } from "../event-store/archive";
@@ -32,15 +32,6 @@ export type AppendDomainEventCoreDeps = {
32
32
  readonly callerFeature?: string;
33
33
  };
34
34
 
35
- // Extract the owning feature from a qualified event name. Events are
36
- // registered as "<feature>:event:<short>" (see registry.ts qualify()) so the
37
- // prefix before the first ":" is the owner. Falls back to undefined if the
38
- // name isn't qualified — callers then skip the cross-feature check.
39
- function eventOwnerFeature(qualifiedName: string): string | undefined {
40
- const idx = qualifiedName.indexOf(":");
41
- return idx > 0 ? qualifiedName.slice(0, idx) : undefined;
42
- }
43
-
44
35
  // System-event prefix: events under this namespace bypass the registry +
45
36
  // ownership checks. Reserved for framework-internal coordination (step-
46
37
  // engine deferred dispatch, lifecycle signals). The matching MSP filters
@@ -71,7 +62,7 @@ export async function appendDomainEventCore(
71
62
  // into kebab-case for the event/handler names (pubsub-orders:event:…) — so
72
63
  // we compare the kebab form on both sides.
73
64
  if (deps.callerFeature && !isSystemEvent) {
74
- const owner = eventOwnerFeature(args.type);
65
+ const owner = qnScope(args.type);
75
66
  const callerKebab = toKebab(deps.callerFeature);
76
67
  if (owner && owner !== callerKebab) {
77
68
  throw new InternalError({
@@ -1,4 +1,4 @@
1
- import { requestContext } from "../api/request-context";
1
+ import { requestContext, runWithOrigin } from "../api/request-context";
2
2
  import type { SseBroker } from "../api/sse-broker";
3
3
  import type { DbConnection, DbRunner, DbTx } from "../db/connection";
4
4
  import { runInSavepoint, selectMany } from "../db/query";
@@ -31,10 +31,9 @@ import type {
31
31
  import { isRateLimitDisabled } from "../engine/types";
32
32
  import type { TenantId } from "../engine/types/identifiers";
33
33
  import {
34
- AccessDeniedError,
35
34
  FeatureDisabledError,
36
- FrameworkReasons,
37
35
  InternalError,
36
+ memberResolutionReadOnlyDenied,
38
37
  VersionConflictError,
39
38
  type WriteErrorInfo,
40
39
  } from "../errors";
@@ -176,7 +175,7 @@ async function appendDomainEvent(
176
175
  callerFeature: string | undefined,
177
176
  ): Promise<void> {
178
177
  // Sink behind every append surface, so a resolved member stays read-only even via fetchForWriting handles.
179
- if (user.origin === "member-resolution") throw memberResolutionReadOnlyDenied();
178
+ if (isMemberResolutionPrincipal(user)) throw memberResolutionReadOnlyDenied();
180
179
  const { registry } = ctx;
181
180
  const dbSource = resolveDbSource(ctx, tx);
182
181
  if (!dbSource) {
@@ -222,12 +221,8 @@ function createSystemScopedDbGuard(
222
221
  });
223
222
  }
224
223
 
225
- export function memberResolutionReadOnlyDenied(cause?: unknown): AccessDeniedError {
226
- return new AccessDeniedError({
227
- message: "a resolved member principal (ctx.queryAsMember) cannot write — read-only",
228
- details: { reason: FrameworkReasons.memberResolutionReadOnly },
229
- ...(cause instanceof Error && { cause }),
230
- });
224
+ export function isMemberResolutionPrincipal(user: SessionUser): boolean {
225
+ return user.origin === "member-resolution";
231
226
  }
232
227
 
233
228
  async function denyMemberResolutionWrite(): Promise<never> {
@@ -246,10 +241,11 @@ function denyingJobRunnerProxy(): JobRunnerRef {
246
241
 
247
242
  // Every write/side-effect surface throws, so "no writeAsMember" holds even
248
243
  // if a handler tries ctx.write directly on a resolved member principal.
249
- function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext {
244
+ export function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext {
250
245
  return {
251
246
  ...handlerContext,
252
- // `db` stays open — executeQuery runs the whole handler in a Postgres READ ONLY transaction.
247
+ // `db` stays open — executeQuery runs the whole handler in a Postgres READ ONLY
248
+ // transaction, and its `memberReadOnly` grant denies every raw-runner handout.
253
249
  dbOutsideTransaction: undefined,
254
250
  write: denyMemberResolutionWrite,
255
251
  writeAs: denyMemberResolutionWrite,
@@ -319,7 +315,12 @@ export async function buildHandlerContext(
319
315
  context.tracer,
320
316
  context.meter,
321
317
  signal,
322
- { globalWrites: writeEscapeHatch, unsafeRaw: handlerEscapeHatch, report: reportEscapeHatch },
318
+ {
319
+ globalWrites: writeEscapeHatch,
320
+ unsafeRaw: handlerEscapeHatch,
321
+ report: reportEscapeHatch,
322
+ memberReadOnly: isMemberResolutionPrincipal(user),
323
+ },
323
324
  );
324
325
  // Propagate the request's AbortSignal so every TenantDb query throws when
325
326
  // the client has disconnected — handlers with many sequential queries skip
@@ -865,7 +866,7 @@ export async function buildHandlerContext(
865
866
  } as HandlerContext; // @cast-boundary engine-bridge
866
867
 
867
868
  // A resolved member principal is read-only by construction.
868
- return user.origin === "member-resolution"
869
+ return isMemberResolutionPrincipal(user)
869
870
  ? applyMemberResolutionReadOnly(handlerContext)
870
871
  : handlerContext;
871
872
  }
@@ -906,7 +907,12 @@ export async function runHandlerInstrumented<T>(
906
907
  },
907
908
  async (span) => {
908
909
  try {
909
- const result = await inner();
910
+ // #3043 — everything the handler writes, including entity-executor
911
+ // writes below it, is attributed to this handler.
912
+ const result = await runWithOrigin(
913
+ { handler: type, feature: registry.getHandlerFeature(type) },
914
+ inner,
915
+ );
910
916
  if (operation === "write" && isFailedWriteResult(result)) {
911
917
  success = false;
912
918
  errorClass = result.error?.code ?? "UnknownError";
@@ -962,11 +968,18 @@ export async function* runStreamInstrumented<T>(
962
968
  attributes: dispatcherSpanAttributes(type, "stream", user, registry.getHandlerFeature(type)),
963
969
  });
964
970
  const it = inner();
971
+ // Each pull re-enters both scopes: AsyncLocalStorage does not survive a
972
+ // generator suspension, so wrapping inner() once would leave every event a
973
+ // stream writes unattributed.
974
+ const inScope = <R>(fn: () => R): R =>
975
+ runWithOrigin({ handler: type, feature: registry.getHandlerFeature(type) }, () =>
976
+ observabilityContext.run({ activeSpan: span }, fn),
977
+ );
965
978
  try {
966
- let next = await observabilityContext.run({ activeSpan: span }, () => it.next());
979
+ let next = await inScope(() => it.next());
967
980
  while (!next.done) {
968
981
  yield next.value;
969
- next = await observabilityContext.run({ activeSpan: span }, () => it.next());
982
+ next = await inScope(() => it.next());
970
983
  }
971
984
  completedNormally = true;
972
985
  return next.value;
@@ -979,7 +992,7 @@ export async function* runStreamInstrumented<T>(
979
992
  } finally {
980
993
  // forward close so inner()'s finally still fires — yield* did this for free
981
994
  try {
982
- await observabilityContext.run({ activeSpan: span }, () => it.return?.(undefined));
995
+ await inScope(() => it.return?.(undefined));
983
996
  } catch (closeError) {
984
997
  // Only fold this in when nothing has already failed — a close-time
985
998
  // error while an earlier error is in flight would mask the real
@@ -1,13 +1,18 @@
1
1
  import { hasAccess } from "../engine/access";
2
2
  import type { SessionUser } from "../engine/types";
3
- import { AccessDeniedError, NotFoundError, validationErrorFromZod } from "../errors";
3
+ import {
4
+ AccessDeniedError,
5
+ memberResolutionReadOnlyDenied,
6
+ NotFoundError,
7
+ validationErrorFromZod,
8
+ } from "../errors";
4
9
  import { assertNoSecretLeak } from "../secrets";
5
10
  import {
6
11
  buildHandlerContext,
7
12
  type DispatchContext,
8
13
  enforceRateLimit,
9
14
  ensureFeatureEnabled,
10
- memberResolutionReadOnlyDenied,
15
+ isMemberResolutionPrincipal,
11
16
  runStreamInstrumented,
12
17
  } from "./dispatch-shared";
13
18
 
@@ -38,7 +43,7 @@ async function* executeStreamInner(
38
43
 
39
44
  // A resolved member principal (ctx.queryAsMember) is read-only — streams
40
45
  // are excluded the same way executeWriteInner excludes writes.
41
- if (user.origin === "member-resolution") {
46
+ if (isMemberResolutionPrincipal(user)) {
42
47
  throw memberResolutionReadOnlyDenied();
43
48
  }
44
49
 
@@ -15,6 +15,7 @@ import {
15
15
  FrameworkReasons,
16
16
  InternalError,
17
17
  isKumikoError,
18
+ memberResolutionReadOnlyDenied,
18
19
  NotFoundError,
19
20
  ValidationError,
20
21
  validationErrorFromZod,
@@ -28,7 +29,7 @@ import {
28
29
  CONFIG_WRITE_SET_TYPE,
29
30
  checkFeatureEnabled,
30
31
  enforceRateLimit,
31
- memberResolutionReadOnlyDenied,
32
+ isMemberResolutionPrincipal,
32
33
  resolveDbSource,
33
34
  runHandlerInstrumented,
34
35
  TENANT_TIMEZONE_CONFIG_KEY,
@@ -371,7 +372,7 @@ async function executeWriteInner(
371
372
 
372
373
  // Defense in depth — covers runBatch and a spread-copied user that kept
373
374
  // `origin`, beyond buildHandlerContext's own ctx.write replacement.
374
- if (user.origin === "member-resolution") {
375
+ if (isMemberResolutionPrincipal(user)) {
375
376
  return writeFailure(memberResolutionReadOnlyDenied());
376
377
  }
377
378
 
@@ -14,6 +14,7 @@ import {
14
14
  } from "../db/queries/event-consumer";
15
15
  import { selectEventsHeadId } from "../db/queries/event-store";
16
16
  import { coerceRow, extractTableInfo, selectMany } from "../db/query";
17
+ import { qnScope } from "../engine/qualified-name";
17
18
  import type { AppContext } from "../engine/types";
18
19
  import { eventsTable, toStoredEvent as rowToStoredEvent } from "../event-store";
19
20
  import {
@@ -240,9 +241,20 @@ export async function deliverEvents(
240
241
  const correlationId = stored.metadata.correlationId ?? requestContext.generateId();
241
242
  const causationId = String(stored.id);
242
243
  const requestId = requestContext.generateId();
243
- await requestContext.run({ requestId, correlationId, causationId }, async () => {
244
- await consumer.handler(stored, context);
245
- });
244
+ // #3043 — an event this apply writes is attributed to the consumer, not
245
+ // to whatever wrote the triggering event; causationId already links back.
246
+ await requestContext.run(
247
+ {
248
+ requestId,
249
+ correlationId,
250
+ causationId,
251
+ handler: consumer.name,
252
+ feature: consumer.featureName ?? qnScope(consumer.name),
253
+ },
254
+ async () => {
255
+ await consumer.handler(stored, context);
256
+ },
257
+ );
246
258
  cursor = row.id;
247
259
  attempts = 0;
248
260
  lastError = null;
@@ -127,6 +127,13 @@ export type EventDispatcher = {
127
127
  // a full stop/start cycle. Production never needs this — start() runs
128
128
  // it once on boot and the rows survive dispatcher lifetime.
129
129
  ensureRegistered(): Promise<void>;
130
+ // Waits out a pass already in flight, without touching the timer or the
131
+ // LISTEN subscription — unlike stop(), the dispatcher keeps running
132
+ // afterwards. Test-teardown surface: a beforeEach/afterEach that resets
133
+ // the events table (TRUNCATE) must not race a pass this same dispatcher
134
+ // started (timer tick or NOTIFY wake-up) that is still writing into the
135
+ // tables the reset is about to wipe. No-op when nothing is in flight.
136
+ drain(): Promise<void>;
130
137
  // Read-only view of the consumers this dispatcher is wired with. Exists
131
138
  // for lane-filter assertions (Welle 2.6.b split-deploy tests) and for
132
139
  // the boot-validator (Welle 2.6.c coverage check: every registered MSP
@@ -253,6 +260,14 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
253
260
  // pattern so behaviour under races stays predictable.
254
261
  let passInFlight: Promise<DispatcherPassResult> | null = null;
255
262
 
263
+ async function drainPassInFlight(): Promise<void> {
264
+ if (passInFlight) {
265
+ await passInFlight.catch(() => {
266
+ // skip: errors already recorded per-consumer inside the pass
267
+ });
268
+ }
269
+ }
270
+
256
271
  async function runOnce(): Promise<DispatcherPassResult> {
257
272
  if (!preRegistered) {
258
273
  throw new Error(
@@ -529,11 +544,7 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
529
544
  }
530
545
 
531
546
  // Drain any in-flight pass so shutdown observes consistent state.
532
- if (passInFlight) {
533
- await passInFlight.catch(() => {
534
- // skip: errors already recorded per-consumer inside the pass
535
- });
536
- }
547
+ await drainPassInFlight();
537
548
  // preRegistered stays true — the rows survive stop(). runOnce()
538
549
  // after a stop() still works (tests stop the timer and then drain
539
550
  // deterministically).
@@ -544,6 +555,8 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
544
555
  preRegistered = true;
545
556
  },
546
557
 
558
+ drain: drainPassInFlight,
559
+
547
560
  runOnce,
548
561
  };
549
562
  }
@@ -1,12 +1,8 @@
1
1
  import type { DbTx } from "../db/connection";
2
2
  import { extractPgError } from "../db/pg-error";
3
3
  import { asRawClient, runInSavepoint, transaction } from "../db/query";
4
- import { AccessDeniedError, InternalError } from "../errors";
5
- import {
6
- type DispatchContext,
7
- memberResolutionReadOnlyDenied,
8
- resolveDbSource,
9
- } from "./dispatch-shared";
4
+ import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied } from "../errors";
5
+ import { type DispatchContext, resolveDbSource } from "./dispatch-shared";
10
6
 
11
7
  const PG_READ_ONLY_SQLSTATE = "25006";
12
8
 
@@ -37,7 +37,7 @@ function memoEntity(access: Parameters<typeof createEntity>[0]["access"]) {
37
37
  return createEntity({
38
38
  table: "fwbootguard_memos",
39
39
  fields: {
40
- ownerId: createTextField({ required: true }),
40
+ ownerId: createTextField({ personal: false, reason: "test_fixture", required: true }),
41
41
  title: createTextField({ personal: false, reason: "test_fixture", required: true }),
42
42
  },
43
43
  ...(access ? { access } : {}),
@@ -146,6 +146,12 @@ export async function resetEventStore(
146
146
  stack: { db: unknown; eventDispatcher?: EventDispatcher },
147
147
  extraTables: readonly (unknown | string)[] = [],
148
148
  ): Promise<void> {
149
+ // A pass the dispatcher's own timer/LISTEN already started (from a
150
+ // preceding test) may still be writing into the tables truncated below —
151
+ // drain it first so the TRUNCATE never races a write it didn't cause.
152
+ if (stack.eventDispatcher) {
153
+ await stack.eventDispatcher.drain();
154
+ }
149
155
  const frameworkTables = [
150
156
  "kumiko_events",
151
157
  "kumiko_event_consumers",
@@ -97,6 +97,56 @@ describe("generateE2ESpec", () => {
97
97
  ]);
98
98
  });
99
99
 
100
+ test("groups-only sections liefern Required-Felder, Fill-Ops und Text-Assertion", () => {
101
+ const feature = defineFeature("tasks", (r) => {
102
+ r.systemScope();
103
+ r.entity("task", taskEntity);
104
+ r.writeHandler(
105
+ defineEntityCreateHandler("task", taskEntity, {
106
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
107
+ }),
108
+ );
109
+ r.screen({
110
+ id: "task-list",
111
+ type: "entityList",
112
+ entity: "task",
113
+ columns: ["title", "status", "done"],
114
+ });
115
+ r.screen({
116
+ id: "task-edit",
117
+ type: "entityEdit",
118
+ entity: "task",
119
+ layout: {
120
+ sections: [
121
+ {
122
+ title: "tasks:section.basics",
123
+ fields: [],
124
+ groups: [
125
+ { title: "tasks:group.core", fields: ["title", "status"] },
126
+ { title: "tasks:group.state", fields: ["done"] },
127
+ ],
128
+ },
129
+ ],
130
+ },
131
+ });
132
+ });
133
+ const specs = generateE2ESpec(createRegistry([feature]));
134
+ const editSpecs = specs.filter((s) => s.screenQn === "tasks:screen:task-edit");
135
+
136
+ const validates = editSpecs.find((s) => s.kind === "edit-validates-required");
137
+ if (validates?.kind !== "edit-validates-required") throw new Error("unreachable");
138
+ expect(validates.requiredFields).toEqual(["title"]);
139
+
140
+ const persists = editSpecs.find((s) => s.kind === "edit-save-persists");
141
+ if (persists?.kind !== "edit-save-persists") throw new Error("unreachable");
142
+ expect(persists.fills).toEqual([
143
+ { kind: "fill", field: "title", value: "e2e title" },
144
+ { kind: "select", field: "status", value: "todo" },
145
+ { kind: "check", field: "done", value: true },
146
+ ]);
147
+ expect(persists.identifyingField).toBe("title");
148
+ });
149
+
100
150
  test("date emittiert fill, timestamp wird übersprungen (zwei Inputs seit #369)", () => {
101
151
  const entity = createEntity({
102
152
  table: "events",
@@ -36,6 +36,7 @@ import {
36
36
  parseQn,
37
37
  qn,
38
38
  type Registry,
39
+ sectionFieldSpecs,
39
40
  toKebab,
40
41
  } from "../engine";
41
42
 
@@ -249,7 +250,7 @@ function collectRequiredEditFields(
249
250
  const out: string[] = [];
250
251
  for (const section of screen.layout.sections) {
251
252
  if (!isFieldsEditSection(section)) continue;
252
- for (const rawField of section.fields) {
253
+ for (const rawField of sectionFieldSpecs(section)) {
253
254
  const { field } = normalizeEditField(rawField);
254
255
  const def = entity.fields[field];
255
256
  if (def && "required" in def && def.required === true) {
@@ -287,7 +288,7 @@ function buildEditFillOps(
287
288
  const ops: EditFillOp[] = [];
288
289
  for (const section of screen.layout.sections) {
289
290
  if (!isFieldsEditSection(section)) continue;
290
- for (const raw of section.fields) {
291
+ for (const raw of sectionFieldSpecs(section)) {
291
292
  const { field } = normalizeEditField(raw);
292
293
  const def = entity.fields[field];
293
294
  if (!def) continue;
@@ -335,7 +336,7 @@ function pickIdentifyingForEdit(
335
336
 
336
337
  for (const section of editScreen.layout.sections) {
337
338
  if (!isFieldsEditSection(section)) continue;
338
- for (const rawField of section.fields) {
339
+ for (const rawField of sectionFieldSpecs(section)) {
339
340
  const { field } = normalizeEditField(rawField);
340
341
  if (entity.fields[field]?.type !== "text") continue;
341
342
  if (columns && !columns.has(field)) continue;
@@ -25,6 +25,7 @@ export type { DerivedCellRoundingTarget } from "../engine/embedded-derived";
25
25
  export { computeDerivedCellValue, roundDerivedCellValue } from "../engine/embedded-derived";
26
26
  export type { ParsedRefTarget } from "../engine/parse-ref-target";
27
27
  export { parseRefTarget } from "../engine/parse-ref-target";
28
+ export type { FieldsOrGroupsSection } from "../engine/screen-helpers";
28
29
  export {
29
30
  evalFieldCondition,
30
31
  explicitListScreenId,
@@ -35,6 +36,7 @@ export {
35
36
  normalizeEditField,
36
37
  normalizeListColumn,
37
38
  resolveNavParentScreen,
39
+ sectionFieldSpecs,
38
40
  } from "../engine/screen-helpers";
39
41
  // Entity + field types. EntityDefinition is the canonical shape that
40
42
  // view-model builders iterate; FieldDefinition is the per-field union