@cosmicdrift/kumiko-samples 0.338.0 → 0.339.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 (138) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +21 -1
  3. package/packages/bundled-features/src/auth-email-password/changes.json +7 -0
  4. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -6
  5. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +2 -6
  6. package/packages/bundled-features/src/auth-email-password/handlers/invite-info.query.ts +3 -5
  7. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -8
  8. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
  9. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -1
  10. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +35 -3
  11. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +60 -0
  12. package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +10 -0
  13. package/packages/bundled-features/src/auth-email-password/web/index.ts +8 -0
  14. package/packages/bundled-features/src/auth-email-password/web/session.tsx +15 -2
  15. package/packages/bundled-features/src/auth-mfa/feature.ts +1 -1
  16. package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -7
  17. package/packages/bundled-features/src/auth-mfa/handlers/enable-start-preauth.write.ts +1 -5
  18. package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -7
  19. package/packages/bundled-features/src/auth-mfa/mfa-code-verifier.ts +1 -5
  20. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +1 -7
  21. package/packages/bundled-features/src/billing-foundation/changes.json +34 -0
  22. package/packages/bundled-features/src/billing-foundation/constants.ts +11 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +3 -3
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +11 -3
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-text.ts +2 -2
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +48 -2
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +10 -3
  28. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +44 -0
  29. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +32 -2
  30. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +129 -33
  31. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +13 -59
  32. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +20 -21
  33. package/packages/bundled-features/src/billing-foundation/events.ts +18 -0
  34. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +4 -5
  35. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +6 -5
  36. package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
  37. package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +4 -4
  38. package/packages/bundled-features/src/billing-foundation/types.ts +6 -0
  39. package/packages/bundled-features/src/billing-foundation/validate-options.ts +6 -0
  40. package/packages/bundled-features/src/channel-discord/discord-channel.ts +32 -0
  41. package/packages/bundled-features/src/channel-discord/feature.ts +40 -0
  42. package/packages/bundled-features/src/channel-discord/index.ts +2 -0
  43. package/packages/bundled-features/src/channel-slack/feature.ts +38 -0
  44. package/packages/bundled-features/src/channel-slack/index.ts +2 -0
  45. package/packages/bundled-features/src/channel-slack/slack-channel.ts +29 -0
  46. package/packages/bundled-features/src/channel-teams/feature.ts +42 -0
  47. package/packages/bundled-features/src/channel-teams/index.ts +2 -0
  48. package/packages/bundled-features/src/channel-teams/teams-channel.ts +50 -0
  49. package/packages/bundled-features/src/channel-telegram/feature.ts +35 -0
  50. package/packages/bundled-features/src/channel-telegram/index.ts +2 -0
  51. package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +74 -0
  52. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +6 -18
  53. package/packages/bundled-features/src/custom-fields/handlers/clear-custom-field.write.ts +1 -1
  54. package/packages/bundled-features/src/custom-fields/handlers/define-tenant-field.write.ts +1 -4
  55. package/packages/bundled-features/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
  56. package/packages/bundled-features/src/custom-fields/wire-user-data-rights.ts +1 -1
  57. package/packages/bundled-features/src/data-retention/feature.ts +2 -3
  58. package/packages/bundled-features/src/delivery/changes.json +7 -0
  59. package/packages/bundled-features/src/delivery/channel-context.ts +3 -1
  60. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +76 -0
  61. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +135 -0
  62. package/packages/bundled-features/src/delivery/delivery-service.ts +3 -0
  63. package/packages/bundled-features/src/delivery/index.ts +15 -0
  64. package/packages/bundled-features/src/delivery/jobs.ts +1 -1
  65. package/packages/bundled-features/src/delivery/testing.ts +2 -0
  66. package/packages/bundled-features/src/delivery/types.ts +8 -3
  67. package/packages/bundled-features/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +1 -1
  68. package/packages/bundled-features/src/files-tenant-data/index.ts +1 -1
  69. package/packages/bundled-features/src/form-draft/feature.ts +1 -1
  70. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  71. package/packages/bundled-features/src/form-draft/handlers/save.write.ts +1 -5
  72. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
  73. package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +29 -13
  74. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
  75. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +1 -1
  76. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +7 -0
  77. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +201 -0
  78. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +1 -1
  79. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +17 -1
  80. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +74 -36
  81. package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +1 -1
  82. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +7 -10
  83. package/packages/bundled-features/src/renderer-simple/changes.json +9 -1
  84. package/packages/bundled-features/src/renderer-simple/feature.ts +14 -4
  85. package/packages/bundled-features/src/renderer-simple/index.ts +2 -2
  86. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +94 -31
  87. package/packages/bundled-features/src/secrets/feature.ts +1 -1
  88. package/packages/bundled-features/src/sessions/feature.ts +1 -1
  89. package/packages/bundled-features/src/sessions/handlers/revoke-all-for-user.write.ts +1 -1
  90. package/packages/bundled-features/src/tenant-handover/handlers/claim.write.ts +1 -4
  91. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -3
  92. package/packages/bundled-features/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +3 -5
  93. package/packages/bundled-features/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
  94. package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
  95. package/packages/bundled-features/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -1
  96. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -1
  97. package/packages/bundled-features/src/user-data-rights/feature.ts +4 -6
  98. package/packages/bundled-features/src/user-data-rights/handlers/cancel-deletion.write.ts +1 -1
  99. package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +1 -3
  100. package/packages/bundled-features/src/user-data-rights/handlers/download-by-job.query.ts +1 -1
  101. package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  102. package/packages/bundled-features/src/user-data-rights/handlers/export-status.query.ts +1 -1
  103. package/packages/bundled-features/src/user-data-rights/handlers/lift-restriction.write.ts +2 -6
  104. package/packages/bundled-features/src/user-data-rights/handlers/my-audit-log.query.ts +1 -1
  105. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion-by-email.write.ts +1 -1
  106. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -1
  107. package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +1 -1
  108. package/packages/bundled-features/src/user-data-rights/handlers/restrict-account.write.ts +2 -8
  109. package/packages/bundled-features/src/user-data-rights/handlers/run-forget-cleanup.write.ts +1 -1
  110. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +1 -1
  111. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +1 -1
  112. package/packages/bundled-features/src/user-data-rights/run-user-export.ts +1 -1
  113. package/packages/bundled-features/src/workflow-runner/changes.json +7 -0
  114. package/packages/bundled-features/src/workflow-runner/event-subscriber.ts +7 -2
  115. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +5 -0
  116. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +231 -77
  117. package/packages/bundled-features/src/workflow-runner/pending-projection.ts +1 -0
  118. package/packages/bundled-features/src/workflow-runner/runner.ts +12 -2
  119. package/packages/bundled-features/src/workflow-runner/tables.ts +8 -1
  120. package/samples/apps/styleguide/src/features/widgets/i18n.ts +5 -0
  121. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +11 -0
  122. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +16 -0
  123. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +152 -0
  124. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +82 -0
  125. package/samples/apps/use-all-bundled/feature-manifest.json +146 -1
  126. package/samples/apps/use-all-bundled/package.json +1 -0
  127. package/samples/apps/use-all-bundled/playwright.billing-gallery.config.ts +14 -0
  128. package/samples/apps/use-all-bundled/schema/generate.ts +20 -0
  129. package/samples/apps/use-all-bundled/scripts/gen-feature-manifest.ts +1 -0
  130. package/samples/apps/use-all-bundled/src/run-config.ts +10 -0
  131. package/samples/apps/use-all-bundled/tsconfig.json +1 -0
  132. package/samples/recipes/chat-channels/README.md +19 -0
  133. package/samples/recipes/chat-channels/package.json +9 -0
  134. package/samples/recipes/chat-channels/src/feature.ts +40 -0
  135. package/samples/recipes/public-share-token/src/feature.ts +1 -1
  136. package/samples/recipes/workflow-engine/src/event-trigger.ts +5 -0
  137. package/samples/recipes/workflow-engine/src/postgres-resume-loop.ts +50 -9
  138. package/samples/recipes/workflow-engine/src/workflow-runner.ts +12 -2
@@ -16,16 +16,19 @@
16
16
  // otherwise (wait already wrote its effect; resume past it).
17
17
  //
18
18
  // The run's ORIGINAL trigger event (the one that started the whole run) is
19
- // always recovered from the run's own WORKFLOW_RUN_STARTED_TYPE event,
20
- // never from the pending row — that event always carries it (see
21
- // WorkflowRunStartedPayload in ./runner). The pending row's own
22
- // triggerEventType/triggerPayload columns are a DIFFERENT thing: for a
23
- // waitForEvent suspension they hold the AWAITED event the Phase 3b
19
+ // always re-read from the event store via the triggerEventRef on the run's
20
+ // own WORKFLOW_RUN_STARTED_TYPE event, never from the pending row (see
21
+ // WorkflowRunStartedPayload in ./runner). Neither the run stream nor the
22
+ // pending row holds a copy of a foreign payload, so an erased source event
23
+ // shows up here in its shredded form, and an unloadable one (archived
24
+ // stream) fails the run. The pending row's own
25
+ // triggerEventType/triggerEventRef columns are a DIFFERENT thing: for a
26
+ // waitForEvent suspension they point at the AWAITED event the Phase 3b
24
27
  // event-subscriber matched (NULL until then, and NULL forever on a
25
- // timeout-without-a-match). That payload becomes the resumed pipeline's
26
- // result for the skipped waitForEvent step itself (see resultKey on
27
- // steps/wait-for-event.ts) — pre-seeded into stepsAcc below so a
28
- // subsequent step's resolver can read `ctx.steps[awaits.someKey]`.
28
+ // timeout-without-a-match). That event's payload becomes the resumed
29
+ // pipeline's result for the skipped waitForEvent step itself (see
30
+ // resultKey on steps/wait-for-event.ts) — pre-seeded into stepsAcc below so
31
+ // a subsequent step's resolver can read `ctx.steps[awaits.someKey]`.
29
32
 
30
33
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
31
34
  import {
@@ -53,9 +56,9 @@ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
53
56
  import * as z from "zod";
54
57
  import {
55
58
  isResumableSuspension,
59
+ type TriggerEventRef,
56
60
  type WorkflowRunCompletedPayload,
57
61
  type WorkflowRunFailedPayload,
58
- type WorkflowRunStartedPayload,
59
62
  WorkflowSuspensionUnsupportedError,
60
63
  } from "../runner.js";
61
64
  import { workflowRunPendingTable } from "../tables.js";
@@ -182,50 +185,218 @@ function isRunAlreadySettled(
182
185
  });
183
186
  }
184
187
 
185
- function recoverTriggerEvent(
186
- runEvents: Awaited<ReturnType<HandlerContext["loadAggregate"]>>,
188
+ type LoadedEvents = Awaited<ReturnType<HandlerContext["loadAggregate"]>>;
189
+
190
+ function isRecord(value: unknown): value is Record<string, unknown> {
191
+ return typeof value === "object" && value !== null;
192
+ }
193
+
194
+ function isTriggerEventRef(value: unknown): value is TriggerEventRef {
195
+ return (
196
+ isRecord(value) &&
197
+ typeof value["eventId"] === "string" &&
198
+ typeof value["aggregateId"] === "string" &&
199
+ typeof value["version"] === "number"
200
+ );
201
+ }
202
+
203
+ // run-started events written before the reference existed carry a copy of the
204
+ // trigger payload instead; they stay readable for runs still in flight.
205
+ type TriggerSource =
206
+ | { readonly kind: "ref"; readonly eventType: string; readonly ref: TriggerEventRef }
207
+ | { readonly kind: "legacy-copy"; readonly eventType: string; readonly payload: unknown };
208
+
209
+ function readTriggerSource(startedPayload: unknown): TriggerSource | null {
210
+ if (!isRecord(startedPayload) || typeof startedPayload["triggerEventType"] !== "string") {
211
+ return null;
212
+ }
213
+ const eventType = startedPayload["triggerEventType"];
214
+ const ref = startedPayload["triggerEventRef"];
215
+ if (isTriggerEventRef(ref)) {
216
+ return { kind: "ref", eventType, ref };
217
+ }
218
+ if ("triggerPayload" in startedPayload) {
219
+ return { kind: "legacy-copy", eventType, payload: startedPayload["triggerPayload"] };
220
+ }
221
+ return null;
222
+ }
223
+
224
+ async function loadReferencedEvent(ctx: HandlerContext, ref: TriggerEventRef) {
225
+ const events = await ctx.loadAggregate(ref.aggregateId);
226
+ return events.find((e) => e.id === ref.eventId);
227
+ }
228
+
229
+ type RecoveredTrigger =
230
+ | { readonly event: WriteEvent; readonly unavailable: false }
231
+ | { readonly event: null; readonly unavailable: true };
232
+
233
+ async function recoverTriggerEvent(
234
+ ctx: HandlerContext,
235
+ runEvents: LoadedEvents,
187
236
  runId: string,
188
237
  user: WriteEvent["user"],
189
- ): WriteEvent {
238
+ ): Promise<RecoveredTrigger> {
190
239
  const started = runEvents.find((e) => e.type === WORKFLOW_RUN_STARTED_TYPE);
191
240
  if (!started) {
192
241
  throw new InternalError({
193
242
  message: `workflow-runner:write:resume-run: run ${runId} has no ${WORKFLOW_RUN_STARTED_TYPE} event — cannot recover its trigger event.`,
194
243
  });
195
244
  }
196
- const startedPayload = started.payload as WorkflowRunStartedPayload; // @cast-boundary event-store-payload
197
- const triggerEvent: WriteEvent = {
198
- type: startedPayload.triggerEventType,
199
- payload: startedPayload.triggerPayload,
200
- user,
245
+ const source = readTriggerSource(started.payload);
246
+ if (!source) {
247
+ throw new InternalError({
248
+ message: `workflow-runner:write:resume-run: run ${runId} has a malformed ${WORKFLOW_RUN_STARTED_TYPE} event — neither triggerEventRef nor a legacy triggerPayload.`,
249
+ });
250
+ }
251
+ if (source.kind === "legacy-copy") {
252
+ return {
253
+ event: { type: source.eventType, payload: source.payload, user },
254
+ unavailable: false,
255
+ };
256
+ }
257
+ const triggerEvent = await loadReferencedEvent(ctx, source.ref);
258
+ if (!triggerEvent) {
259
+ return { event: null, unavailable: true };
260
+ }
261
+ return {
262
+ event: { type: source.eventType, payload: triggerEvent.payload, user },
263
+ unavailable: false,
201
264
  };
202
- return triggerEvent;
265
+ }
266
+
267
+ type PendingTriggerColumns = {
268
+ readonly triggerEventRef: unknown | null;
269
+ readonly triggerPayload: unknown | null;
270
+ };
271
+
272
+ type AwaitedPayload =
273
+ | { readonly unavailable: false; readonly payload: unknown }
274
+ | { readonly unavailable: true };
275
+
276
+ // NULL ref + NULL payload = timeout without a match: the step result stays
277
+ // undefined, same as before. A matched row whose event can no longer be loaded
278
+ // is NOT folded into that case: a later step could not tell "nothing arrived"
279
+ // from "arrived but gone" and would run on missing data, so the run fails
280
+ // instead. Rows matched before the reference existed still carry the copy.
281
+ async function loadAwaitedPayload(
282
+ ctx: HandlerContext,
283
+ pending: PendingTriggerColumns,
284
+ ): Promise<AwaitedPayload> {
285
+ if (!isTriggerEventRef(pending.triggerEventRef)) {
286
+ return { unavailable: false, payload: pending.triggerPayload ?? undefined };
287
+ }
288
+ const awaited = await loadReferencedEvent(ctx, pending.triggerEventRef);
289
+ return awaited ? { unavailable: false, payload: awaited.payload } : { unavailable: true };
203
290
  }
204
291
 
205
292
  // The suspended waitForEvent step itself is skipped on resume
206
293
  // (resumeFrom = stepIndex + 1) — its run() never re-executes, so its
207
294
  // resultKey (args.event, see steps/wait-for-event.ts) never gets
208
- // populated by the normal runStepList loop. Seed it here from the
209
- // pending row's own triggerPayload so a subsequent step's resolver
210
- // sees the matched event via `ctx.steps[awaits.someKey]`, same as any
211
- // other step result. NULL on a timeout-without-a-match — a later
212
- // resolver reading it just sees `undefined`, no special-casing needed.
295
+ // populated by the normal runStepList loop. Seed it here from the awaited
296
+ // event's payload so a subsequent step's resolver sees the matched event via
297
+ // `ctx.steps[awaits.someKey]`, same as any other step result.
213
298
  function seedResumedStepResults(
214
- pending: { suspensionEventType: string; triggerPayload: unknown | null },
299
+ suspensionEventType: string,
300
+ awaitedPayload: unknown,
215
301
  steps: readonly StepInstance[],
216
302
  stepIndex: number,
217
303
  ): Record<string, unknown> {
218
304
  const stepsAcc: Record<string, unknown> = {};
219
- if (pending.suspensionEventType === WORKFLOW_WAITING_FOR_EVENT_TYPE) {
305
+ if (suspensionEventType === WORKFLOW_WAITING_FOR_EVENT_TYPE) {
220
306
  const suspendedStep = steps[stepIndex];
221
307
  const key = suspendedStep && getStep(suspendedStep.kind)?.resultKey?.(suspendedStep.args);
222
308
  if (key !== undefined) {
223
- stepsAcc[key] = pending.triggerPayload;
309
+ stepsAcc[key] = awaitedPayload;
224
310
  }
225
311
  }
226
312
  return stepsAcc;
227
313
  }
228
314
 
315
+ type ResumedPipelineInput = {
316
+ readonly workflow: WorkflowDefinition;
317
+ readonly workflowName: string;
318
+ readonly runId: string;
319
+ readonly stepIndex: number;
320
+ readonly resumeFrom: number;
321
+ readonly triggerEvent: WriteEvent;
322
+ readonly awaitedPayload: unknown;
323
+ readonly pending: {
324
+ readonly suspensionEventType: string;
325
+ readonly retryAttempt: number | null;
326
+ readonly definitionFingerprint: string | null;
327
+ };
328
+ };
329
+
330
+ async function runResumedPipeline(ctx: HandlerContext, input: ResumedPipelineInput) {
331
+ const {
332
+ workflow,
333
+ workflowName,
334
+ runId,
335
+ stepIndex,
336
+ resumeFrom,
337
+ triggerEvent,
338
+ awaitedPayload,
339
+ pending,
340
+ } = input;
341
+ try {
342
+ const steps = buildPipelineSteps(workflow.pipelineDef, triggerEvent);
343
+ const workflowCtx = {
344
+ runId,
345
+ workflowName,
346
+ stepIndex,
347
+ definitionFingerprint: pending.definitionFingerprint ?? undefined,
348
+ ...(pending.retryAttempt !== null && { retryAttempt: pending.retryAttempt + 1 }),
349
+ };
350
+
351
+ const stepsAcc = seedResumedStepResults(
352
+ pending.suspensionEventType,
353
+ awaitedPayload,
354
+ steps,
355
+ stepIndex,
356
+ );
357
+
358
+ const outcome = await runStepList(
359
+ steps,
360
+ triggerEvent,
361
+ ctx,
362
+ stepsAcc,
363
+ {},
364
+ workflowCtx,
365
+ resumeFrom,
366
+ );
367
+
368
+ if (outcome.kind === "suspended") {
369
+ if (!isResumableSuspension(steps, outcome.stepIndex)) {
370
+ throw new WorkflowSuspensionUnsupportedError(workflowName, outcome.stepIndex);
371
+ }
372
+ // Another suspension further down the pipeline — pending-projection.ts
373
+ // already materialised the new row; nothing more to do this pass.
374
+ return { isSuccess: true as const, data: { outcome: "suspended" as const } };
375
+ }
376
+
377
+ const completedPayload: WorkflowRunCompletedPayload = {
378
+ workflowName,
379
+ stepIndex: steps.length,
380
+ };
381
+ await ctx.unsafeAppendEvent({
382
+ aggregateId: runId,
383
+ aggregateType: WORKFLOW_AGGREGATE_TYPE,
384
+ type: WORKFLOW_RUN_COMPLETED_TYPE,
385
+ payload: completedPayload,
386
+ });
387
+ return { isSuccess: true as const, data: { outcome: "completed" as const } };
388
+ } catch (error) {
389
+ log.warn("workflow run failed", { runId, workflowName, stepIndex, error: String(error) });
390
+ const failedPayload: WorkflowRunFailedPayload = {
391
+ workflowName,
392
+ stepIndex,
393
+ error: describeWorkflowStepError(error),
394
+ };
395
+ await appendRunFailed(ctx, runId, failedPayload);
396
+ return { isSuccess: true as const, data: { outcome: "failed" as const } };
397
+ }
398
+ }
399
+
229
400
  export const resumeRunHandler: WriteHandlerDef = {
230
401
  name: "resume-run",
231
402
  agent: { expose: false },
@@ -248,6 +419,7 @@ export const resumeRunHandler: WriteHandlerDef = {
248
419
  suspensionEventType: string;
249
420
  retryAttempt: number | null;
250
421
  definitionFingerprint: string | null;
422
+ triggerEventRef: unknown | null;
251
423
  triggerPayload: unknown | null;
252
424
  }>(db, workflowRunPendingTable, { runId, stepIndex, tenantId });
253
425
 
@@ -297,62 +469,44 @@ export const resumeRunHandler: WriteHandlerDef = {
297
469
  return { isSuccess: true, data: { outcome: "already-resumed" as const } };
298
470
  }
299
471
 
300
- const triggerEvent = recoverTriggerEvent(runEvents, runId, event.user);
301
-
302
- const resumeFrom =
303
- pending.suspensionEventType === WORKFLOW_RETRY_SCHEDULED_TYPE ? stepIndex : stepIndex + 1;
304
-
305
- try {
306
- const steps = buildPipelineSteps(workflow.pipelineDef, triggerEvent);
307
- const workflowCtx = {
308
- runId,
472
+ const recovered = await recoverTriggerEvent(ctx, runEvents, runId, event.user);
473
+ if (recovered.unavailable) {
474
+ await appendRunFailed(ctx, runId, {
309
475
  workflowName,
310
476
  stepIndex,
311
- definitionFingerprint: pending.definitionFingerprint ?? undefined,
312
- ...(pending.retryAttempt !== null && { retryAttempt: pending.retryAttempt + 1 }),
313
- };
314
-
315
- const stepsAcc = seedResumedStepResults(pending, steps, stepIndex);
316
-
317
- const outcome = await runStepList(
318
- steps,
319
- triggerEvent,
320
- ctx,
321
- stepsAcc,
322
- {},
323
- workflowCtx,
324
- resumeFrom,
325
- );
326
-
327
- if (outcome.kind === "suspended") {
328
- if (!isResumableSuspension(steps, outcome.stepIndex)) {
329
- throw new WorkflowSuspensionUnsupportedError(workflowName, outcome.stepIndex);
330
- }
331
- // Another suspension further down the pipeline — pending-projection.ts
332
- // already materialised the new row; nothing more to do this pass.
333
- return { isSuccess: true, data: { outcome: "suspended" as const } };
334
- }
335
-
336
- const completedPayload: WorkflowRunCompletedPayload = {
337
- workflowName,
338
- stepIndex: steps.length,
339
- };
340
- await ctx.unsafeAppendEvent({
341
- aggregateId: runId,
342
- aggregateType: WORKFLOW_AGGREGATE_TYPE,
343
- type: WORKFLOW_RUN_COMPLETED_TYPE,
344
- payload: completedPayload,
477
+ error: `Trigger event of run ${runId} is no longer available — cannot resume.`,
478
+ reason: "trigger_event_unavailable",
345
479
  });
346
- return { isSuccess: true, data: { outcome: "completed" as const } };
347
- } catch (error) {
348
- log.warn("workflow run failed", { runId, workflowName, stepIndex, error: String(error) });
349
- const failedPayload: WorkflowRunFailedPayload = {
480
+ return { isSuccess: true, data: { outcome: "failed" as const } };
481
+ }
482
+ const triggerEvent = recovered.event;
483
+
484
+ const awaited =
485
+ pending.suspensionEventType === WORKFLOW_WAITING_FOR_EVENT_TYPE
486
+ ? await loadAwaitedPayload(ctx, pending)
487
+ : ({ unavailable: false, payload: undefined } as const);
488
+ if (awaited.unavailable) {
489
+ await appendRunFailed(ctx, runId, {
350
490
  workflowName,
351
491
  stepIndex,
352
- error: describeWorkflowStepError(error),
353
- };
354
- await appendRunFailed(ctx, runId, failedPayload);
492
+ error: `Awaited event of run ${runId} is no longer available — cannot resume.`,
493
+ reason: "awaited_event_unavailable",
494
+ });
355
495
  return { isSuccess: true, data: { outcome: "failed" as const } };
356
496
  }
497
+
498
+ const resumeFrom =
499
+ pending.suspensionEventType === WORKFLOW_RETRY_SCHEDULED_TYPE ? stepIndex : stepIndex + 1;
500
+
501
+ return runResumedPipeline(ctx, {
502
+ workflow,
503
+ workflowName,
504
+ runId,
505
+ stepIndex,
506
+ resumeFrom,
507
+ triggerEvent,
508
+ awaitedPayload: awaited.payload,
509
+ pending,
510
+ });
357
511
  },
358
512
  };
@@ -84,6 +84,7 @@ async function upsertPending(tx: DbRunner, row: PendingRow): Promise<void> {
84
84
  // Phase 3's event-subscriber writes these when the awaited event
85
85
  // arrives — Phase 1 never touches them, insert or update.
86
86
  triggerEventType: null,
87
+ triggerEventRef: null,
87
88
  triggerPayload: null,
88
89
  },
89
90
  { conflictKeys: ["tenantId", "runId", "stepIndex"] },
@@ -35,10 +35,19 @@ export function isResumableSuspension(steps: readonly StepInstance[], stepIndex:
35
35
  return RESUMABLE_STEP_KINDS.has(steps[stepIndex]?.kind ?? "");
36
36
  }
37
37
 
38
+ // Points at the trigger event in the event store. The run stream never holds a
39
+ // copy of a foreign payload: erasing or archiving the source event must also
40
+ // cover the workflow, so a resume re-reads the event through this reference.
41
+ export type TriggerEventRef = {
42
+ readonly eventId: string;
43
+ readonly aggregateId: string;
44
+ readonly version: number;
45
+ };
46
+
38
47
  export type WorkflowRunStartedPayload = {
39
48
  readonly workflowName: string;
40
49
  readonly triggerEventType: string;
41
- readonly triggerPayload: unknown;
50
+ readonly triggerEventRef: TriggerEventRef;
42
51
  readonly definitionFingerprint: string;
43
52
  readonly idempotencyKey?: string;
44
53
  };
@@ -91,6 +100,7 @@ export async function startAndRunWorkflow(args: {
91
100
  readonly runId: string;
92
101
  readonly workflow: WorkflowDefinition;
93
102
  readonly triggerEvent: WriteEvent;
103
+ readonly triggerEventRef: TriggerEventRef;
94
104
  readonly idempotencyKey?: string;
95
105
  readonly handlerCtx: HandlerContext;
96
106
  }): Promise<{ readonly outcome: "completed" | "suspended" }> {
@@ -99,7 +109,7 @@ export async function startAndRunWorkflow(args: {
99
109
  const startedPayload: WorkflowRunStartedPayload = {
100
110
  workflowName: args.workflow.name,
101
111
  triggerEventType: args.triggerEvent.type,
102
- triggerPayload: args.triggerEvent.payload,
112
+ triggerEventRef: args.triggerEventRef,
103
113
  definitionFingerprint: fingerprint,
104
114
  ...(args.idempotencyKey && { idempotencyKey: args.idempotencyKey }),
105
115
  };
@@ -52,8 +52,14 @@ export const workflowRunPendingTable = pgTable(
52
52
  waitEventType: text("wait_event_type"),
53
53
  matchExpr: jsonb("match_expr"),
54
54
  // Written by the Phase 3 event-subscriber when the awaited event
55
- // arrives — always NULL in Phase 1/2.
55
+ // arrives — always NULL in Phase 1/2. The row points at the awaited event
56
+ // ({ eventId, aggregateId, version }); resume-run re-reads it from the
57
+ // event store, so erasing the event also covers the workflow.
56
58
  triggerEventType: text("trigger_event_type"),
59
+ triggerEventRef: jsonb("trigger_event_ref"),
60
+ // Legacy copy of the awaited payload. No longer written; only rows
61
+ // matched before the reference existed still carry it, and resume-run
62
+ // falls back to it for those.
57
63
  triggerPayload: jsonb("trigger_payload"),
58
64
  },
59
65
  (t) => [
@@ -82,6 +88,7 @@ export const workflowRunPendingTableMeta: EntityTableMeta = defineUnmanagedTable
82
88
  { name: "wait_event_type", pgType: "text", notNull: false },
83
89
  { name: "match_expr", pgType: "jsonb", notNull: false },
84
90
  { name: "trigger_event_type", pgType: "text", notNull: false },
91
+ { name: "trigger_event_ref", pgType: "jsonb", notNull: false },
85
92
  { name: "trigger_payload", pgType: "jsonb", notNull: false },
86
93
  ],
87
94
  indexes: [
@@ -55,6 +55,11 @@ export const WIDGETS_I18N = {
55
55
  de: "Antwortzeit-Verlauf",
56
56
  en: "Response time history",
57
57
  },
58
+ "widgets:catalog:response-time-p95": { de: "p95: 180 ms", en: "p95: 180 ms" },
59
+ "widgets:catalog:response-time-p95-aria": {
60
+ de: "p95-Schwelle der letzten 24 Stunden",
61
+ en: "p95 threshold over the last 24 hours",
62
+ },
58
63
  "widgets:catalog:24h-ago": { de: "vor 24h", en: "24h ago" },
59
64
  "widgets:catalog:12h-ago": { de: "vor 12h", en: "12h ago" },
60
65
  "widgets:catalog:now": { de: "jetzt", en: "now" },
@@ -120,6 +120,17 @@ export function Widgets(): ReactNode {
120
120
  end: t("widgets:catalog:now"),
121
121
  }}
122
122
  />
123
+ <div className="mt-4">
124
+ <TimeseriesChart
125
+ points={RESPONSE_TIMES}
126
+ windowStartMs={0}
127
+ windowEndMs={24 * 60 * 60 * 1000}
128
+ ariaLabel={t("widgets:catalog:response-time-p95-aria")}
129
+ referenceLines={[
130
+ { value: 180, label: t("widgets:catalog:response-time-p95"), tone: "warn" },
131
+ ]}
132
+ />
133
+ </div>
123
134
  </SectionCard>
124
135
 
125
136
  <SectionCard
@@ -0,0 +1,16 @@
1
+ // @runtime test
2
+ //
3
+ // Framework-free constants shared by the gallery server and spec (the spec
4
+ // runs in a Playwright worker and must not pull the server import chain).
5
+
6
+ export const GALLERY_PROVIDER = "gallery-mock-provider";
7
+ export const GALLERY_TERMS_SLUG = "gallery-terms";
8
+ export const GALLERY_OPERATOR_EMAIL = "billing@gallery.example";
9
+ export const GALLERY_PLAN_PATH = "/tenant-admin/billing-plans";
10
+
11
+ // "localhost" resolves the SYSTEM tenant like the stock dev app; the numeric
12
+ // loopback host resolves none and plays the platform apex.
13
+ export const GALLERY_TENANT_HOST = "localhost";
14
+ export const GALLERY_APEX_HOST = "127.0.0.1";
15
+
16
+ export const GALLERY_PRICE_IDS = { starter: "price_gallery_starter", pro: "price_gallery_pro" };
@@ -0,0 +1,152 @@
1
+ // @runtime test
2
+ //
3
+ // E2E-only wiring for the consent/termination review gallery: a mock billing
4
+ // provider (the only fake — real handlers, real dispatcher), the catalog and
5
+ // the nav entry that makes billing-foundation's dormant plans screen reachable.
6
+
7
+ import {
8
+ createBillingFoundationFeature,
9
+ type ProviderPrice,
10
+ type SubscriptionProviderPlugin,
11
+ SubscriptionStatuses,
12
+ subscriptionAggregateId,
13
+ subscriptionsProjectionTable,
14
+ } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
15
+ import {
16
+ createChannelEmailFeature,
17
+ createInMemoryTransport,
18
+ } from "@cosmicdrift/kumiko-bundled-features/channel-email";
19
+ import { simpleRenderer } from "@cosmicdrift/kumiko-bundled-features/renderer-simple";
20
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
21
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
22
+ import {
23
+ GALLERY_OPERATOR_EMAIL,
24
+ GALLERY_PLAN_PATH,
25
+ GALLERY_PRICE_IDS,
26
+ GALLERY_PROVIDER,
27
+ GALLERY_TERMS_SLUG,
28
+ } from "./gallery-constants";
29
+
30
+ export const galleryMailTransport = createInMemoryTransport();
31
+
32
+ const PRICES: readonly ProviderPrice[] = [
33
+ {
34
+ priceId: GALLERY_PRICE_IDS.starter,
35
+ unitAmount: 900,
36
+ currency: "eur",
37
+ interval: "month",
38
+ intervalCount: 1,
39
+ active: true,
40
+ metadata: {},
41
+ },
42
+ {
43
+ priceId: GALLERY_PRICE_IDS.pro,
44
+ unitAmount: 1900,
45
+ currency: "eur",
46
+ interval: "month",
47
+ intervalCount: 1,
48
+ active: true,
49
+ metadata: {},
50
+ },
51
+ ];
52
+
53
+ async function resolveCurrentTier(db: TenantDb, tenantId: string): Promise<string> {
54
+ const rows = await db.selectMany(
55
+ subscriptionsProjectionTable,
56
+ { id: subscriptionAggregateId(tenantId) },
57
+ { limit: 1 },
58
+ );
59
+ const row = rows[0];
60
+ if (!row) return "free";
61
+ const status = row["status"] as string;
62
+ return status === SubscriptionStatuses.active || status === SubscriptionStatuses.trialing
63
+ ? (row["tier"] as string)
64
+ : "free";
65
+ }
66
+
67
+ export function createGalleryBillingFeature(baseUrl: string): FeatureDefinition {
68
+ return createBillingFoundationFeature({
69
+ baseUrl,
70
+ catalog: {
71
+ plans: ["starter", "pro"],
72
+ tierLabelKey: (tier) => `gallery.plan.${tier}.label`,
73
+ benefits: (tier) => [{ labelKey: `gallery.plan.${tier}.benefit` }],
74
+ resolveCurrentTier,
75
+ viewRoles: ["TenantAdmin", "SystemAdmin"],
76
+ successPath: GALLERY_PLAN_PATH,
77
+ cancelPath: GALLERY_PLAN_PATH,
78
+ providerName: GALLERY_PROVIDER,
79
+ },
80
+ consumerProtection: {
81
+ termsTextBlock: GALLERY_TERMS_SLUG,
82
+ vatNote: { de: "Preise inkl. USt.", en: "Prices include VAT." },
83
+ operatorEmail: GALLERY_OPERATOR_EMAIL,
84
+ terminationScope: "platform",
85
+ legalLinks: {
86
+ terms: "/legal/terms",
87
+ withdrawal: "/legal/withdrawal",
88
+ privacy: "/legal/privacy",
89
+ },
90
+ },
91
+ });
92
+ }
93
+
94
+ export function createGalleryEmailFeature(): FeatureDefinition {
95
+ return createChannelEmailFeature({
96
+ transport: galleryMailTransport,
97
+ renderer: simpleRenderer,
98
+ resolveEmail: async () => "unused@gallery.example",
99
+ });
100
+ }
101
+
102
+ // The page URL carries the consent id so the spec can echo it on the webhook,
103
+ // exactly as a real provider echoes the checkout metadata.
104
+ const galleryProviderPlugin: SubscriptionProviderPlugin = {
105
+ verifyAndParseWebhook: async (rawBody) => JSON.parse(rawBody),
106
+ priceToTier: {
107
+ [GALLERY_PRICE_IDS.starter]: "starter",
108
+ [GALLERY_PRICE_IDS.pro]: "pro",
109
+ },
110
+ retrievePrices: async (_ctx, priceIds) => PRICES.filter((p) => priceIds.includes(p.priceId)),
111
+ createCheckoutSession: async (_ctx, options) => {
112
+ const url = new URL(options.successUrl);
113
+ if (options.consentId !== undefined) url.searchParams.set("consentId", options.consentId);
114
+ return { url: url.toString() };
115
+ },
116
+ cancelSubscription: async () => {},
117
+ };
118
+
119
+ export const galleryProviderFeature: FeatureDefinition = defineFeature(
120
+ "gallery-mock-provider",
121
+ (r) => {
122
+ r.describe("E2E-only mock subscription provider for the consent/termination gallery.");
123
+ r.requires("billing-foundation");
124
+ r.useExtension("subscriptionProvider", GALLERY_PROVIDER, galleryProviderPlugin);
125
+ },
126
+ );
127
+
128
+ export const galleryScreensFeature: FeatureDefinition = defineFeature("gallery-screens", (r) => {
129
+ r.describe("E2E-only nav entry for the billing plans screen plus plan labels.");
130
+ r.requires("billing-foundation", "admin-shell");
131
+ r.nav({
132
+ id: "billing",
133
+ label: "gallery:nav.billing",
134
+ icon: "file",
135
+ screen: "billing-foundation:screen:billing-plans",
136
+ order: 20,
137
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
138
+ workspaces: ["admin-shell:workspace:tenant-admin"],
139
+ });
140
+ r.translations({
141
+ keys: {
142
+ "gallery:nav.billing": { de: "Abrechnung", en: "Billing" },
143
+ "gallery.plan.free.label": { de: "Free", en: "Free" },
144
+ "gallery.plan.starter.label": { de: "Starter", en: "Starter" },
145
+ "gallery.plan.pro.label": { de: "Pro", en: "Pro" },
146
+ "gallery.plan.starter.benefit": { de: "Für kleine Teams", en: "For small teams" },
147
+ "gallery.plan.pro.benefit": { de: "Für wachsende Teams", en: "For growing teams" },
148
+ "gallery.plan.free.benefit": { de: "Zum Ausprobieren", en: "To try things out" },
149
+ },
150
+ });
151
+ return {};
152
+ });