@cosmicdrift/kumiko-framework 0.230.0 → 0.232.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.
@@ -1,5 +1,6 @@
1
1
  import { beforeEach, describe, expect, it, mock } from "bun:test";
2
2
  import { getStep } from "../define-step";
3
+ import { evaluateEventMatch } from "../steps/_event-match";
3
4
  import {
4
5
  SUSPEND_SENTINEL,
5
6
  WORKFLOW_AGGREGATE_TYPE,
@@ -9,7 +10,12 @@ import {
9
10
  import { buildRetryStep, calculateBackoff } from "../steps/retry";
10
11
  import { buildWaitStep } from "../steps/wait";
11
12
  import { buildWaitForEventStep } from "../steps/wait-for-event";
12
- import type { PipelineCtx } from "../types/step";
13
+ import type { AwaitedEventType, EventMatch, PipelineCtx } from "../types/step";
14
+
15
+ // Test-only helper for the low-level step-builder unit tests below, which
16
+ // call buildWaitForEventStep directly instead of going through a
17
+ // defineWorkflow `awaits` declaration (the only legitimate branding route).
18
+ const asAwaited = (event: string): AwaitedEventType => event as AwaitedEventType;
13
19
 
14
20
  const mockUnsafeAppendEvent = mock();
15
21
 
@@ -90,20 +96,31 @@ describe("workflow.wait run", () => {
90
96
  describe("buildWaitForEventStep", () => {
91
97
  it("returns a StepInstance with kind workflow.waitForEvent", () => {
92
98
  const step = buildWaitForEventStep({
93
- event: "user.confirmed-email",
99
+ event: asAwaited("user.confirmed-email"),
94
100
  timeout: "P7D",
95
101
  });
96
102
  expect(step.kind).toBe("workflow.waitForEvent");
97
103
  });
98
104
 
99
- it("accepts an optional match resolver", () => {
105
+ it("accepts an optional match AST", () => {
106
+ const match: EventMatch = {
107
+ version: 1,
108
+ expr: { kind: "atom", path: ["email"], op: { kind: "eq", value: "test@test.com" } },
109
+ };
100
110
  const step = buildWaitForEventStep({
101
- event: "user.confirmed-email",
102
- match: (payload: unknown) => (payload as { email: string }).email === "test@test.com",
111
+ event: asAwaited("user.confirmed-email"),
112
+ match,
103
113
  timeout: "P7D",
104
114
  });
105
115
  expect(step.kind).toBe("workflow.waitForEvent");
106
116
  });
117
+
118
+ it("@ts-expect-error: event must come from an awaits declaration, not a raw string", () => {
119
+ // @ts-expect-error — a raw string is not an AwaitedEventType; only
120
+ // defineWorkflow's `awaits` map can produce one (D4, workflow-resume-loop.md).
121
+ const step = buildWaitForEventStep({ event: "user.confirmed-email", timeout: "P7D" });
122
+ expect(step.kind).toBe("workflow.waitForEvent");
123
+ });
107
124
  });
108
125
 
109
126
  describe("workflow.waitForEvent run", () => {
@@ -138,6 +155,144 @@ describe("workflow.waitForEvent run", () => {
138
155
  expect(typeof eventArg.payload.timeoutAt).toBe("string");
139
156
  expect(eventArg.payload.workflowName).toBe("test-workflow");
140
157
  });
158
+
159
+ it("omits the match key from the payload when no match is given", async () => {
160
+ const stepDef = getStep("workflow.waitForEvent");
161
+ expect(stepDef).toBeDefined();
162
+
163
+ await stepDef!.run({ event: "user.confirmed-email", timeout: "P7D" }, workflowCtx);
164
+
165
+ const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
166
+ expect("match" in eventArg.payload).toBe(false);
167
+ });
168
+
169
+ it("resolves a match resolver to a serializable AST that round-trips through JSON and evaluates against real payloads", async () => {
170
+ const stepDef = getStep("workflow.waitForEvent");
171
+ expect(stepDef).toBeDefined();
172
+
173
+ const result = await stepDef!.run(
174
+ {
175
+ event: "user.confirmed-email",
176
+ match: (ctx: PipelineCtx): EventMatch => ({
177
+ version: 1,
178
+ expr: {
179
+ kind: "atom",
180
+ path: ["email"],
181
+ op: { kind: "eq", value: (ctx.event.payload as { email: string }).email },
182
+ },
183
+ }),
184
+ timeout: "P7D",
185
+ },
186
+ workflowCtx,
187
+ );
188
+
189
+ expect(result).toBe(SUSPEND_SENTINEL);
190
+ const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
191
+ const persistedMatch = eventArg.payload.match;
192
+
193
+ // Proves the persisted value is the resolved literal AST, not the resolver function.
194
+ expect(persistedMatch).toEqual({
195
+ version: 1,
196
+ expr: {
197
+ kind: "atom",
198
+ path: ["email"],
199
+ op: { kind: "eq", value: "test@example.com" },
200
+ },
201
+ });
202
+
203
+ // Proves serializability — the whole point of the AST switch — by round-tripping
204
+ // through JSON before feeding it to the evaluator, same as a rehydrated event would.
205
+ const roundTripped = JSON.parse(JSON.stringify(persistedMatch));
206
+ expect(evaluateEventMatch(roundTripped, { email: "test@example.com" })).toBe(true);
207
+ expect(evaluateEventMatch(roundTripped, { email: "someone-else@example.com" })).toBe(false);
208
+ });
209
+ });
210
+
211
+ describe("evaluateEventMatch", () => {
212
+ it("throws on an unsupported version", () => {
213
+ const stale = JSON.parse(
214
+ JSON.stringify({
215
+ version: 2,
216
+ expr: { kind: "atom", path: ["x"], op: { kind: "eq", value: 1 } },
217
+ }),
218
+ );
219
+ expect(() => evaluateEventMatch(stale, {})).toThrow(/version/i);
220
+ });
221
+
222
+ it("throws on an unrecognized expr kind", () => {
223
+ const bad = JSON.parse(JSON.stringify({ version: 1, expr: { kind: "not-a-kind" } }));
224
+ expect(() => evaluateEventMatch(bad, {})).toThrow(/kind/i);
225
+ });
226
+
227
+ it("throws on an unrecognized op kind", () => {
228
+ const bad = JSON.parse(
229
+ JSON.stringify({
230
+ version: 1,
231
+ expr: { kind: "atom", path: ["x"], op: { kind: "not-an-op" } },
232
+ }),
233
+ );
234
+ expect(() => evaluateEventMatch(bad, {})).toThrow(/kind/i);
235
+ });
236
+
237
+ it("and with empty nodes is true, or with empty nodes is false", () => {
238
+ expect(evaluateEventMatch({ version: 1, expr: { kind: "and", nodes: [] } }, {})).toBe(true);
239
+ expect(evaluateEventMatch({ version: 1, expr: { kind: "or", nodes: [] } }, {})).toBe(false);
240
+ });
241
+
242
+ it("combines atoms with and/or", () => {
243
+ const match: EventMatch = {
244
+ version: 1,
245
+ expr: {
246
+ kind: "and",
247
+ nodes: [
248
+ { kind: "atom", path: ["status"], op: { kind: "eq", value: "confirmed" } },
249
+ { kind: "atom", path: ["tier"], op: { kind: "in", values: ["gold", "platinum"] } },
250
+ ],
251
+ },
252
+ };
253
+ expect(evaluateEventMatch(match, { status: "confirmed", tier: "gold" })).toBe(true);
254
+ expect(evaluateEventMatch(match, { status: "confirmed", tier: "silver" })).toBe(false);
255
+ });
256
+
257
+ it("ne matches when the resolved value differs", () => {
258
+ const match: EventMatch = {
259
+ version: 1,
260
+ expr: { kind: "atom", path: ["status"], op: { kind: "ne", value: "cancelled" } },
261
+ };
262
+ expect(evaluateEventMatch(match, { status: "confirmed" })).toBe(true);
263
+ expect(evaluateEventMatch(match, { status: "cancelled" })).toBe(false);
264
+ });
265
+
266
+ it("compares numbers with gte", () => {
267
+ const match: EventMatch = {
268
+ version: 1,
269
+ expr: { kind: "atom", path: ["amount"], op: { kind: "gte", value: 100 } },
270
+ };
271
+ expect(evaluateEventMatch(match, { amount: 150 })).toBe(true);
272
+ expect(evaluateEventMatch(match, { amount: 50 })).toBe(false);
273
+ });
274
+
275
+ it("does not match a comparison op when the payload value's type differs from the operand", () => {
276
+ const match: EventMatch = {
277
+ version: 1,
278
+ expr: { kind: "atom", path: ["amount"], op: { kind: "gt", value: 100 } },
279
+ };
280
+ expect(evaluateEventMatch(match, { amount: "150" })).toBe(false);
281
+ });
282
+
283
+ it("resolves nested paths and treats a path running into a non-object as unmatched", () => {
284
+ const match: EventMatch = {
285
+ version: 1,
286
+ expr: {
287
+ kind: "atom",
288
+ path: ["user", "email"],
289
+ op: { kind: "eq", value: "a@b.com" },
290
+ },
291
+ };
292
+ expect(evaluateEventMatch(match, { user: { email: "a@b.com" } })).toBe(true);
293
+ expect(evaluateEventMatch(match, { user: "not-an-object" })).toBe(false);
294
+ expect(evaluateEventMatch(match, {})).toBe(false);
295
+ });
141
296
  });
142
297
 
143
298
  describe("buildRetryStep", () => {
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { createHash } from "node:crypto";
7
7
  import type { WriteEvent } from "./types/handlers";
8
- import type { PipelineDef } from "./types/step";
8
+ import type { AwaitedEventType, PipelineDef } from "./types/step";
9
9
 
10
10
  /**
11
11
  * Trigger configuration for a workflow. Determines what starts a run.
@@ -28,12 +28,27 @@ export type WorkflowTrigger =
28
28
  /**
29
29
  * Workflow definition — the result of defineWorkflow().
30
30
  */
31
- export type WorkflowDefinition<TPayload = unknown, TData = unknown> = {
31
+ export type WorkflowDefinition<
32
+ TPayload = unknown,
33
+ TData = unknown,
34
+ TAwaits extends Record<string, string> = Record<string, string>,
35
+ > = {
32
36
  readonly __kind: "workflow";
33
37
  readonly name: string;
34
38
  readonly trigger: WorkflowTrigger;
39
+ /**
40
+ * Declared expected events (D4, workflow-resume-loop.md) — the sole route
41
+ * a waitForEvent step can reach an event type. Read at registration time
42
+ * to build the static apply-map (Phase 3b); values are plain event-type
43
+ * strings, not the branded AwaitedEventType the pipeline closure sees.
44
+ */
45
+ readonly awaits?: TAwaits;
35
46
  /** The pipeline definition containing the step list closure. */
36
- readonly pipelineDef: PipelineDef<TPayload, TData>;
47
+ readonly pipelineDef: PipelineDef<
48
+ TPayload,
49
+ TData,
50
+ { readonly [K in keyof TAwaits]: AwaitedEventType }
51
+ >;
37
52
  /** Idempotency key for deduplication — prevents duplicate runs. */
38
53
  readonly idempotencyKey?: string | ((event: WriteEvent<TPayload>) => string);
39
54
  };
@@ -41,10 +56,15 @@ export type WorkflowDefinition<TPayload = unknown, TData = unknown> = {
41
56
  /**
42
57
  * Input shape for defineWorkflow() — the user-facing API.
43
58
  */
44
- export type WorkflowInput<TPayload = unknown, TData = unknown> = {
59
+ export type WorkflowInput<
60
+ TPayload = unknown,
61
+ TData = unknown,
62
+ TAwaits extends Record<string, string> = Record<string, string>,
63
+ > = {
45
64
  readonly name: string;
46
65
  readonly trigger: WorkflowTrigger;
47
- readonly steps: PipelineDef<TPayload, TData>;
66
+ readonly awaits?: TAwaits;
67
+ readonly steps: PipelineDef<TPayload, TData, { readonly [K in keyof TAwaits]: AwaitedEventType }>;
48
68
  readonly idempotencyKey?: string | ((event: WriteEvent<TPayload>) => string);
49
69
  readonly onError?: PipelineDef<unknown>;
50
70
  };
@@ -69,14 +89,22 @@ export type WorkflowInput<TPayload = unknown, TData = unknown> = {
69
89
  * });
70
90
  * ```
71
91
  */
72
- export function defineWorkflow<TPayload = unknown, TData = unknown>(
73
- input: WorkflowInput<TPayload, TData>,
74
- ): WorkflowDefinition<TPayload, TData> {
92
+ export function defineWorkflow<
93
+ TPayload = unknown,
94
+ TData = unknown,
95
+ const TAwaits extends Record<string, string> = Record<string, string>,
96
+ >(input: WorkflowInput<TPayload, TData, TAwaits>): WorkflowDefinition<TPayload, TData, TAwaits> {
97
+ // pipelineDef.awaits carries the same raw strings as the declaration
98
+ // below — buildPipelineSteps (pipeline.ts) is where they're branded into
99
+ // AwaitedEventType for the step closure, not here (see PipelineDef.awaits
100
+ // doc comment in types/step.ts for why the brand can't happen at this
101
+ // layer without breaking inference for an inline `stepsPipeline(...)`).
75
102
  return {
76
103
  __kind: "workflow",
77
104
  name: input.name,
78
105
  trigger: input.trigger,
79
- pipelineDef: input.steps,
106
+ awaits: input.awaits,
107
+ pipelineDef: { ...input.steps, awaits: input.awaits },
80
108
  idempotencyKey: input.idempotencyKey,
81
109
  };
82
110
  }
@@ -98,12 +126,21 @@ export function defineWorkflow<TPayload = unknown, TData = unknown>(
98
126
  * environments. Run-the-fingerprint-in-the-same-environment is the
99
127
  * contract; cross-env replay is out of scope.
100
128
  */
101
- export function computeDefinitionFingerprint(
102
- workflow: Pick<WorkflowDefinition, "name" | "trigger" | "pipelineDef">,
129
+ export function computeDefinitionFingerprint<
130
+ TPayload = unknown,
131
+ TData = unknown,
132
+ TAwaits extends Record<string, string> = Record<string, string>,
133
+ >(
134
+ workflow: Pick<WorkflowDefinition<TPayload, TData, TAwaits>, "name" | "trigger" | "pipelineDef">,
103
135
  ): string {
136
+ // `awaits` is part of the routing contract (D4, workflow-resume-loop.md):
137
+ // changing which events a run waits on changes the run's behavior just
138
+ // like changing the step source does, so a changed declaration must flip
139
+ // the fingerprint and trip the Q7 stale-definition check on resume.
104
140
  const material = JSON.stringify({
105
141
  name: workflow.name,
106
142
  trigger: workflow.trigger,
143
+ awaits: workflow.pipelineDef.awaits,
107
144
  source: workflow.pipelineDef.build.toString(),
108
145
  });
109
146
  return createHash("sha256").update(material).digest("hex");
@@ -360,12 +360,15 @@ export function buildUiExtensionsMethods<TName extends string>(
360
360
  // Sugar for the common "one nav entry pointing at this screen"
361
361
  // case — synthesizes id/screen from the screen's own id. Beyond
362
362
  // label/icon/parent/order, declare a standalone r.nav() instead.
363
+ // Optional fields stay absent rather than explicitly undefined —
364
+ // buildAppSchema's JSON-safety check flags undefined values. Same
365
+ // pattern as r.contentCollection() below.
363
366
  registerNav({
364
367
  id: definition.id,
365
368
  label: definition.nav.label,
366
- icon: definition.nav.icon,
367
- parent: definition.nav.parent,
368
- order: definition.nav.order,
369
+ ...(definition.nav.icon !== undefined && { icon: definition.nav.icon }),
370
+ ...(definition.nav.parent !== undefined && { parent: definition.nav.parent }),
371
+ ...(definition.nav.order !== undefined && { order: definition.nav.order }),
369
372
  screen: `${name}:screen:${definition.id}`,
370
373
  });
371
374
  }
@@ -247,6 +247,7 @@ export {
247
247
  } from "./screen-helpers";
248
248
  export type { TransitionGraph } from "./state-machine";
249
249
  export { defineTransitions, guardTransition } from "./state-machine";
250
+ export { evaluateEventMatch } from "./steps/_event-match";
250
251
  export {
251
252
  SUSPEND_SENTINEL,
252
253
  WORKFLOW_AGGREGATE_TYPE,
@@ -469,6 +470,7 @@ export type {
469
470
  export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, HookPhases } from "./types";
470
471
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers";
471
472
  export type {
473
+ AwaitedEventType,
472
474
  PipelineBuildCtx,
473
475
  PipelineCtx,
474
476
  PipelineDef,
@@ -31,7 +31,13 @@ import { buildWaitStep } from "./steps/wait";
31
31
  import { buildWaitForEventStep } from "./steps/wait-for-event";
32
32
  import { buildWebhookSendStep } from "./steps/webhook-send";
33
33
  import type { WriteEvent } from "./types/handlers";
34
- import type { PipelineBuildCtx, PipelineDef, StepBuilder, StepInstance } from "./types/step";
34
+ import type {
35
+ AwaitedEventType,
36
+ PipelineBuildCtx,
37
+ PipelineDef,
38
+ StepBuilder,
39
+ StepInstance,
40
+ } from "./types/step";
35
41
 
36
42
  const stepBuilder: StepBuilder = {
37
43
  step: {
@@ -64,9 +70,13 @@ const stepBuilder: StepBuilder = {
64
70
  },
65
71
  };
66
72
 
67
- export function stepsPipeline<TPayload = unknown, TData = unknown>(
68
- closure: (ctx: PipelineBuildCtx<TPayload>) => readonly StepInstance[],
69
- ): PipelineDef<TPayload, TData> {
73
+ export function stepsPipeline<
74
+ TPayload = unknown,
75
+ TData = unknown,
76
+ TAwaits extends Record<string, AwaitedEventType> = Record<string, AwaitedEventType>,
77
+ >(
78
+ closure: (ctx: PipelineBuildCtx<TPayload, TAwaits>) => readonly StepInstance[],
79
+ ): PipelineDef<TPayload, TData, TAwaits> {
70
80
  return {
71
81
  __kind: "pipeline",
72
82
  build: closure,
@@ -76,9 +86,26 @@ export function stepsPipeline<TPayload = unknown, TData = unknown>(
76
86
  // Internal: invoked by run-pipeline.ts to materialise the step list.
77
87
  // Not exported from the engine barrel — pipeline-internal plumbing.
78
88
  // @wrapper-known semantic-alias
79
- export function buildPipelineSteps<TPayload>(
80
- pipelineDef: PipelineDef<TPayload>,
89
+ //
90
+ // `awaits` comes off pipelineDef itself (set by defineWorkflow), not a
91
+ // function argument — every existing buildPipelineSteps caller (runner.ts,
92
+ // resume-run.write.ts, run-pipeline.ts, validate-projection-allowlist.ts)
93
+ // keeps working unchanged while workflow pipelines still get their real
94
+ // awaits map wired into the closure. The cast below is the one place a
95
+ // workflow's raw declared event-type strings become AwaitedEventType — safe
96
+ // because pipelineDef.awaits only ever holds what defineWorkflow's `awaits`
97
+ // carried in (D4, workflow-resume-loop.md), never an unchecked external
98
+ // value.
99
+ export function buildPipelineSteps<
100
+ TPayload,
101
+ TAwaits extends Record<string, AwaitedEventType> = Record<string, AwaitedEventType>,
102
+ >(
103
+ pipelineDef: PipelineDef<TPayload, unknown, TAwaits>,
81
104
  event: WriteEvent<TPayload>,
82
105
  ): readonly StepInstance[] {
83
- return pipelineDef.build({ event, r: stepBuilder });
106
+ return pipelineDef.build({
107
+ event,
108
+ r: stepBuilder,
109
+ awaits: (pipelineDef.awaits ?? {}) as TAwaits,
110
+ });
84
111
  }
@@ -0,0 +1,98 @@
1
+ // Evaluator for the persisted r.step.waitForEvent `match` AST (EventMatch).
2
+ // Runtime-only: kept in the framework package (not packages/types) so it
3
+ // stays out of the type graph, same steps/-vs-packages/types split as
4
+ // _duration-utils.ts.
5
+
6
+ import type { EventMatch, EventMatchExpr, EventMatchOp } from "../types/step";
7
+
8
+ function isRecord(value: unknown): value is Record<string, unknown> {
9
+ return typeof value === "object" && value !== null;
10
+ }
11
+
12
+ function resolvePath(payload: unknown, path: readonly string[]): unknown {
13
+ let current: unknown = payload;
14
+ for (const segment of path) {
15
+ if (!isRecord(current)) return undefined;
16
+ current = current[segment];
17
+ }
18
+ return current;
19
+ }
20
+
21
+ function compareOrdered(
22
+ actual: number | string,
23
+ expected: number | string,
24
+ kind: "gt" | "gte" | "lt" | "lte",
25
+ ): boolean {
26
+ if (typeof actual === "number" && typeof expected === "number") {
27
+ switch (kind) {
28
+ case "gt":
29
+ return actual > expected;
30
+ case "gte":
31
+ return actual >= expected;
32
+ case "lt":
33
+ return actual < expected;
34
+ case "lte":
35
+ return actual <= expected;
36
+ }
37
+ }
38
+ if (typeof actual === "string" && typeof expected === "string") {
39
+ switch (kind) {
40
+ case "gt":
41
+ return actual > expected;
42
+ case "gte":
43
+ return actual >= expected;
44
+ case "lt":
45
+ return actual < expected;
46
+ case "lte":
47
+ return actual <= expected;
48
+ }
49
+ }
50
+ return false;
51
+ }
52
+
53
+ function evaluateOp(op: EventMatchOp, actual: unknown): boolean {
54
+ switch (op.kind) {
55
+ case "eq":
56
+ return actual === op.value;
57
+ case "ne":
58
+ return actual !== op.value;
59
+ case "in":
60
+ return op.values.some((value) => value === actual);
61
+ case "gt":
62
+ case "gte":
63
+ case "lt":
64
+ case "lte":
65
+ // A payload value of a different primitive kind than the operand
66
+ // doesn't match — bad input, not a malformed AST.
67
+ if (typeof actual !== "number" && typeof actual !== "string") return false;
68
+ return compareOrdered(actual, op.value, op.kind);
69
+ default: {
70
+ // op.kind comes from a persisted event, not from this call's static
71
+ // type — an unrecognized kind means a format we don't understand.
72
+ const unrecognized: { readonly kind: string } = op;
73
+ throw new Error(`Unknown EventMatchOp kind "${unrecognized.kind}"`);
74
+ }
75
+ }
76
+ }
77
+
78
+ function evaluateExpr(expr: EventMatchExpr, payload: unknown): boolean {
79
+ switch (expr.kind) {
80
+ case "and":
81
+ return expr.nodes.every((node) => evaluateExpr(node, payload));
82
+ case "or":
83
+ return expr.nodes.some((node) => evaluateExpr(node, payload));
84
+ case "atom":
85
+ return evaluateOp(expr.op, resolvePath(payload, expr.path));
86
+ default: {
87
+ const unrecognized: { readonly kind: string } = expr;
88
+ throw new Error(`Unknown EventMatchExpr kind "${unrecognized.kind}"`);
89
+ }
90
+ }
91
+ }
92
+
93
+ export function evaluateEventMatch(match: EventMatch, payload: unknown): boolean {
94
+ if (match.version !== 1) {
95
+ throw new Error(`Unsupported EventMatch version ${JSON.stringify(match.version)} — expected 1`);
96
+ }
97
+ return evaluateExpr(match.expr, payload);
98
+ }
@@ -8,13 +8,29 @@
8
8
  // it writes workflow.step.resumed with the matched event's data.
9
9
  //
10
10
  // The `match` resolver is optional — when omitted, any event of the given
11
- // type resumes the workflow. When provided, it receives the event payload
12
- // and must return true for the event to trigger resume.
11
+ // type resumes the workflow. When provided, it resolves to a serializable
12
+ // EventMatch AST (not a closure) that is persisted into the waiting event's
13
+ // payload; the Resume-Loop evaluates it via evaluateEventMatch against the
14
+ // candidate event's payload.
15
+ //
16
+ // resultKey is `args.event` itself (the branded event-type string) rather
17
+ // than a new `name` argument — a workflow only ever has one `awaits` entry
18
+ // per event type, so `ctx.steps[awaits.someKey]` is already a unique,
19
+ // intention-revealing handle. resume-run.write.ts (framework#2513 Phase 3b)
20
+ // is the only producer of this key's value: the step itself always
21
+ // suspends (never returns a real result), so a subsequent step only ever
22
+ // sees the matched event's payload after a resume seeds it in.
13
23
 
14
24
  import { defineStep } from "../define-step";
15
- import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
25
+ import type {
26
+ AwaitedEventType,
27
+ EventMatch,
28
+ PipelineCtx,
29
+ StepInstance,
30
+ StepResolver,
31
+ } from "../types/step";
16
32
  import { addDuration } from "./_duration-utils";
17
- import { resolveRequired } from "./_resolver-utils";
33
+ import { resolveOptional, resolveRequired } from "./_resolver-utils";
18
34
  import {
19
35
  SUSPEND_SENTINEL,
20
36
  WORKFLOW_AGGREGATE_TYPE,
@@ -22,8 +38,8 @@ import {
22
38
  } from "./_step-dispatch-constants";
23
39
 
24
40
  type WaitForEventArgs = {
25
- readonly event: string;
26
- readonly match?: StepResolver<(payload: unknown) => boolean>;
41
+ readonly event: AwaitedEventType;
42
+ readonly match?: StepResolver<EventMatch>;
27
43
  readonly timeout: StepResolver<string>;
28
44
  };
29
45
 
@@ -31,6 +47,7 @@ defineStep<WaitForEventArgs, undefined | typeof SUSPEND_SENTINEL>({
31
47
  kind: "workflow.waitForEvent",
32
48
  tier: 3,
33
49
  defaultFailureStrategy: "throw",
50
+ resultKey: (args) => args.event,
34
51
  run: async (args, ctx: PipelineCtx): Promise<undefined | typeof SUSPEND_SENTINEL> => {
35
52
  if (!ctx.workflow) {
36
53
  throw new Error(
@@ -40,6 +57,7 @@ defineStep<WaitForEventArgs, undefined | typeof SUSPEND_SENTINEL>({
40
57
  }
41
58
 
42
59
  const timeout = resolveRequired(args.timeout, ctx);
60
+ const resolvedMatch = resolveOptional(args.match, ctx);
43
61
 
44
62
  const now = Temporal.Now.instant().toString();
45
63
  const timeoutAt =
@@ -51,6 +69,7 @@ defineStep<WaitForEventArgs, undefined | typeof SUSPEND_SENTINEL>({
51
69
  type: WORKFLOW_WAITING_FOR_EVENT_TYPE,
52
70
  payload: {
53
71
  eventType: args.event,
72
+ ...(resolvedMatch && { match: resolvedMatch }),
54
73
  timeoutAt,
55
74
  stepIndex: ctx.workflow.stepIndex,
56
75
  workflowName: ctx.workflow.workflowName,
@@ -90,6 +90,32 @@ describe("createFileContext", () => {
90
90
  const h2 = files.ref("tenant/foo.pdf");
91
91
  expect(Array.from(await h2.read())).toEqual([7]);
92
92
  });
93
+
94
+ test("list delegates to the provider and returns its result", async () => {
95
+ const provider = createInMemoryFileProvider();
96
+ await provider.write("tenant/photo.jpg", new Uint8Array([1]));
97
+ await provider.write("tenant/photo.medium.jpg", new Uint8Array([2]));
98
+ await provider.write("other/photo.jpg", new Uint8Array([3]));
99
+ const files = createFileContext(() => Promise.resolve(provider));
100
+
101
+ const keys = await files.list("tenant/");
102
+ expect([...keys].sort()).toEqual(["tenant/photo.jpg", "tenant/photo.medium.jpg"].sort());
103
+ });
104
+
105
+ test("the provider is resolved once and memoized across ref() and list()", async () => {
106
+ const provider = createInMemoryFileProvider();
107
+ let resolveCount = 0;
108
+ const files = createFileContext(() => {
109
+ resolveCount++;
110
+ return Promise.resolve(provider);
111
+ });
112
+
113
+ await files.ref("tenant/a.jpg").write(new Uint8Array([1]));
114
+ await files.list("tenant/");
115
+ await files.ref("tenant/b.jpg").write(new Uint8Array([2]));
116
+
117
+ expect(resolveCount).toBe(1);
118
+ });
93
119
  });
94
120
 
95
121
  describe("InMemoryFileProvider", () => {
@@ -45,6 +45,7 @@ export function createFileContext(resolve: () => Promise<FileStorageProvider>):
45
45
  };
46
46
  return {
47
47
  ref: (key) => createFileHandle(key, getProvider),
48
+ list: async (prefix) => (await getProvider()).list(prefix),
48
49
  };
49
50
  }
50
51