@cosmicdrift/kumiko-samples 0.338.0 → 0.340.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 (147) 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 +41 -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/projection.ts +35 -41
  38. package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +4 -4
  39. package/packages/bundled-features/src/billing-foundation/types.ts +6 -0
  40. package/packages/bundled-features/src/billing-foundation/validate-options.ts +6 -0
  41. package/packages/bundled-features/src/channel-discord/discord-channel.ts +32 -0
  42. package/packages/bundled-features/src/channel-discord/feature.ts +40 -0
  43. package/packages/bundled-features/src/channel-discord/index.ts +2 -0
  44. package/packages/bundled-features/src/channel-slack/feature.ts +38 -0
  45. package/packages/bundled-features/src/channel-slack/index.ts +2 -0
  46. package/packages/bundled-features/src/channel-slack/slack-channel.ts +29 -0
  47. package/packages/bundled-features/src/channel-teams/feature.ts +42 -0
  48. package/packages/bundled-features/src/channel-teams/index.ts +2 -0
  49. package/packages/bundled-features/src/channel-teams/teams-channel.ts +50 -0
  50. package/packages/bundled-features/src/channel-telegram/feature.ts +35 -0
  51. package/packages/bundled-features/src/channel-telegram/index.ts +2 -0
  52. package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +74 -0
  53. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +6 -18
  54. package/packages/bundled-features/src/custom-fields/handlers/clear-custom-field.write.ts +1 -1
  55. package/packages/bundled-features/src/custom-fields/handlers/define-tenant-field.write.ts +1 -4
  56. package/packages/bundled-features/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
  57. package/packages/bundled-features/src/custom-fields/wire-user-data-rights.ts +1 -1
  58. package/packages/bundled-features/src/data-retention/feature.ts +2 -3
  59. package/packages/bundled-features/src/delivery/changes.json +7 -0
  60. package/packages/bundled-features/src/delivery/channel-context.ts +3 -1
  61. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +76 -0
  62. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +135 -0
  63. package/packages/bundled-features/src/delivery/delivery-service.ts +3 -0
  64. package/packages/bundled-features/src/delivery/index.ts +15 -0
  65. package/packages/bundled-features/src/delivery/jobs.ts +1 -1
  66. package/packages/bundled-features/src/delivery/testing.ts +2 -0
  67. package/packages/bundled-features/src/delivery/types.ts +8 -3
  68. package/packages/bundled-features/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +1 -1
  69. package/packages/bundled-features/src/files-tenant-data/index.ts +1 -1
  70. package/packages/bundled-features/src/form-draft/feature.ts +1 -1
  71. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  72. package/packages/bundled-features/src/form-draft/handlers/save.write.ts +1 -5
  73. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +14 -0
  74. package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +29 -13
  75. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
  76. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +1 -1
  77. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +7 -0
  78. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +201 -0
  79. package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +57 -58
  80. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +1 -1
  81. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +17 -1
  82. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +74 -36
  83. package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +1 -1
  84. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +7 -10
  85. package/packages/bundled-features/src/renderer-simple/changes.json +9 -1
  86. package/packages/bundled-features/src/renderer-simple/feature.ts +14 -4
  87. package/packages/bundled-features/src/renderer-simple/index.ts +2 -2
  88. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +94 -31
  89. package/packages/bundled-features/src/secrets/feature.ts +1 -1
  90. package/packages/bundled-features/src/sessions/feature.ts +1 -1
  91. package/packages/bundled-features/src/sessions/handlers/revoke-all-for-user.write.ts +1 -1
  92. package/packages/bundled-features/src/tenant-handover/handlers/claim.write.ts +1 -4
  93. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -3
  94. package/packages/bundled-features/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +3 -5
  95. package/packages/bundled-features/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
  96. package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
  97. package/packages/bundled-features/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -1
  98. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -1
  99. package/packages/bundled-features/src/user/changes.json +13 -6
  100. package/packages/bundled-features/src/user/handlers/update-roles.ts +7 -23
  101. package/packages/bundled-features/src/user-data-rights/feature.ts +4 -6
  102. package/packages/bundled-features/src/user-data-rights/handlers/cancel-deletion.write.ts +1 -1
  103. package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +1 -3
  104. package/packages/bundled-features/src/user-data-rights/handlers/download-by-job.query.ts +1 -1
  105. package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  106. package/packages/bundled-features/src/user-data-rights/handlers/export-status.query.ts +1 -1
  107. package/packages/bundled-features/src/user-data-rights/handlers/lift-restriction.write.ts +2 -6
  108. package/packages/bundled-features/src/user-data-rights/handlers/my-audit-log.query.ts +1 -1
  109. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion-by-email.write.ts +1 -1
  110. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -1
  111. package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +1 -1
  112. package/packages/bundled-features/src/user-data-rights/handlers/restrict-account.write.ts +2 -8
  113. package/packages/bundled-features/src/user-data-rights/handlers/run-forget-cleanup.write.ts +1 -1
  114. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +1 -1
  115. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +1 -1
  116. package/packages/bundled-features/src/user-data-rights/run-user-export.ts +1 -1
  117. package/packages/bundled-features/src/workflow-runner/changes.json +14 -0
  118. package/packages/bundled-features/src/workflow-runner/db/queries/due-runs.ts +10 -13
  119. package/packages/bundled-features/src/workflow-runner/event-subscriber.ts +7 -2
  120. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +5 -0
  121. package/packages/bundled-features/src/workflow-runner/feature.ts +4 -11
  122. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +231 -77
  123. package/packages/bundled-features/src/workflow-runner/pending-projection.ts +1 -0
  124. package/packages/bundled-features/src/workflow-runner/runner.ts +12 -2
  125. package/packages/bundled-features/src/workflow-runner/tables.ts +8 -1
  126. package/samples/apps/styleguide/src/features/widgets/i18n.ts +5 -0
  127. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +11 -0
  128. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +16 -0
  129. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +152 -0
  130. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +82 -0
  131. package/samples/apps/use-all-bundled/feature-manifest.json +146 -1
  132. package/samples/apps/use-all-bundled/package.json +1 -0
  133. package/samples/apps/use-all-bundled/playwright.billing-gallery.config.ts +14 -0
  134. package/samples/apps/use-all-bundled/schema/generate.ts +20 -0
  135. package/samples/apps/use-all-bundled/scripts/gen-feature-manifest.ts +1 -0
  136. package/samples/apps/use-all-bundled/src/run-config.ts +10 -0
  137. package/samples/apps/use-all-bundled/tsconfig.json +1 -0
  138. package/samples/recipes/chat-channels/README.md +19 -0
  139. package/samples/recipes/chat-channels/package.json +9 -0
  140. package/samples/recipes/chat-channels/src/feature.ts +40 -0
  141. package/samples/recipes/public-share-token/src/feature.ts +1 -1
  142. package/samples/recipes/workflow-engine/src/event-trigger.ts +5 -0
  143. package/samples/recipes/workflow-engine/src/postgres-resume-loop.ts +50 -9
  144. package/samples/recipes/workflow-engine/src/workflow-runner.ts +12 -2
  145. package/packages/bundled-features/src/billing-foundation/db/queries/payment-projection.ts +0 -19
  146. package/packages/bundled-features/src/billing-foundation/db/queries/subscription-projection.ts +0 -15
  147. package/packages/bundled-features/src/inbound-mail-foundation/db/queries/inbound-projections.ts +0 -38
@@ -29,7 +29,7 @@ export type UpdateUserLifecycleOptions = {
29
29
  readonly expect?: Readonly<Record<string, string | number | boolean | null>>;
30
30
  };
31
31
 
32
- // `conn` is ctx.db.unsafeRaw(reason) (regular handlers) or the open tx
32
+ // `conn` is ctx.db.unsafeRaw() (regular handlers) or the open tx
33
33
  // (forget-cleanup sub-tx) — keeps the event append atomic with the write.
34
34
  //
35
35
  // Returns `{ applied: false }` instead of throwing only when `options.expect`
@@ -155,7 +155,7 @@ export interface RunForgetCleanupArgs {
155
155
 
156
156
  // fw#2914 — sourced from the owning job's ctx (_escapeHatchAuditSink,
157
157
  // systemUser.id); attributes+audits any EXT_USER_DATA usage's declared
158
- // escapeHatch when its delete hook calls ctx.db.unsafeRaw(reason).
158
+ // escapeHatch when its delete hook calls ctx.db.unsafeRaw().
159
159
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
160
160
  readonly actor?: string;
161
161
  }
@@ -60,7 +60,7 @@ export interface RunUserExportArgs {
60
60
  readonly now: Instant;
61
61
  // fw#2914 — sourced from the owning job's ctx (_escapeHatchAuditSink,
62
62
  // systemUser.id); attributes+audits any EXT_USER_DATA usage's declared
63
- // escapeHatch when its export hook calls ctx.db.unsafeRaw(reason).
63
+ // escapeHatch when its export hook calls ctx.db.unsafeRaw().
64
64
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
65
65
  readonly actor?: string;
66
66
  }
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "version": "0.340.0",
4
+ "type": "improvement",
5
+ "title": "The resume job reads due runs through the job's TenantDb",
6
+ "detail": "`selectDueWorkflowRunPending` takes a `TenantDb` and returns `{ runId, stepIndex }` rows, so the tenant filter comes from the TenantDb instead of a hand-written `WHERE tenant_id`.",
7
+ "migration": "keine"
8
+ },
9
+ {
10
+ "version": "0.339.0",
11
+ "type": "breaking",
12
+ "title": "workflow-run stream stores a reference to the trigger event instead of its payload",
13
+ "detail": "`workflow.run-started` now holds `triggerEventRef: { eventId, aggregateId, version }` and no `triggerPayload`; the wait, waitForEvent and retry step events no longer embed it either. `workflow_run_pending` gets a `trigger_event_ref` column and the event-subscriber stops writing `trigger_payload`. `resume-run` loads the trigger and the awaited event through `ctx.loadAggregate` and fails the run with `trigger_event_unavailable` or `awaited_event_unavailable` when one is gone.",
14
+ "migration": "Own code that reads `triggerPayload` from `workflow.run-started` must read `triggerEventRef` and load the event with `ctx.loadAggregate(ref.aggregateId)`. Apps generate the migration for the new `workflow_run_pending.trigger_event_ref` column with `kumiko schema generate`. The step events `workflow.step.waiting`, `workflow.step.waiting-for-event` and `workflow.retry.scheduled` no longer carry `triggerPayload`; a custom resume loop that read it must load the trigger event through the `triggerEventRef` on its own run-started event (see `samples/recipes/workflow-engine/src/postgres-resume-loop.ts`). Stored run-started events and pending rows keep their old copy; this release rewrites no events, and runs started before it resume from that copy."
15
+ },
2
16
  {
3
17
  "version": "0.337.0",
4
18
  "type": "fix",
@@ -1,8 +1,7 @@
1
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
- import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { sql, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import { workflowRunPendingTable } from "../../tables.js";
4
3
 
5
- export type DueRow = { readonly run_id: string; readonly step_index: number };
4
+ export type DueRow = { readonly runId: string; readonly stepIndex: number };
6
5
 
7
6
  // Oldest first and capped, so a backlog after an outage drains over several
8
7
  // ticks instead of holding one tick's `concurrency: "skip"` slot for minutes.
@@ -12,13 +11,11 @@ export const DUE_RUNS_BATCH_SIZE = 100;
12
11
  // (ctx.tryAppendEvent on WORKFLOW_RESUMED) — a second dispatch for the same
13
12
  // row loses that race and no-ops. No row locks: in an autocommit statement
14
13
  // they would be released right after the SELECT and guard nothing.
15
- export async function selectDueWorkflowRunPending(
16
- db: DbConnection,
17
- tenantId: TenantId,
18
- ): Promise<readonly DueRow[]> {
19
- // kumiko-lint-ignore raw-sql tenant-scoped due-row pickup
20
- return (await asRawClient(db).unsafe(
21
- `SELECT run_id, step_index FROM workflow_run_pending WHERE tenant_id = $1 AND wake_at < now() ORDER BY wake_at ASC LIMIT ${DUE_RUNS_BATCH_SIZE}`,
22
- [tenantId],
23
- )) as readonly DueRow[];
14
+ // `now()` stays the DB clock so job-host clock skew cannot mark rows due early.
15
+ export function selectDueWorkflowRunPending(db: TenantDb): Promise<readonly DueRow[]> {
16
+ return db.selectMany<DueRow>(
17
+ workflowRunPendingTable,
18
+ { wakeAt: { lt: sql`now()` } },
19
+ { orderBy: { col: "wakeAt", direction: "asc" }, limit: DUE_RUNS_BATCH_SIZE },
20
+ );
24
21
  }
@@ -20,7 +20,7 @@
20
20
  // This subscriber never resumes a run itself (D3, workflow-resume-loop.md):
21
21
  // the MultiStreamApplyContext it runs in has no callFeature/runStepList
22
22
  // access, only unsafeAppendEvent/loadAggregate. It writes triggerEventType
23
- // + triggerPayload + wakeAt=now() onto the matching pending row(s); the
23
+ // + triggerEventRef + wakeAt=now() onto the matching pending row(s); the
24
24
  // existing resume-due-runs job (Phase 2) picks the row up on its next
25
25
  // cron tick — the one resume path stays exactly the one Phase 2 built.
26
26
 
@@ -34,6 +34,7 @@ import type {
34
34
  import { evaluateEventMatch } from "@cosmicdrift/kumiko-framework/engine";
35
35
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
36
36
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
37
+ import type { TriggerEventRef } from "./runner.js";
37
38
  import { workflowRunPendingTable } from "./tables.js";
38
39
 
39
40
  type CandidateRow = {
@@ -87,7 +88,11 @@ const wakeupApply: MultiStreamApplyFn = async (event, tx) => {
87
88
  workflowRunPendingTable,
88
89
  {
89
90
  triggerEventType: event.type,
90
- triggerPayload: event.payload,
91
+ triggerEventRef: {
92
+ eventId: event.id,
93
+ aggregateId: event.aggregateId,
94
+ version: event.version,
95
+ } satisfies TriggerEventRef,
91
96
  wakeAt: getTemporal().Now.instant().toString(),
92
97
  },
93
98
  { tenantId: row.tenantId, runId: row.runId, stepIndex: row.stepIndex },
@@ -85,6 +85,11 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
85
85
  runId,
86
86
  workflow,
87
87
  triggerEvent,
88
+ triggerEventRef: {
89
+ eventId: event.id,
90
+ aggregateId: event.aggregateId,
91
+ version: event.version,
92
+ },
88
93
  ...(idempotencyKey && { idempotencyKey }),
89
94
  // @cast-boundary msp-to-handler-ctx — MultiStreamApplyContext only
90
95
  // exposes unsafeAppendEvent/loadAggregate, a subset of
@@ -24,7 +24,6 @@
24
24
  //
25
25
  // wait/retry/waitForEvent suspensions all resume automatically now.
26
26
 
27
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
28
27
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
29
28
  import { selectDueWorkflowRunPending } from "./db/queries/due-runs.js";
30
29
  import { resumeRunHandler } from "./handlers/resume-run.write.js";
@@ -68,26 +67,20 @@ export const workflowRunnerFeature = defineFeature(FEATURE_NAME, (r) => {
68
67
  return;
69
68
  }
70
69
 
71
- ctx.systemDb.assertTenantMatch(tenantId);
72
- const dueRows = await selectDueWorkflowRunPending(
73
- ctx.systemDb.unsafeRaw(
74
- "due-row pickup needs FOR UPDATE SKIP LOCKED raw SQL; the query filters by this job's tenantId",
75
- ) as DbConnection, // @cast-boundary db-operator — DbRunner narrows to DbConnection, jobs never run inside a DbTx
76
- tenantId,
77
- );
70
+ const dueRows = await selectDueWorkflowRunPending(ctx.systemDb.assertTenantMatch(tenantId));
78
71
 
79
72
  for (const row of dueRows) {
80
73
  try {
81
74
  await ctx.write("workflow-runner:write:resume-run", {
82
- runId: row.run_id,
83
- stepIndex: row.step_index,
75
+ runId: row.runId,
76
+ stepIndex: row.stepIndex,
84
77
  });
85
78
  } catch (error) {
86
79
  // skip: one row's dispatch failing (infra hiccup, not a business
87
80
  // outcome — resume-run itself never throws for expected cases)
88
81
  // must not stop the rest of this tenant's due rows from resuming.
89
82
  ctx.log.warn(
90
- `[workflow-runner:resume-due-runs] runId=${row.run_id} stepIndex=${row.step_index} dispatch failed: ${String(error)}`,
83
+ `[workflow-runner:resume-due-runs] runId=${row.runId} stepIndex=${row.stepIndex} dispatch failed: ${String(error)}`,
91
84
  );
92
85
  }
93
86
  }
@@ -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