yieldstar 0.5.0 → 0.6.1

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,7 +1,15 @@
1
1
  import {
2
+ assertSynchronousClaim,
3
+ assertSynchronousUpdater,
2
4
  cloneStoreState,
3
5
  defineStore,
4
6
  type Draft,
7
+ type DurableObject,
8
+ type DurableStoreSchema,
9
+ type IfDurable,
10
+ type ExecutionStartRequest,
11
+ type ExecutionStartResult,
12
+ InfrastructureError,
5
13
  type StandardSchemaV1,
6
14
  StepResponse,
7
15
  StepKey,
@@ -10,7 +18,6 @@ import {
10
18
  StepDelay,
11
19
  StepCacheCheck,
12
20
  StepStoreWait,
13
- type StoreClient,
14
21
  type StoreDefinition,
15
22
  type StoreDeleteFromResult,
16
23
  type StoreKey,
@@ -20,11 +27,32 @@ import {
20
27
  type StoreTakeResult,
21
28
  type StoreUpdateFromResult,
22
29
  type StoreUpdateResult,
30
+ type StoreWaiter,
23
31
  trackStoreSelector,
32
+ type Durable,
33
+ type StepSchemaVerdict,
34
+ durableValueCodec,
35
+ StepEffectWait,
36
+ validateEffectRequest,
37
+ WorkflowEffectError,
38
+ WorkflowStartError,
39
+ WorkflowValueError,
24
40
  } from "@yieldstar/core";
25
41
  import { RetryableError } from "../exports/errors";
26
42
  import { getCallSiteHash } from "./utils";
43
+ import {
44
+ StepRuntimeCall,
45
+ workflowCallback,
46
+ type RuntimeCallResult,
47
+ } from "./runtime-call";
27
48
  import type { WorkflowEvent } from "@yieldstar/core";
49
+ import {
50
+ StepEffectCall,
51
+ type EffectCallOutcome,
52
+ type EffectLookup,
53
+ } from "./effect-call";
54
+
55
+ export type { EffectLookup };
28
56
 
29
57
  /**
30
58
  * @description A library of step generators, each of which:
@@ -43,7 +71,7 @@ export type WorkflowStore<T> = {
43
71
  get(key?: string): AsyncGenerator<StepResponse, StoreSnapshot<T>>;
44
72
  select<R>(
45
73
  key: string,
46
- selector: StoreSelector<T, R>
74
+ selector: StoreSelector<T, R> & DurableSelector<T, R>
47
75
  ): AsyncGenerator<StepResponse, R>;
48
76
  update(
49
77
  key: string,
@@ -59,158 +87,318 @@ export type WorkflowStore<T> = {
59
87
  snapshot: StoreSnapshot<T>
60
88
  ): AsyncGenerator<StepResponse, StoreDeleteFromResult>;
61
89
  when<R>(
62
- selector: StoreSelector<T, R | undefined | null | false>
90
+ selector: StoreSelector<T, R | undefined | null | false> &
91
+ DurableMatchSelector<T, R>
63
92
  ): AsyncGenerator<StepResponse, NonNullable<R>>;
64
93
  when<R>(
65
94
  key: string,
66
- selector: StoreSelector<T, R | undefined | null | false>
95
+ selector: StoreSelector<T, R | undefined | null | false> &
96
+ DurableMatchSelector<T, R>
67
97
  ): AsyncGenerator<StepResponse, NonNullable<R>>;
68
98
  take<R>(
69
99
  key: string,
70
- selector: StoreSelector<T, R | undefined | null | false>,
100
+ selector: StoreSelector<T, R | undefined | null | false> &
101
+ DurableMatchSelector<T, R>,
71
102
  claim: (draft: Draft<T>, selected: NonNullable<R>) => void
72
103
  ): AsyncGenerator<StepResponse, NonNullable<R>>;
73
104
  };
74
105
 
106
+ /** A store selector whose result, which the step records, is durable. */
107
+ type DurableSelector<T, R> = IfDurable<R, StoreSelector<T, Durable<R>>>;
108
+
109
+ /**
110
+ * A `when` or `take` selector whose match is durable. `false` is excluded
111
+ * before the check so that `R` is inferred as it would be without it.
112
+ */
113
+ type DurableMatchSelector<T, R> = IfDurable<
114
+ Exclude<R, false>,
115
+ StoreSelector<T, Durable<Exclude<R, false>> | undefined | null | false>
116
+ >;
117
+
75
118
  export type StepRunner = ReturnType<typeof createStepRunner>;
76
119
 
77
120
  export function createStepRunner(params: {
78
121
  event: WorkflowEvent;
79
- storeClient: StoreClient;
122
+ clock: () => number;
80
123
  }) {
81
- const { event, storeClient } = params;
82
-
124
+ const { event, clock } = params;
125
+
126
+ /**
127
+ * Returns a handle to the store `definition.name` + `id`, where `id`
128
+ * defaults to this execution's ID. Obtaining a handle reads, creates and
129
+ * records nothing, so any caller may obtain the same handle again. Each
130
+ * operation on a missing store first creates it from `definition.initial`.
131
+ */
83
132
  function store<Schema extends StandardSchemaV1>(
84
- definition: StoreDefinition<Schema>,
85
- params?: {
86
- id?: string;
87
- initial?:
88
- | StoreState<Schema>
89
- | (() => StoreState<Schema> | Promise<StoreState<Schema>>);
90
- }
91
- ): AsyncGenerator<StepResponse, WorkflowStore<StoreState<Schema>>> {
92
- const id = params?.id ?? event.executionId;
93
- const key = `store:${definition.name}:${id}`;
94
-
95
- return storeStep({
96
- key,
133
+ definition: StoreDefinition<Schema> & {
134
+ schema: DurableStoreSchema<Schema>;
135
+ },
136
+ params?: { id?: string }
137
+ ): WorkflowStore<StoreState<Schema>> {
138
+ return createWorkflowStore({
97
139
  definition,
98
- id,
140
+ id: params?.id ?? event.executionId,
99
141
  event,
100
- storeClient,
101
- initial: params?.initial,
102
142
  });
103
143
  }
104
144
 
105
- return { run, delay, poll, store };
145
+ /**
146
+ * Starts another workflow as a durable step and returns its execution ID.
147
+ * The start is identified by this execution's ID and `stepKey`, so every
148
+ * replay of the step names the same target. A rejected start throws a
149
+ * `WorkflowStartError`.
150
+ */
151
+ function start<Params extends Record<string, any>>(
152
+ stepKey: string,
153
+ target: { workflowId: string; params?: Params & Durable<Params> }
154
+ ): AsyncGenerator<
155
+ StepResponse,
156
+ { executionId: string },
157
+ StepResult | StepError
158
+ > {
159
+ if (stepKey === "") {
160
+ throw new Error("step.start requires a non-empty step key");
161
+ }
162
+ const request: ExecutionStartRequest = {
163
+ requestId: { executionId: event.executionId, stepKey },
164
+ workflowId: target.workflowId,
165
+ };
166
+ // A missing params and an empty object are different requests.
167
+ if (target.params !== undefined) {
168
+ request.params = target.params as DurableObject;
169
+ }
170
+ return startStep(stepKey, request);
171
+ }
172
+
173
+ /**
174
+ * Runs an external operation identified by `descriptor.effectId`, at most
175
+ * once in effect: after an uncertain attempt, `lookup` decides whether the
176
+ * operation already happened before `perform` runs again (RFC 005).
177
+ */
178
+ function effect<T, Input extends Record<string, any> = DurableObject>(
179
+ stepKey: string,
180
+ descriptor: EffectDescriptor<T, Input> & DurableEffectResult<T>
181
+ ): AsyncGenerator<StepResponse, T, StepResult | StepError> {
182
+ if (typeof stepKey !== "string" || stepKey === "") {
183
+ throw new Error("step.effect requires a non-empty step key");
184
+ }
185
+ return effectStep(stepKey, descriptor) as AsyncGenerator<
186
+ StepResponse,
187
+ T,
188
+ StepResult | StepError
189
+ >;
190
+ }
191
+
192
+ return { run, delay: createDelay(clock), poll, store, start, effect };
106
193
  }
107
194
 
108
- async function* storeStep<Schema extends StandardSchemaV1>(params: {
109
- key: string;
110
- definition: StoreDefinition<Schema>;
111
- id: string;
112
- event: WorkflowEvent;
113
- storeClient: StoreClient;
114
- initial?:
115
- | StoreState<Schema>
116
- | (() => StoreState<Schema> | Promise<StoreState<Schema>>);
117
- }): AsyncGenerator<
118
- StepResponse,
119
- WorkflowStore<StoreState<Schema>>,
120
- StepResult | StepError
121
- > {
122
- const { key, definition, id, event, storeClient, initial } = params;
195
+ /**
196
+ * Describes one external operation for `step.effect`. `effectId` names the
197
+ * operation in the external system, so every caller that means it, in any
198
+ * execution or runtime, uses the same ID, and no caller reuses it for another
199
+ * operation. The external system must guarantee that repeated `perform` calls
200
+ * for one ID make one operation with one result, that `not-found` proves no
201
+ * call exists or can still finish, and that `settled` never changes.
202
+ */
203
+ export type EffectDescriptor<T, Input = DurableObject> = {
204
+ effectId: string;
205
+ /** Names the version of the behaviour of `input`, `lookup` and `perform`. */
206
+ operation: string;
207
+ input: Input & Durable<Input>;
208
+ /** Milliseconds before an uncertain outcome is looked up again. */
209
+ retryIn: number;
210
+ /** Reports what the external system knows about `effectId`. */
211
+ lookup(effectId: string): Promise<EffectLookup<T>>;
212
+ /** Performs the operation under `effectId`. */
213
+ perform(effectId: string): Promise<T>;
214
+ };
123
215
 
216
+ /**
217
+ * Checks the effect's result type without taking part in its inference. A
218
+ * `perform` that resolves with nothing settles the effect with `undefined`.
219
+ */
220
+ type DurableEffectResult<T> = [T] extends [void]
221
+ ? unknown
222
+ : IfDurable<T, { perform(effectId: string): Promise<Durable<T>> }>;
223
+
224
+ async function* effectStep(
225
+ key: string,
226
+ descriptor: EffectDescriptor<unknown, unknown>
227
+ ): AsyncGenerator<StepResponse, unknown, any> {
124
228
  yield new StepKey(key);
125
229
 
126
230
  const cached = yield new StepCacheCheck();
127
231
 
128
- if (cached) {
129
- yield cached;
130
- if (cached instanceof StepError) {
131
- throw cached.err;
232
+ if (cached) return yield* replay(cached);
233
+
234
+ const { effectId, operation, input, retryIn, lookup, perform } =
235
+ (descriptor ?? {}) as Partial<EffectDescriptor<unknown, unknown>>;
236
+ const invalid = invalidEffectRequest({
237
+ effectId,
238
+ operation,
239
+ input,
240
+ retryIn,
241
+ lookup,
242
+ perform,
243
+ });
244
+ if (invalid) {
245
+ const err = new WorkflowEffectError(
246
+ "INVALID_EFFECT_REQUEST",
247
+ `step.effect "${key}": ${invalid}`
248
+ );
249
+ yield new StepError(err);
250
+ // unreachable – consumer calls throw() on the generator first
251
+ throw err;
252
+ }
253
+
254
+ const outcome: EffectCallOutcome = yield new StepEffectCall({
255
+ stepKey: key,
256
+ effectId: effectId!,
257
+ operation: operation!,
258
+ input: input as DurableObject,
259
+ retryIn: retryIn!,
260
+ lookup: (id) => lookup!.call(descriptor, id),
261
+ perform: (id) => perform!.call(descriptor, id),
262
+ });
263
+
264
+ switch (outcome.status) {
265
+ case "settled":
266
+ return yield* record(outcome.result);
267
+ case "rejected": {
268
+ const err = new WorkflowEffectError(
269
+ outcome.code,
270
+ `Effect "${effectId}" was begun with a different operation or input`
271
+ );
272
+ yield new StepError(err);
273
+ throw err;
132
274
  }
133
- return createWorkflowStore({
134
- definition,
135
- id,
136
- event,
137
- storeClient,
138
- });
275
+ case "suspended":
276
+ yield new StepEffectWait(outcome.wakeAt);
277
+ throw new Error("Effect wait yielded control unexpectedly");
139
278
  }
279
+ }
140
280
 
141
- try {
142
- await storeClient.getOrCreateStore({
143
- definition,
144
- id,
145
- initial,
146
- });
147
- yield new StepResult({ storeName: definition.name, storeId: id });
148
- return createWorkflowStore({
149
- definition,
150
- id,
151
- event,
152
- storeClient,
281
+ function invalidEffectRequest(request: {
282
+ effectId: unknown;
283
+ operation: unknown;
284
+ input: unknown;
285
+ retryIn: unknown;
286
+ lookup: unknown;
287
+ perform: unknown;
288
+ }): string | undefined {
289
+ const validated = validateEffectRequest(request);
290
+ if (!validated.ok) return validated.message;
291
+ const { retryIn } = request;
292
+ if (typeof retryIn !== "number" || !Number.isFinite(retryIn) || retryIn < 0) {
293
+ return "retryIn must be a finite, non-negative number";
294
+ }
295
+ if (typeof request.lookup !== "function") return "lookup must be a function";
296
+ if (typeof request.perform !== "function") return "perform must be a function";
297
+ return undefined;
298
+ }
299
+
300
+ async function* startStep(
301
+ key: string,
302
+ request: ExecutionStartRequest
303
+ ): AsyncGenerator<StepResponse, { executionId: string }, any> {
304
+ yield new StepKey(key);
305
+
306
+ const cached = yield new StepCacheCheck();
307
+
308
+ if (cached) return yield* replay(cached);
309
+
310
+ const started: RuntimeCallResult<ExecutionStartResult> =
311
+ yield new StepRuntimeCall(async ({ executions }) => {
312
+ // The client's failures are never the workflow's to catch: whatever it
313
+ // throws, the delivery ends and the next attempt calls it again.
314
+ try {
315
+ return await executions.start(request);
316
+ } catch (err) {
317
+ throw InfrastructureError.is(err) ? err : new InfrastructureError(err);
318
+ }
153
319
  });
154
- } catch (err: unknown) {
320
+
321
+ if (!started.ok) {
322
+ throw new Error("ExecutionClient.start failed without an infrastructure error");
323
+ }
324
+
325
+ const outcome = started.value;
326
+
327
+ if (outcome.status === "rejected") {
328
+ const err = new WorkflowStartError(outcome.code, outcome.message);
155
329
  yield new StepError(err);
330
+ // unreachable – consumer calls throw() on the generator first
156
331
  throw err;
157
332
  }
333
+
334
+ return yield* record({ executionId: outcome.executionId });
158
335
  }
159
336
 
160
337
  function createWorkflowStore<T>(params: {
161
338
  definition: StoreDefinition<any>;
162
339
  id: string;
163
340
  event: WorkflowEvent;
164
- storeClient: StoreClient;
165
341
  }): WorkflowStore<T> {
166
- const { definition, id, event, storeClient } = params;
167
- const key = { storeName: definition.name, storeId: id };
342
+ const { id, event } = params;
343
+ const key = { storeName: params.definition.name, storeId: id };
344
+ // Any operation may run `initial` on a missing store, so its errors must
345
+ // reach workflow code instead of ending the delivery as infrastructure.
346
+ const definition: StoreDefinition<any> = {
347
+ ...params.definition,
348
+ initial: workflowCallback(params.definition.initial),
349
+ };
168
350
 
169
351
  return {
170
- definition,
352
+ definition: params.definition,
171
353
  id,
172
354
  key,
173
355
  get(stepKey?: string) {
174
- return durableStep<StoreSnapshot<T>>(stepKey ?? getCallSiteHash(this.get), async () =>
175
- (await storeClient.getStore({ definition, id })) as StoreSnapshot<T>
356
+ return runtimeStep(
357
+ stepKey ?? getCallSiteHash(this.get),
358
+ async ({ stores }) =>
359
+ (await stores.getStore({ definition, id })) as StoreSnapshot<T>
176
360
  );
177
361
  },
178
362
  select<R>(stepKey: string, selector: StoreSelector<T, R>) {
179
- return durableStep(stepKey, async () => {
180
- const snapshot = await storeClient.getStore({ definition, id });
363
+ return runtimeStep(stepKey, async ({ stores }) => {
364
+ const snapshot = await stores.getStore({ definition, id });
181
365
  // Run the selector against a clone so accidental mutation cannot
182
366
  // corrupt shared state (consistent with when, which throws via
183
367
  // its tracking proxy).
184
- return selector(cloneStoreState(snapshot.state as T));
368
+ return workflowCallback(selector)(cloneStoreState(snapshot.state as T));
185
369
  });
186
370
  },
187
371
  update(stepKey, updater) {
188
- return durableStep<StoreUpdateResult<T>>(stepKey, async () =>
189
- (await storeClient.updateStore({
190
- definition,
191
- id,
192
- updater: updater as any,
193
- // Exactly-once across the store-commit/heap-write gap: on replay
194
- // after a crash, the store's applied-steps ledger returns the
195
- // recorded result instead of re-running the updater.
196
- stepId: { executionId: event.executionId, stepKey },
197
- })) as StoreUpdateResult<T>
372
+ return runtimeStep(
373
+ stepKey,
374
+ async ({ stores }) =>
375
+ (await stores.updateStore({
376
+ definition,
377
+ id,
378
+ updater: workflowCallback(updater, assertSynchronousUpdater) as any,
379
+ // Exactly-once across the store-commit/heap-write gap: on replay
380
+ // after a crash, the store's applied-steps ledger returns the
381
+ // recorded result instead of re-running the updater.
382
+ stepId: { executionId: event.executionId, stepKey },
383
+ })) as StoreUpdateResult<T>
198
384
  );
199
385
  },
200
386
  updateFrom(stepKey, snapshot, updater) {
201
- return durableStep<StoreUpdateFromResult<T>>(stepKey, async () =>
202
- (await storeClient.updateStoreFrom({
203
- definition,
204
- id,
205
- snapshot,
206
- updater: updater as any,
207
- stepId: { executionId: event.executionId, stepKey },
208
- })) as StoreUpdateFromResult<T>
387
+ return runtimeStep(
388
+ stepKey,
389
+ async ({ stores }) =>
390
+ (await stores.updateStoreFrom({
391
+ definition,
392
+ id,
393
+ snapshot,
394
+ updater: workflowCallback(updater, assertSynchronousUpdater) as any,
395
+ stepId: { executionId: event.executionId, stepKey },
396
+ })) as StoreUpdateFromResult<T>
209
397
  );
210
398
  },
211
399
  deleteFrom(stepKey, snapshot) {
212
- return durableStep<StoreDeleteFromResult>(stepKey, () =>
213
- storeClient.deleteStoreFrom({
400
+ return runtimeStep<StoreDeleteFromResult>(stepKey, ({ stores }) =>
401
+ stores.deleteStoreFrom({
214
402
  definition,
215
403
  id,
216
404
  snapshot,
@@ -234,7 +422,6 @@ function createWorkflowStore<T>(params: {
234
422
  definition,
235
423
  id,
236
424
  event,
237
- storeClient,
238
425
  stepKey,
239
426
  selector,
240
427
  });
@@ -248,7 +435,6 @@ function createWorkflowStore<T>(params: {
248
435
  definition,
249
436
  id,
250
437
  event,
251
- storeClient,
252
438
  stepKey,
253
439
  selector,
254
440
  claim,
@@ -257,14 +443,45 @@ function createWorkflowStore<T>(params: {
257
443
  };
258
444
  }
259
445
 
260
- function run<T extends any>(
261
- fn: () => T | Promise<T>,
262
- ): AsyncGenerator<StepResponse, T, StepResult | StepError>;
446
+ export type StepRunOptions<S extends StandardSchemaV1> = {
447
+ /**
448
+ * Validates the step's decoded value, on the first run and on every
449
+ * replay; the step returns the schema's output. The step function must
450
+ * return the schema's input as a durable value.
451
+ */
452
+ schema: S;
453
+ };
263
454
 
264
- function run<T extends any>(
455
+ /**
456
+ * A step function whose result, or the value its promise resolves to, is a
457
+ * durable value. Checking the whole function type keeps the result's type as
458
+ * TypeScript infers it, and names the offending property in the error.
459
+ */
460
+ export type DurableStepFn<F> = F extends () => infer R
461
+ ? () => R extends PromiseLike<infer U> ? PromiseLike<DurableUnion<U>> : DurableUnion<R>
462
+ : never;
463
+
464
+ type DurableUnion<T> = T extends unknown ? Durable<T> : never;
465
+
466
+ function run<F extends () => unknown>(
467
+ fn: F & DurableStepFn<F>
468
+ ): AsyncGenerator<StepResponse, Awaited<ReturnType<F>>, StepResponse>;
469
+
470
+ function run<F extends () => unknown>(
471
+ key: string,
472
+ fn: F & DurableStepFn<F>
473
+ ): AsyncGenerator<StepResponse, Awaited<ReturnType<F>>, StepResponse>;
474
+
475
+ function run<S extends StandardSchemaV1>(
476
+ fn: () => StepSchemaInput<S> | PromiseLike<StepSchemaInput<S>>,
477
+ options: StepRunOptions<S>
478
+ ): AsyncGenerator<StepResponse, StandardSchemaV1.InferOutput<S>, StepResponse>;
479
+
480
+ function run<S extends StandardSchemaV1>(
265
481
  key: string,
266
- fn: () => T | Promise<T>,
267
- ): AsyncGenerator<StepResponse, T, StepResult | StepError>;
482
+ fn: () => StepSchemaInput<S> | PromiseLike<StepSchemaInput<S>>,
483
+ options: StepRunOptions<S>
484
+ ): AsyncGenerator<StepResponse, StandardSchemaV1.InferOutput<S>, StepResponse>;
268
485
 
269
486
  // NOTE: run/delay/poll are plain (synchronous) functions that delegate to
270
487
  // async generators. This matters for keyless steps: the call-site hash must be
@@ -272,59 +489,72 @@ function run<T extends any>(
272
489
  // generator body doesn't execute until its first .next(), which happens inside
273
490
  // the workflow driver, so hashing there would give every keyless step the same
274
491
  // key.
275
- function run<T extends any>(
276
- arg1: string | (() => T | Promise<T>),
277
- arg2?: (() => T | Promise<T>) | string,
278
- ): AsyncGenerator<StepResponse, T, StepResult | StepError> {
492
+ function run(
493
+ arg1: string | (() => unknown),
494
+ arg2?: (() => unknown) | StepRunOptions<StandardSchemaV1>,
495
+ arg3?: StepRunOptions<StandardSchemaV1>
496
+ ): AsyncGenerator<StepResponse, any, StepResponse> {
279
497
  let key: string | null = null;
280
- let fn: () => T | Promise<T>;
498
+ let fn: () => unknown;
499
+ let options: StepRunOptions<StandardSchemaV1> | undefined;
281
500
 
282
501
  if (typeof arg1 === "string") {
283
502
  key = arg1;
284
- fn = arg2 as () => T | Promise<T>;
503
+ fn = arg2 as () => unknown;
504
+ options = arg3;
285
505
  } else {
286
506
  fn = arg1;
507
+ options = arg2 as StepRunOptions<StandardSchemaV1> | undefined;
287
508
  }
288
509
 
289
510
  if (!key) key = getCallSiteHash(run);
290
511
 
291
- return durableStep(key, fn);
512
+ return durableStep(key, fn, options?.schema);
292
513
  }
293
514
 
294
- function delay(retryInterval: number): any;
295
- function delay(key: string, retryInterval: number): any;
515
+ type StepSchemaInput<S extends StandardSchemaV1> = Durable<
516
+ StandardSchemaV1.InferInput<S>
517
+ >;
518
+
519
+ /** `step.delay`, reading its wake time from the runtime clock. */
520
+ function createDelay(clock: () => number) {
521
+ function delay(retryInterval: number): any;
522
+ function delay(key: string, retryInterval: number): any;
523
+ function delay(
524
+ arg1: string | number,
525
+ arg2?: number
526
+ ): AsyncGenerator<any, void, StepDelay> {
527
+ let key: string | null = null;
528
+ let retryInterval: number;
529
+
530
+ if (typeof arg1 === "string") {
531
+ key = arg1;
532
+ retryInterval = arg2!;
533
+ } else {
534
+ retryInterval = arg1;
535
+ }
296
536
 
297
- function delay(
298
- arg1: string | number,
299
- arg2?: number
300
- ): AsyncGenerator<any, void, StepDelay> {
301
- let key: string | null = null;
302
- let retryInterval: number;
537
+ if (!key) key = getCallSiteHash(delay);
303
538
 
304
- if (typeof arg1 === "string") {
305
- key = arg1;
306
- retryInterval = arg2!;
307
- } else {
308
- retryInterval = arg1;
539
+ return delayStep(key, retryInterval, clock);
309
540
  }
310
541
 
311
- if (!key) key = getCallSiteHash(delay);
312
-
313
- return delayStep(key, retryInterval);
542
+ return delay;
314
543
  }
315
544
 
316
545
  async function* delayStep(
317
546
  key: string,
318
- retryInterval: number
547
+ retryInterval: number,
548
+ clock: () => number
319
549
  ): AsyncGenerator<any, void, StepDelay> {
320
550
  yield new StepKey(key);
321
551
 
322
552
  const cached = yield new StepCacheCheck();
323
553
 
324
554
  if (cached) {
325
- yield new StepDelay(cached.resumeIn);
555
+ yield new StepDelay(cached.wakeAt);
326
556
  } else {
327
- yield new StepDelay(Date.now() + retryInterval);
557
+ yield new StepDelay(clock() + retryInterval);
328
558
  }
329
559
  }
330
560
 
@@ -366,27 +596,32 @@ function poll(
366
596
  return run(key, task);
367
597
  }
368
598
 
369
- async function* durableStep<T extends any>(
599
+ async function* durableStep(
370
600
  key: string,
371
- fn: () => T | Promise<T>
372
- ): AsyncGenerator<StepResponse, T, StepResult | StepError> {
601
+ fn: () => unknown,
602
+ schema: StandardSchemaV1 | undefined
603
+ ): AsyncGenerator<StepResponse, any, any> {
373
604
  yield new StepKey(key);
374
605
 
375
606
  const cached = yield new StepCacheCheck();
376
607
 
377
608
  if (cached) {
378
- yield cached;
379
- if (cached instanceof StepError) {
380
- // unreachable – consumer calls throw() on the generator first
381
- throw cached.err;
609
+ const value = yield* replay(cached);
610
+ if (!schema) return value;
611
+ const validated = await validateStepValue(key, schema, value);
612
+ if (cached.schema === "rejected" && validated.ok) {
613
+ throw new WorkflowValueError(
614
+ "STEP_SCHEMA_INVALID",
615
+ key,
616
+ `Step "${key}" schema rejected its value on the first run`
617
+ );
382
618
  }
383
- return cached.result;
619
+ return unwrapValidated(validated);
384
620
  }
385
621
 
622
+ let result: unknown;
386
623
  try {
387
- const result = await fn();
388
- yield new StepResult(result);
389
- return result;
624
+ result = await fn();
390
625
  } catch (err: unknown) {
391
626
  if (err instanceof RetryableError) {
392
627
  yield new StepError(err, {
@@ -399,47 +634,139 @@ async function* durableStep<T extends any>(
399
634
  // unreachable – consumer calls throw() on the generator first
400
635
  throw err;
401
636
  }
637
+
638
+ if (!schema) return yield* record(result);
639
+
640
+ // The schema validates the value as replay will decode it. A value the
641
+ // codec rejects is left for the driver to record as INVALID_STEP_VALUE.
642
+ const encoding = durableValueCodec.encode(result);
643
+ if (encoding.status === "invalid") return yield* record(result);
644
+ const decoding = durableValueCodec.decode(encoding.canonical);
645
+ if (decoding.status === "invalid") throw new Error(decoding.message);
646
+ const validated = await validateStepValue(key, schema, decoding.value);
647
+ yield* record(result, validated.ok ? "accepted" : "rejected");
648
+ return unwrapValidated(validated);
649
+ }
650
+
651
+ type ValidatedStepValue =
652
+ | { ok: true; value: unknown }
653
+ | { ok: false; error: WorkflowValueError };
654
+
655
+ async function validateStepValue(
656
+ key: string,
657
+ schema: StandardSchemaV1,
658
+ value: unknown
659
+ ): Promise<ValidatedStepValue> {
660
+ const result = await schema["~standard"].validate(value);
661
+ if (result.issues) {
662
+ const messages = result.issues.map((issue) => issue.message).join("; ");
663
+ return {
664
+ ok: false,
665
+ error: new WorkflowValueError(
666
+ "STEP_SCHEMA_INVALID",
667
+ key,
668
+ `Step "${key}" value failed its schema: ${messages}`
669
+ ),
670
+ };
671
+ }
672
+ return { ok: true, value: result.value };
673
+ }
674
+
675
+ function unwrapValidated(validated: ValidatedStepValue) {
676
+ if (!validated.ok) throw validated.error;
677
+ return validated.value;
678
+ }
679
+
680
+ /**
681
+ * Yields a step's result for the driver to record, and returns the decoded
682
+ * record the driver resumes the step with, so that the first run returns
683
+ * what replay will.
684
+ */
685
+ async function* record<T>(
686
+ result: T,
687
+ schema?: StepSchemaVerdict
688
+ ): AsyncGenerator<StepResponse, T, any> {
689
+ const recorded: StepResult = yield new StepResult(result, schema);
690
+ return recorded.result;
691
+ }
692
+
693
+ /** Yields a recorded response; the driver throws a recorded error into the step. */
694
+ async function* replay(cached: StepResponse): AsyncGenerator<StepResponse, any, any> {
695
+ const recorded: StepResult = yield cached;
696
+ return recorded.result;
697
+ }
698
+
699
+ /**
700
+ * Yields a store step's error for the driver to record and throw. A value
701
+ * error raised by a store client is given this step's key.
702
+ */
703
+ async function* fail(key: string, error: unknown): AsyncGenerator<StepResponse, never, any> {
704
+ const err =
705
+ error instanceof WorkflowValueError && error.stepKey !== key
706
+ ? new WorkflowValueError(error.code, key, error.message)
707
+ : error;
708
+ yield new StepError(err);
709
+ // unreachable – consumer calls throw() on the generator first
710
+ throw err;
711
+ }
712
+
713
+ /**
714
+ * A durable step whose work is a runtime client call. The driver makes the
715
+ * call and resumes the step with its result.
716
+ */
717
+ async function* runtimeStep<T>(
718
+ key: string,
719
+ call: StepRuntimeCall<T>["call"]
720
+ ): AsyncGenerator<StepResponse, T, any> {
721
+ yield new StepKey(key);
722
+
723
+ const cached = yield new StepCacheCheck();
724
+
725
+ if (cached) return yield* replay(cached);
726
+
727
+ const outcome: RuntimeCallResult<T> = yield new StepRuntimeCall(call);
728
+
729
+ if (!outcome.ok) return yield* fail(key, outcome.error);
730
+
731
+ return yield* record(outcome.value);
402
732
  }
403
733
 
404
734
  async function* whenStep<T, R>(params: {
405
735
  definition: StoreDefinition<any>;
406
736
  id: string;
407
737
  event: WorkflowEvent;
408
- storeClient: StoreClient;
409
738
  stepKey: string;
410
739
  selector: StoreSelector<T, R | undefined | null | false>;
411
- }): AsyncGenerator<StepResponse, NonNullable<R>, StepResult | StepStoreWait> {
412
- const { definition, id, event, storeClient, stepKey, selector } = params;
740
+ }): AsyncGenerator<StepResponse, NonNullable<R>, any> {
741
+ const { definition, id, event, stepKey, selector } = params;
413
742
 
414
743
  yield new StepKey(stepKey);
415
744
 
416
745
  const cached = yield new StepCacheCheck();
417
746
 
418
- if (cached) {
419
- yield cached;
420
- if (!(cached instanceof StepResult)) {
421
- throw new Error("Store wait step cache must resolve to a step result");
422
- }
423
- return cached.result;
424
- }
747
+ if (cached) return yield* replay(cached);
425
748
 
426
- const snapshot = await storeClient.getStore({ definition, id });
749
+ const read: RuntimeCallResult<StoreSnapshot<unknown>> =
750
+ yield new StepRuntimeCall(({ stores }) =>
751
+ stores.getStore({ definition, id })
752
+ );
753
+
754
+ if (!read.ok) return yield* fail(stepKey, read.error);
755
+
756
+ const snapshot = read.value;
427
757
  const { result, readPaths } = trackStoreSelector(
428
758
  cloneStoreState(snapshot.state as T),
429
759
  selector
430
760
  );
431
761
 
432
762
  if (result !== undefined && result !== null && result !== false) {
433
- const clonedResult = cloneStoreState(result);
434
- yield new StepResult(clonedResult);
435
- return clonedResult as NonNullable<R>;
763
+ return yield* record(result as NonNullable<R>);
436
764
  }
437
765
 
438
- await storeClient.registerWaiter({
766
+ yield* registerWaiter({
439
767
  workflowId: event.workflowId,
440
768
  executionId: event.executionId,
441
769
  stepKey,
442
- event,
443
770
  storeName: definition.name,
444
771
  storeId: id,
445
772
  instanceId: snapshot.instanceId,
@@ -447,8 +774,6 @@ async function* whenStep<T, R>(params: {
447
774
  readPaths,
448
775
  });
449
776
 
450
- yield new StepStoreWait();
451
-
452
777
  throw new Error("Store wait yielded control unexpectedly");
453
778
  }
454
779
 
@@ -456,54 +781,42 @@ async function* takeStep<T, R>(params: {
456
781
  definition: StoreDefinition<any>;
457
782
  id: string;
458
783
  event: WorkflowEvent;
459
- storeClient: StoreClient;
460
784
  stepKey: string;
461
785
  selector: StoreSelector<T, R | undefined | null | false>;
462
786
  claim: (draft: Draft<T>, selected: NonNullable<R>) => void;
463
- }): AsyncGenerator<StepResponse, NonNullable<R>, StepResult | StepStoreWait> {
464
- const { definition, id, event, storeClient, stepKey, selector, claim } =
465
- params;
787
+ }): AsyncGenerator<StepResponse, NonNullable<R>, any> {
788
+ const { definition, id, event, stepKey, selector, claim } = params;
466
789
 
467
790
  yield new StepKey(stepKey);
468
791
 
469
792
  const cached = yield new StepCacheCheck();
470
793
 
471
- if (cached) {
472
- yield cached;
473
- if (!(cached instanceof StepResult)) {
474
- throw new Error("Store take step cache must resolve to a step result");
475
- }
476
- return cached.result;
477
- }
794
+ if (cached) return yield* replay(cached);
478
795
 
479
- let outcome: StoreTakeResult<R>;
480
- try {
481
- outcome = await storeClient.takeFromStore({
482
- definition,
483
- id,
484
- selector: selector as any,
485
- claim: claim as any,
486
- // Exactly-once across the store-commit/heap-write gap: on replay
487
- // after a crash, the store's applied-steps ledger returns the
488
- // recorded (matched) outcome instead of re-running selector/claim.
489
- stepId: { executionId: event.executionId, stepKey },
490
- });
491
- } catch (err: unknown) {
492
- yield new StepError(err);
493
- // unreachable – consumer calls throw() on the generator first
494
- throw err;
495
- }
796
+ const taken: RuntimeCallResult<StoreTakeResult<R>> =
797
+ yield new StepRuntimeCall(({ stores }) =>
798
+ stores.takeFromStore({
799
+ definition,
800
+ id,
801
+ selector: workflowCallback(selector) as any,
802
+ claim: workflowCallback(claim, assertSynchronousClaim) as any,
803
+ // Exactly-once across the store-commit/heap-write gap: on replay
804
+ // after a crash, the store's applied-steps ledger returns the
805
+ // recorded (matched) outcome instead of re-running selector/claim.
806
+ stepId: { executionId: event.executionId, stepKey },
807
+ })
808
+ );
496
809
 
497
- if (outcome.matched) {
498
- yield new StepResult(outcome.selected);
499
- return outcome.selected as NonNullable<R>;
500
- }
810
+ if (!taken.ok) return yield* fail(stepKey, taken.error);
811
+
812
+ const outcome = taken.value;
501
813
 
502
- await storeClient.registerWaiter({
814
+ if (outcome.matched) return yield* record(outcome.selected as NonNullable<R>);
815
+
816
+ yield* registerWaiter({
503
817
  workflowId: event.workflowId,
504
818
  executionId: event.executionId,
505
819
  stepKey,
506
- event,
507
820
  storeName: definition.name,
508
821
  storeId: id,
509
822
  instanceId: outcome.instanceId,
@@ -511,7 +824,18 @@ async function* takeStep<T, R>(params: {
511
824
  readPaths: outcome.readPaths,
512
825
  });
513
826
 
514
- yield new StepStoreWait();
515
-
516
827
  throw new Error("Store take yielded control unexpectedly");
517
828
  }
829
+
830
+ /** Registers a waiter, then suspends the step until a store change wakes it. */
831
+ async function* registerWaiter(
832
+ waiter: StoreWaiter
833
+ ): AsyncGenerator<StepResponse, void, any> {
834
+ const registered: RuntimeCallResult<void> = yield new StepRuntimeCall(
835
+ ({ stores }) => stores.registerWaiter(waiter)
836
+ );
837
+
838
+ if (!registered.ok) return yield* fail(waiter.stepKey, registered.error);
839
+
840
+ yield new StepStoreWait();
841
+ }