yieldstar 0.4.6 → 0.6.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 (43) hide show
  1. package/README.md +57 -64
  2. package/dist/exports/errors.d.ts.map +1 -1
  3. package/dist/exports/sdk-http.d.ts +8 -0
  4. package/dist/exports/sdk-http.d.ts.map +1 -1
  5. package/dist/exports/sdk-local.d.ts +19 -2
  6. package/dist/exports/sdk-local.d.ts.map +1 -1
  7. package/dist/exports/workflow.d.ts +14 -2
  8. package/dist/exports/workflow.d.ts.map +1 -1
  9. package/dist/index.d.ts +5 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +791 -137
  12. package/dist/internal/effect-call.d.ts +60 -0
  13. package/dist/internal/effect-call.d.ts.map +1 -0
  14. package/dist/internal/runtime-call.d.ts +39 -0
  15. package/dist/internal/runtime-call.d.ts.map +1 -0
  16. package/dist/internal/serialise.d.ts.map +1 -1
  17. package/dist/internal/step-record.d.ts +13 -0
  18. package/dist/internal/step-record.d.ts.map +1 -0
  19. package/dist/internal/step-runner.d.ts +93 -9
  20. package/dist/internal/step-runner.d.ts.map +1 -1
  21. package/dist/internal/utils.d.ts +11 -0
  22. package/dist/internal/utils.d.ts.map +1 -1
  23. package/package.json +8 -4
  24. package/src/exports/errors.ts +4 -0
  25. package/src/exports/sdk-http.ts +60 -27
  26. package/src/exports/sdk-local.ts +41 -15
  27. package/src/exports/workflow.ts +159 -99
  28. package/src/index.ts +51 -1
  29. package/src/internal/effect-call.ts +217 -0
  30. package/src/internal/runtime-call.ts +95 -0
  31. package/src/internal/step-record.ts +110 -0
  32. package/src/internal/step-runner.ts +748 -53
  33. package/src/internal/utils.ts +24 -10
  34. package/dist/exports/scheduler.d.ts +0 -49
  35. package/dist/exports/scheduler.d.ts.map +0 -1
  36. package/dist/exports/simplified-types.d.ts +0 -31
  37. package/dist/exports/simplified-types.d.ts.map +0 -1
  38. package/dist/exports/types.d.ts +0 -6
  39. package/dist/exports/types.d.ts.map +0 -1
  40. package/dist/internal/types.d.ts +0 -9
  41. package/dist/internal/types.d.ts.map +0 -1
  42. package/src/internal/serialise.ts +0 -44
  43. package/tsconfig.tsbuildinfo +0 -1
@@ -1,13 +1,58 @@
1
1
  import {
2
+ assertSynchronousClaim,
3
+ assertSynchronousUpdater,
4
+ cloneStoreState,
5
+ defineStore,
6
+ type Draft,
7
+ type DurableObject,
8
+ type DurableStoreSchema,
9
+ type IfDurable,
10
+ type ExecutionStartRequest,
11
+ type ExecutionStartResult,
12
+ InfrastructureError,
13
+ type StandardSchemaV1,
2
14
  StepResponse,
3
15
  StepKey,
4
16
  StepError,
5
17
  StepResult,
6
18
  StepDelay,
7
19
  StepCacheCheck,
20
+ StepStoreWait,
21
+ type StoreDefinition,
22
+ type StoreDeleteFromResult,
23
+ type StoreKey,
24
+ type StoreSelector,
25
+ type StoreSnapshot,
26
+ type StoreState,
27
+ type StoreTakeResult,
28
+ type StoreUpdateFromResult,
29
+ type StoreUpdateResult,
30
+ type StoreWaiter,
31
+ trackStoreSelector,
32
+ type Durable,
33
+ type StepSchemaVerdict,
34
+ durableValueCodec,
35
+ StepEffectWait,
36
+ validateEffectRequest,
37
+ WorkflowEffectError,
38
+ WorkflowStartError,
39
+ WorkflowValueError,
8
40
  } from "@yieldstar/core";
9
41
  import { RetryableError } from "../exports/errors";
10
42
  import { getCallSiteHash } from "./utils";
43
+ import {
44
+ StepRuntimeCall,
45
+ workflowCallback,
46
+ type RuntimeCallResult,
47
+ } from "./runtime-call";
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 };
11
56
 
12
57
  /**
13
58
  * @description A library of step generators, each of which:
@@ -17,93 +62,499 @@ import { getCallSiteHash } from "./utils";
17
62
  * @throws any error caught when running the user-defined function,
18
63
  * once retries have been exhausted, or if there are no retry semantics
19
64
  */
20
- export const stepRunner = { run, delay, poll };
65
+ export { defineStore };
21
66
 
22
- export type StepRunner = typeof stepRunner;
67
+ export type WorkflowStore<T> = {
68
+ readonly definition: StoreDefinition<any>;
69
+ readonly id: string;
70
+ readonly key: StoreKey;
71
+ get(key?: string): AsyncGenerator<StepResponse, StoreSnapshot<T>>;
72
+ select<R>(
73
+ key: string,
74
+ selector: StoreSelector<T, R> & DurableSelector<T, R>
75
+ ): AsyncGenerator<StepResponse, R>;
76
+ update(
77
+ key: string,
78
+ updater: (draft: Draft<T>) => void | T
79
+ ): AsyncGenerator<StepResponse, StoreUpdateResult<T>>;
80
+ updateFrom(
81
+ key: string,
82
+ snapshot: StoreSnapshot<T>,
83
+ updater: (draft: Draft<T>) => void | T
84
+ ): AsyncGenerator<StepResponse, StoreUpdateFromResult<T>>;
85
+ deleteFrom(
86
+ key: string,
87
+ snapshot: StoreSnapshot<T>
88
+ ): AsyncGenerator<StepResponse, StoreDeleteFromResult>;
89
+ when<R>(
90
+ selector: StoreSelector<T, R | undefined | null | false> &
91
+ DurableMatchSelector<T, R>
92
+ ): AsyncGenerator<StepResponse, NonNullable<R>>;
93
+ when<R>(
94
+ key: string,
95
+ selector: StoreSelector<T, R | undefined | null | false> &
96
+ DurableMatchSelector<T, R>
97
+ ): AsyncGenerator<StepResponse, NonNullable<R>>;
98
+ take<R>(
99
+ key: string,
100
+ selector: StoreSelector<T, R | undefined | null | false> &
101
+ DurableMatchSelector<T, R>,
102
+ claim: (draft: Draft<T>, selected: NonNullable<R>) => void
103
+ ): AsyncGenerator<StepResponse, NonNullable<R>>;
104
+ };
23
105
 
24
- function run<T extends any>(
25
- fn: () => T | Promise<T>,
26
- ): AsyncGenerator<StepResponse, T, StepResult | StepError>;
106
+ /** A store selector whose result, which the step records, is durable. */
107
+ type DurableSelector<T, R> = IfDurable<R, StoreSelector<T, Durable<R>>>;
27
108
 
28
- function run<T extends any>(
29
- key: string,
30
- fn: () => T | Promise<T>,
31
- ): AsyncGenerator<StepResponse, T, StepResult | StepError>;
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
+ >;
32
117
 
33
- async function* run<T extends any>(
34
- arg1: string | (() => T | Promise<T>),
35
- arg2?: (() => T | Promise<T>) | string,
36
- ): AsyncGenerator<StepResponse, T, StepResult | StepError> {
37
- let key: string | null = null;
38
- let fn: () => T | Promise<T>;
118
+ export type StepRunner = ReturnType<typeof createStepRunner>;
39
119
 
40
- if (typeof arg1 === "string") {
41
- key = arg1;
42
- fn = arg2 as () => T | Promise<T>;
43
- } else {
44
- fn = arg1;
120
+ export function createStepRunner(params: {
121
+ event: WorkflowEvent;
122
+ clock: () => number;
123
+ }) {
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
+ */
132
+ function store<Schema extends StandardSchemaV1>(
133
+ definition: StoreDefinition<Schema> & {
134
+ schema: DurableStoreSchema<Schema>;
135
+ },
136
+ params?: { id?: string }
137
+ ): WorkflowStore<StoreState<Schema>> {
138
+ return createWorkflowStore({
139
+ definition,
140
+ id: params?.id ?? event.executionId,
141
+ event,
142
+ });
45
143
  }
46
144
 
47
- if (!key) key = getCallSiteHash(run);
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
+ }
48
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 };
193
+ }
194
+
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
+ };
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> {
49
228
  yield new StepKey(key);
50
229
 
51
230
  const cached = yield new StepCacheCheck();
52
231
 
53
- if (cached) {
54
- yield cached;
55
- if (cached instanceof StepError) {
56
- // unreachable – consumer calls throw() on the generator first
57
- throw cached.err;
58
- }
59
- return cached.result;
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;
60
252
  }
61
253
 
62
- try {
63
- const result = await fn();
64
- yield new StepResult(result);
65
- return result;
66
- } catch (err: unknown) {
67
- if (err instanceof RetryableError) {
68
- yield new StepError(err, {
69
- maxAttempts: err.maxAttempts,
70
- retryInterval: err.retryInterval,
71
- });
72
- } else {
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
+ );
73
272
  yield new StepError(err);
273
+ throw err;
74
274
  }
275
+ case "suspended":
276
+ yield new StepEffectWait(outcome.wakeAt);
277
+ throw new Error("Effect wait yielded control unexpectedly");
278
+ }
279
+ }
280
+
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
+ }
319
+ });
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);
329
+ yield new StepError(err);
75
330
  // unreachable – consumer calls throw() on the generator first
76
331
  throw err;
77
332
  }
333
+
334
+ return yield* record({ executionId: outcome.executionId });
78
335
  }
79
336
 
80
- function delay(retryInterval: number): any;
81
- function delay(key: string, retryInterval: number): any;
337
+ function createWorkflowStore<T>(params: {
338
+ definition: StoreDefinition<any>;
339
+ id: string;
340
+ event: WorkflowEvent;
341
+ }): WorkflowStore<T> {
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
+ };
350
+
351
+ return {
352
+ definition: params.definition,
353
+ id,
354
+ key,
355
+ get(stepKey?: string) {
356
+ return runtimeStep(
357
+ stepKey ?? getCallSiteHash(this.get),
358
+ async ({ stores }) =>
359
+ (await stores.getStore({ definition, id })) as StoreSnapshot<T>
360
+ );
361
+ },
362
+ select<R>(stepKey: string, selector: StoreSelector<T, R>) {
363
+ return runtimeStep(stepKey, async ({ stores }) => {
364
+ const snapshot = await stores.getStore({ definition, id });
365
+ // Run the selector against a clone so accidental mutation cannot
366
+ // corrupt shared state (consistent with when, which throws via
367
+ // its tracking proxy).
368
+ return workflowCallback(selector)(cloneStoreState(snapshot.state as T));
369
+ });
370
+ },
371
+ update(stepKey, updater) {
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>
384
+ );
385
+ },
386
+ updateFrom(stepKey, snapshot, updater) {
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>
397
+ );
398
+ },
399
+ deleteFrom(stepKey, snapshot) {
400
+ return runtimeStep<StoreDeleteFromResult>(stepKey, ({ stores }) =>
401
+ stores.deleteStoreFrom({
402
+ definition,
403
+ id,
404
+ snapshot,
405
+ stepId: { executionId: event.executionId, stepKey },
406
+ })
407
+ );
408
+ },
409
+ when<R>(
410
+ arg1:
411
+ | string
412
+ | StoreSelector<T, R | undefined | null | false>,
413
+ arg2?: StoreSelector<T, R | undefined | null | false>
414
+ ) {
415
+ const stepKey =
416
+ typeof arg1 === "string" ? arg1 : getCallSiteHash(this.when);
417
+ const selector = (
418
+ typeof arg1 === "string" ? arg2 : arg1
419
+ ) as StoreSelector<T, R | undefined | null | false>;
82
420
 
83
- async function* delay(
84
- arg1: string | number,
85
- arg2?: number
86
- ): AsyncGenerator<any, void, StepDelay> {
421
+ return whenStep({
422
+ definition,
423
+ id,
424
+ event,
425
+ stepKey,
426
+ selector,
427
+ });
428
+ },
429
+ take<R>(
430
+ stepKey: string,
431
+ selector: StoreSelector<T, R | undefined | null | false>,
432
+ claim: (draft: Draft<T>, selected: NonNullable<R>) => void
433
+ ) {
434
+ return takeStep({
435
+ definition,
436
+ id,
437
+ event,
438
+ stepKey,
439
+ selector,
440
+ claim,
441
+ });
442
+ },
443
+ };
444
+ }
445
+
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
+ };
454
+
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>(
481
+ key: string,
482
+ fn: () => StepSchemaInput<S> | PromiseLike<StepSchemaInput<S>>,
483
+ options: StepRunOptions<S>
484
+ ): AsyncGenerator<StepResponse, StandardSchemaV1.InferOutput<S>, StepResponse>;
485
+
486
+ // NOTE: run/delay/poll are plain (synchronous) functions that delegate to
487
+ // async generators. This matters for keyless steps: the call-site hash must be
488
+ // captured while the user's call site is still on the stack. An async
489
+ // generator body doesn't execute until its first .next(), which happens inside
490
+ // the workflow driver, so hashing there would give every keyless step the same
491
+ // key.
492
+ function run(
493
+ arg1: string | (() => unknown),
494
+ arg2?: (() => unknown) | StepRunOptions<StandardSchemaV1>,
495
+ arg3?: StepRunOptions<StandardSchemaV1>
496
+ ): AsyncGenerator<StepResponse, any, StepResponse> {
87
497
  let key: string | null = null;
88
- let retryInterval: number;
498
+ let fn: () => unknown;
499
+ let options: StepRunOptions<StandardSchemaV1> | undefined;
89
500
 
90
501
  if (typeof arg1 === "string") {
91
502
  key = arg1;
92
- retryInterval = arg2!;
503
+ fn = arg2 as () => unknown;
504
+ options = arg3;
93
505
  } else {
94
- retryInterval = arg1;
506
+ fn = arg1;
507
+ options = arg2 as StepRunOptions<StandardSchemaV1> | undefined;
95
508
  }
96
509
 
97
- if (!key) key = getCallSiteHash(delay);
510
+ if (!key) key = getCallSiteHash(run);
511
+
512
+ return durableStep(key, fn, options?.schema);
513
+ }
514
+
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;
98
529
 
530
+ if (typeof arg1 === "string") {
531
+ key = arg1;
532
+ retryInterval = arg2!;
533
+ } else {
534
+ retryInterval = arg1;
535
+ }
536
+
537
+ if (!key) key = getCallSiteHash(delay);
538
+
539
+ return delayStep(key, retryInterval, clock);
540
+ }
541
+
542
+ return delay;
543
+ }
544
+
545
+ async function* delayStep(
546
+ key: string,
547
+ retryInterval: number,
548
+ clock: () => number
549
+ ): AsyncGenerator<any, void, StepDelay> {
99
550
  yield new StepKey(key);
100
551
 
101
552
  const cached = yield new StepCacheCheck();
102
553
 
103
554
  if (cached) {
104
- yield new StepDelay(cached.resumeIn);
555
+ yield new StepDelay(cached.wakeAt);
105
556
  } else {
106
- yield new StepDelay(Date.now() + retryInterval);
557
+ yield new StepDelay(clock() + retryInterval);
107
558
  }
108
559
  }
109
560
 
@@ -113,7 +564,7 @@ type PollPredicate = () => boolean | Promise<boolean>;
113
564
  function poll(opts: PollOpts, predicate: PollPredicate): any;
114
565
  function poll(key: string, opts: PollOpts, predicate: PollPredicate): any;
115
566
 
116
- async function* poll(
567
+ function poll(
117
568
  arg1: string | PollOpts,
118
569
  arg2: PollOpts | PollPredicate,
119
570
  arg3?: PollPredicate
@@ -142,5 +593,249 @@ async function* poll(
142
593
  }
143
594
  };
144
595
 
145
- yield* run(key, task, );
596
+ return run(key, task);
597
+ }
598
+
599
+ async function* durableStep(
600
+ key: string,
601
+ fn: () => unknown,
602
+ schema: StandardSchemaV1 | undefined
603
+ ): AsyncGenerator<StepResponse, any, any> {
604
+ yield new StepKey(key);
605
+
606
+ const cached = yield new StepCacheCheck();
607
+
608
+ if (cached) {
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
+ );
618
+ }
619
+ return unwrapValidated(validated);
620
+ }
621
+
622
+ let result: unknown;
623
+ try {
624
+ result = await fn();
625
+ } catch (err: unknown) {
626
+ if (err instanceof RetryableError) {
627
+ yield new StepError(err, {
628
+ maxAttempts: err.maxAttempts,
629
+ retryInterval: err.retryInterval,
630
+ });
631
+ } else {
632
+ yield new StepError(err);
633
+ }
634
+ // unreachable – consumer calls throw() on the generator first
635
+ throw err;
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);
732
+ }
733
+
734
+ async function* whenStep<T, R>(params: {
735
+ definition: StoreDefinition<any>;
736
+ id: string;
737
+ event: WorkflowEvent;
738
+ stepKey: string;
739
+ selector: StoreSelector<T, R | undefined | null | false>;
740
+ }): AsyncGenerator<StepResponse, NonNullable<R>, any> {
741
+ const { definition, id, event, stepKey, selector } = params;
742
+
743
+ yield new StepKey(stepKey);
744
+
745
+ const cached = yield new StepCacheCheck();
746
+
747
+ if (cached) return yield* replay(cached);
748
+
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;
757
+ const { result, readPaths } = trackStoreSelector(
758
+ cloneStoreState(snapshot.state as T),
759
+ selector
760
+ );
761
+
762
+ if (result !== undefined && result !== null && result !== false) {
763
+ return yield* record(result as NonNullable<R>);
764
+ }
765
+
766
+ yield* registerWaiter({
767
+ workflowId: event.workflowId,
768
+ executionId: event.executionId,
769
+ stepKey,
770
+ storeName: definition.name,
771
+ storeId: id,
772
+ instanceId: snapshot.instanceId,
773
+ sinceVersion: snapshot.version,
774
+ readPaths,
775
+ });
776
+
777
+ throw new Error("Store wait yielded control unexpectedly");
778
+ }
779
+
780
+ async function* takeStep<T, R>(params: {
781
+ definition: StoreDefinition<any>;
782
+ id: string;
783
+ event: WorkflowEvent;
784
+ stepKey: string;
785
+ selector: StoreSelector<T, R | undefined | null | false>;
786
+ claim: (draft: Draft<T>, selected: NonNullable<R>) => void;
787
+ }): AsyncGenerator<StepResponse, NonNullable<R>, any> {
788
+ const { definition, id, event, stepKey, selector, claim } = params;
789
+
790
+ yield new StepKey(stepKey);
791
+
792
+ const cached = yield new StepCacheCheck();
793
+
794
+ if (cached) return yield* replay(cached);
795
+
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
+ );
809
+
810
+ if (!taken.ok) return yield* fail(stepKey, taken.error);
811
+
812
+ const outcome = taken.value;
813
+
814
+ if (outcome.matched) return yield* record(outcome.selected as NonNullable<R>);
815
+
816
+ yield* registerWaiter({
817
+ workflowId: event.workflowId,
818
+ executionId: event.executionId,
819
+ stepKey,
820
+ storeName: definition.name,
821
+ storeId: id,
822
+ instanceId: outcome.instanceId,
823
+ sinceVersion: outcome.version,
824
+ readPaths: outcome.readPaths,
825
+ });
826
+
827
+ throw new Error("Store take yielded control unexpectedly");
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();
146
841
  }