yieldstar 0.4.5 → 0.5.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,13 +1,30 @@
1
1
  import {
2
+ cloneStoreState,
3
+ defineStore,
4
+ type Draft,
5
+ type StandardSchemaV1,
2
6
  StepResponse,
3
7
  StepKey,
4
8
  StepError,
5
9
  StepResult,
6
10
  StepDelay,
7
11
  StepCacheCheck,
12
+ StepStoreWait,
13
+ type StoreClient,
14
+ type StoreDefinition,
15
+ type StoreDeleteFromResult,
16
+ type StoreKey,
17
+ type StoreSelector,
18
+ type StoreSnapshot,
19
+ type StoreState,
20
+ type StoreTakeResult,
21
+ type StoreUpdateFromResult,
22
+ type StoreUpdateResult,
23
+ trackStoreSelector,
8
24
  } from "@yieldstar/core";
9
25
  import { RetryableError } from "../exports/errors";
10
26
  import { getCallSiteHash } from "./utils";
27
+ import type { WorkflowEvent } from "@yieldstar/core";
11
28
 
12
29
  /**
13
30
  * @description A library of step generators, each of which:
@@ -17,9 +34,228 @@ import { getCallSiteHash } from "./utils";
17
34
  * @throws any error caught when running the user-defined function,
18
35
  * once retries have been exhausted, or if there are no retry semantics
19
36
  */
20
- export const stepRunner = { run, delay, poll };
37
+ export { defineStore };
21
38
 
22
- export type StepRunner = typeof stepRunner;
39
+ export type WorkflowStore<T> = {
40
+ readonly definition: StoreDefinition<any>;
41
+ readonly id: string;
42
+ readonly key: StoreKey;
43
+ get(key?: string): AsyncGenerator<StepResponse, StoreSnapshot<T>>;
44
+ select<R>(
45
+ key: string,
46
+ selector: StoreSelector<T, R>
47
+ ): AsyncGenerator<StepResponse, R>;
48
+ update(
49
+ key: string,
50
+ updater: (draft: Draft<T>) => void | T
51
+ ): AsyncGenerator<StepResponse, StoreUpdateResult<T>>;
52
+ updateFrom(
53
+ key: string,
54
+ snapshot: StoreSnapshot<T>,
55
+ updater: (draft: Draft<T>) => void | T
56
+ ): AsyncGenerator<StepResponse, StoreUpdateFromResult<T>>;
57
+ deleteFrom(
58
+ key: string,
59
+ snapshot: StoreSnapshot<T>
60
+ ): AsyncGenerator<StepResponse, StoreDeleteFromResult>;
61
+ when<R>(
62
+ selector: StoreSelector<T, R | undefined | null | false>
63
+ ): AsyncGenerator<StepResponse, NonNullable<R>>;
64
+ when<R>(
65
+ key: string,
66
+ selector: StoreSelector<T, R | undefined | null | false>
67
+ ): AsyncGenerator<StepResponse, NonNullable<R>>;
68
+ take<R>(
69
+ key: string,
70
+ selector: StoreSelector<T, R | undefined | null | false>,
71
+ claim: (draft: Draft<T>, selected: NonNullable<R>) => void
72
+ ): AsyncGenerator<StepResponse, NonNullable<R>>;
73
+ };
74
+
75
+ export type StepRunner = ReturnType<typeof createStepRunner>;
76
+
77
+ export function createStepRunner(params: {
78
+ event: WorkflowEvent;
79
+ storeClient: StoreClient;
80
+ }) {
81
+ const { event, storeClient } = params;
82
+
83
+ 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,
97
+ definition,
98
+ id,
99
+ event,
100
+ storeClient,
101
+ initial: params?.initial,
102
+ });
103
+ }
104
+
105
+ return { run, delay, poll, store };
106
+ }
107
+
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;
123
+
124
+ yield new StepKey(key);
125
+
126
+ const cached = yield new StepCacheCheck();
127
+
128
+ if (cached) {
129
+ yield cached;
130
+ if (cached instanceof StepError) {
131
+ throw cached.err;
132
+ }
133
+ return createWorkflowStore({
134
+ definition,
135
+ id,
136
+ event,
137
+ storeClient,
138
+ });
139
+ }
140
+
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,
153
+ });
154
+ } catch (err: unknown) {
155
+ yield new StepError(err);
156
+ throw err;
157
+ }
158
+ }
159
+
160
+ function createWorkflowStore<T>(params: {
161
+ definition: StoreDefinition<any>;
162
+ id: string;
163
+ event: WorkflowEvent;
164
+ storeClient: StoreClient;
165
+ }): WorkflowStore<T> {
166
+ const { definition, id, event, storeClient } = params;
167
+ const key = { storeName: definition.name, storeId: id };
168
+
169
+ return {
170
+ definition,
171
+ id,
172
+ key,
173
+ get(stepKey?: string) {
174
+ return durableStep<StoreSnapshot<T>>(stepKey ?? getCallSiteHash(this.get), async () =>
175
+ (await storeClient.getStore({ definition, id })) as StoreSnapshot<T>
176
+ );
177
+ },
178
+ select<R>(stepKey: string, selector: StoreSelector<T, R>) {
179
+ return durableStep(stepKey, async () => {
180
+ const snapshot = await storeClient.getStore({ definition, id });
181
+ // Run the selector against a clone so accidental mutation cannot
182
+ // corrupt shared state (consistent with when, which throws via
183
+ // its tracking proxy).
184
+ return selector(cloneStoreState(snapshot.state as T));
185
+ });
186
+ },
187
+ 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>
198
+ );
199
+ },
200
+ 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>
209
+ );
210
+ },
211
+ deleteFrom(stepKey, snapshot) {
212
+ return durableStep<StoreDeleteFromResult>(stepKey, () =>
213
+ storeClient.deleteStoreFrom({
214
+ definition,
215
+ id,
216
+ snapshot,
217
+ stepId: { executionId: event.executionId, stepKey },
218
+ })
219
+ );
220
+ },
221
+ when<R>(
222
+ arg1:
223
+ | string
224
+ | StoreSelector<T, R | undefined | null | false>,
225
+ arg2?: StoreSelector<T, R | undefined | null | false>
226
+ ) {
227
+ const stepKey =
228
+ typeof arg1 === "string" ? arg1 : getCallSiteHash(this.when);
229
+ const selector = (
230
+ typeof arg1 === "string" ? arg2 : arg1
231
+ ) as StoreSelector<T, R | undefined | null | false>;
232
+
233
+ return whenStep({
234
+ definition,
235
+ id,
236
+ event,
237
+ storeClient,
238
+ stepKey,
239
+ selector,
240
+ });
241
+ },
242
+ take<R>(
243
+ stepKey: string,
244
+ selector: StoreSelector<T, R | undefined | null | false>,
245
+ claim: (draft: Draft<T>, selected: NonNullable<R>) => void
246
+ ) {
247
+ return takeStep({
248
+ definition,
249
+ id,
250
+ event,
251
+ storeClient,
252
+ stepKey,
253
+ selector,
254
+ claim,
255
+ });
256
+ },
257
+ };
258
+ }
23
259
 
24
260
  function run<T extends any>(
25
261
  fn: () => T | Promise<T>,
@@ -30,7 +266,13 @@ function run<T extends any>(
30
266
  fn: () => T | Promise<T>,
31
267
  ): AsyncGenerator<StepResponse, T, StepResult | StepError>;
32
268
 
33
- async function* run<T extends any>(
269
+ // NOTE: run/delay/poll are plain (synchronous) functions that delegate to
270
+ // async generators. This matters for keyless steps: the call-site hash must be
271
+ // captured while the user's call site is still on the stack. An async
272
+ // generator body doesn't execute until its first .next(), which happens inside
273
+ // the workflow driver, so hashing there would give every keyless step the same
274
+ // key.
275
+ function run<T extends any>(
34
276
  arg1: string | (() => T | Promise<T>),
35
277
  arg2?: (() => T | Promise<T>) | string,
36
278
  ): AsyncGenerator<StepResponse, T, StepResult | StepError> {
@@ -46,41 +288,13 @@ async function* run<T extends any>(
46
288
 
47
289
  if (!key) key = getCallSiteHash(run);
48
290
 
49
- yield new StepKey(key);
50
-
51
- const cached = yield new StepCacheCheck();
52
-
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;
60
- }
61
-
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 {
73
- yield new StepError(err);
74
- }
75
- // unreachable – consumer calls throw() on the generator first
76
- throw err;
77
- }
291
+ return durableStep(key, fn);
78
292
  }
79
293
 
80
294
  function delay(retryInterval: number): any;
81
295
  function delay(key: string, retryInterval: number): any;
82
296
 
83
- async function* delay(
297
+ function delay(
84
298
  arg1: string | number,
85
299
  arg2?: number
86
300
  ): AsyncGenerator<any, void, StepDelay> {
@@ -96,6 +310,13 @@ async function* delay(
96
310
 
97
311
  if (!key) key = getCallSiteHash(delay);
98
312
 
313
+ return delayStep(key, retryInterval);
314
+ }
315
+
316
+ async function* delayStep(
317
+ key: string,
318
+ retryInterval: number
319
+ ): AsyncGenerator<any, void, StepDelay> {
99
320
  yield new StepKey(key);
100
321
 
101
322
  const cached = yield new StepCacheCheck();
@@ -113,7 +334,7 @@ type PollPredicate = () => boolean | Promise<boolean>;
113
334
  function poll(opts: PollOpts, predicate: PollPredicate): any;
114
335
  function poll(key: string, opts: PollOpts, predicate: PollPredicate): any;
115
336
 
116
- async function* poll(
337
+ function poll(
117
338
  arg1: string | PollOpts,
118
339
  arg2: PollOpts | PollPredicate,
119
340
  arg3?: PollPredicate
@@ -142,5 +363,155 @@ async function* poll(
142
363
  }
143
364
  };
144
365
 
145
- yield* run(key, task, );
366
+ return run(key, task);
367
+ }
368
+
369
+ async function* durableStep<T extends any>(
370
+ key: string,
371
+ fn: () => T | Promise<T>
372
+ ): AsyncGenerator<StepResponse, T, StepResult | StepError> {
373
+ yield new StepKey(key);
374
+
375
+ const cached = yield new StepCacheCheck();
376
+
377
+ if (cached) {
378
+ yield cached;
379
+ if (cached instanceof StepError) {
380
+ // unreachable – consumer calls throw() on the generator first
381
+ throw cached.err;
382
+ }
383
+ return cached.result;
384
+ }
385
+
386
+ try {
387
+ const result = await fn();
388
+ yield new StepResult(result);
389
+ return result;
390
+ } catch (err: unknown) {
391
+ if (err instanceof RetryableError) {
392
+ yield new StepError(err, {
393
+ maxAttempts: err.maxAttempts,
394
+ retryInterval: err.retryInterval,
395
+ });
396
+ } else {
397
+ yield new StepError(err);
398
+ }
399
+ // unreachable – consumer calls throw() on the generator first
400
+ throw err;
401
+ }
402
+ }
403
+
404
+ async function* whenStep<T, R>(params: {
405
+ definition: StoreDefinition<any>;
406
+ id: string;
407
+ event: WorkflowEvent;
408
+ storeClient: StoreClient;
409
+ stepKey: string;
410
+ selector: StoreSelector<T, R | undefined | null | false>;
411
+ }): AsyncGenerator<StepResponse, NonNullable<R>, StepResult | StepStoreWait> {
412
+ const { definition, id, event, storeClient, stepKey, selector } = params;
413
+
414
+ yield new StepKey(stepKey);
415
+
416
+ const cached = yield new StepCacheCheck();
417
+
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
+ }
425
+
426
+ const snapshot = await storeClient.getStore({ definition, id });
427
+ const { result, readPaths } = trackStoreSelector(
428
+ cloneStoreState(snapshot.state as T),
429
+ selector
430
+ );
431
+
432
+ if (result !== undefined && result !== null && result !== false) {
433
+ const clonedResult = cloneStoreState(result);
434
+ yield new StepResult(clonedResult);
435
+ return clonedResult as NonNullable<R>;
436
+ }
437
+
438
+ await storeClient.registerWaiter({
439
+ workflowId: event.workflowId,
440
+ executionId: event.executionId,
441
+ stepKey,
442
+ event,
443
+ storeName: definition.name,
444
+ storeId: id,
445
+ instanceId: snapshot.instanceId,
446
+ sinceVersion: snapshot.version,
447
+ readPaths,
448
+ });
449
+
450
+ yield new StepStoreWait();
451
+
452
+ throw new Error("Store wait yielded control unexpectedly");
453
+ }
454
+
455
+ async function* takeStep<T, R>(params: {
456
+ definition: StoreDefinition<any>;
457
+ id: string;
458
+ event: WorkflowEvent;
459
+ storeClient: StoreClient;
460
+ stepKey: string;
461
+ selector: StoreSelector<T, R | undefined | null | false>;
462
+ 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;
466
+
467
+ yield new StepKey(stepKey);
468
+
469
+ const cached = yield new StepCacheCheck();
470
+
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
+ }
478
+
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
+ }
496
+
497
+ if (outcome.matched) {
498
+ yield new StepResult(outcome.selected);
499
+ return outcome.selected as NonNullable<R>;
500
+ }
501
+
502
+ await storeClient.registerWaiter({
503
+ workflowId: event.workflowId,
504
+ executionId: event.executionId,
505
+ stepKey,
506
+ event,
507
+ storeName: definition.name,
508
+ storeId: id,
509
+ instanceId: outcome.instanceId,
510
+ sinceVersion: outcome.version,
511
+ readPaths: outcome.readPaths,
512
+ });
513
+
514
+ yield new StepStoreWait();
515
+
516
+ throw new Error("Store take yielded control unexpectedly");
146
517
  }
@@ -7,19 +7,33 @@ export const isIterable = <T>(i: T | Iterable<T>): i is Iterable<T> => {
7
7
  };
8
8
 
9
9
 
10
- export function getCallSiteHash(stepFn: Function) {
11
- const originalStack = (stepFn as any).stack;
10
+ /**
11
+ * Derives a replay-stable cache key from the call site of a keyless step.
12
+ *
13
+ * IMPORTANT: `stepFn` must be the synchronously-executing function that the
14
+ * user called directly (e.g. the `step.run` wrapper) – NOT an async generator
15
+ * body. Async generator bodies don't run until the first `.next()`, by which
16
+ * point the user's frame is no longer on the stack. `Error.captureStackTrace`
17
+ * omits every frame above (and including) `stepFn`, so the first captured
18
+ * frame is the user's call site (file:line:column), which is deterministic
19
+ * across executions of the same workflow code.
20
+ */
21
+ export function getCallSiteHash(stepFn: Function): string {
22
+ const holder: { stack?: string } = {};
12
23
 
13
- Error.captureStackTrace(stepFn, getCallSiteHash);
24
+ Error.captureStackTrace(holder, stepFn);
14
25
 
15
- const stack = (stepFn as any).stack
16
- const stepFnCallSite = stack.split(')')[1]
26
+ const frames = (holder.stack ?? "")
27
+ .split("\n")
28
+ .filter((line) => /^\s*at\s/.test(line));
17
29
 
18
- if (originalStack === undefined) {
19
- delete (stepFn as any).stack;
20
- } else {
21
- (stepFn as any).stack = originalStack;
30
+ const callSite = frames[0]?.trim();
31
+
32
+ if (!callSite) {
33
+ throw new Error(
34
+ "Could not derive a cache key from the call site. Pass an explicit key to this step."
35
+ );
22
36
  }
23
37
 
24
- return createHash("sha1").update(stepFnCallSite).digest("hex");
38
+ return createHash("sha1").update(callSite).digest("hex");
25
39
  }