yieldstar 0.4.4 → 0.4.6

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.
package/README.md ADDED
@@ -0,0 +1,408 @@
1
+ # YieldStar 🤘
2
+
3
+ JavaScript‑native distributed workflows.
4
+
5
+ Write workflows as async generator functions that yield steps. Use first‑class primitives for retries, delays, and polling. .
6
+
7
+ Currently supports running on Bun and Sqlite. Postgres support will be added next.
8
+
9
+ ## Install
10
+
11
+ Install the core package:
12
+
13
+ ```sh
14
+ bun add yieldstar
15
+ ```
16
+
17
+ Depending on how you run workflows, also install:
18
+
19
+ - Local (recommended): `@yieldstar/bun-worker-invoker` and `@yieldstar/bun-sqlite-runtime`
20
+ - HTTP server (Bun): `@yieldstar/bun-http-server` and `@yieldstar/bun-worker-invoker`
21
+
22
+ Note: `@yieldstar/test-utils` is an internal testing helper used by this repository. For local application use, prefer the SQLite runtime.
23
+
24
+ ## Quick Start: Local (SQLite)
25
+
26
+ Run a workflow locally with persistence and timers using a Bun worker and the SQLite runtime.
27
+
28
+ ```ts
29
+ // router.ts
30
+ import { workflow, createWorkflowRouter } from "yieldstar";
31
+
32
+ export const simple = workflow(async function* (step, event, logger) {
33
+ const n = yield* step.run(() => 1);
34
+ yield* step.delay(1000);
35
+ return yield* step.run(() => n * 2);
36
+ });
37
+
38
+ export const router = createWorkflowRouter({ "simple-workflow": simple });
39
+ export type Router = typeof router;
40
+ ```
41
+
42
+ ```ts
43
+ // worker.ts
44
+ import pino from "pino";
45
+ import { WorkflowRunner } from "@yieldstar/core";
46
+ import { createWorkflowWorker } from "@yieldstar/bun-worker-invoker";
47
+ import { SqliteHeapClient, SqliteTimersClient, SqliteTaskQueueClient, SqliteSchedulerClient, createSqliteDb } from "@yieldstar/bun-sqlite-runtime";
48
+ import { router } from "./router";
49
+
50
+ const logger = pino();
51
+ const db = createSqliteDb({ path: "./.db/local.sqlite" });
52
+
53
+ const runner = new WorkflowRunner({
54
+ router,
55
+ heapClient: new SqliteHeapClient(db),
56
+ schedulerClient: new SqliteSchedulerClient({
57
+ taskQueueClient: new SqliteTaskQueueClient(db),
58
+ timersClient: new SqliteTimersClient(db),
59
+ }),
60
+ logger,
61
+ });
62
+
63
+ createWorkflowWorker(runner, logger).listen();
64
+ ```
65
+
66
+ ```ts
67
+ // app.ts
68
+ import pino from "pino";
69
+ import { createWorkflowInvoker } from "@yieldstar/bun-worker-invoker";
70
+ import { createLocalSdk } from "yieldstar";
71
+ import { SqliteEventLoop, createSqliteDb } from "@yieldstar/bun-sqlite-runtime";
72
+
73
+ const logger = pino();
74
+ const workerPath = new URL("./worker.ts", import.meta.url).href;
75
+ const invoker = createWorkflowInvoker({ workerPath, logger });
76
+
77
+ const db = createSqliteDb({ path: "./.db/local.sqlite" });
78
+ const loop = new SqliteEventLoop(db);
79
+ loop.start({ onNewEvent: invoker.execute, logger });
80
+
81
+ const sdk = createLocalSdk<import("./router").Router>(invoker);
82
+ const result = await sdk.triggerAndWait({ workflowId: "simple-workflow" });
83
+ console.log(result); // 2
84
+ ```
85
+
86
+ ## Pick Your Runtime
87
+
88
+ ### Option A: Local Worker + SQLite (single process)
89
+
90
+ Use a Bun worker to execute steps and a SQLite‑backed runtime for persistence, timers, and a task queue.
91
+
92
+ 1) Define workflows and a router:
93
+
94
+ ```ts
95
+ import { workflow, createWorkflowRouter } from "yieldstar";
96
+
97
+ export const dynamic = workflow<{ msg: string }, void>(async function* (step, event, logger) {
98
+ const n = yield* step.run(() => Math.random());
99
+ yield* step.delay(1000);
100
+ yield* step.run(() => logger.info(`Hello ${event.params.msg}: ${n}`));
101
+ });
102
+
103
+ export const router = createWorkflowRouter({ "dynamic-workflow": dynamic });
104
+ ```
105
+
106
+ 2) Create worker and runtime wiring (worker thread):
107
+
108
+ ```ts
109
+ // worker.ts
110
+ import pino from "pino";
111
+ import { WorkflowRunner } from "@yieldstar/core";
112
+ import { createWorkflowWorker } from "@yieldstar/bun-worker-invoker";
113
+ import { SqliteHeapClient, SqliteTimersClient, SqliteTaskQueueClient, SqliteSchedulerClient } from "@yieldstar/bun-sqlite-runtime";
114
+ import { router } from "./router";
115
+ import { createSqliteDb } from "@yieldstar/bun-sqlite-runtime";
116
+
117
+ const logger = pino();
118
+ const db = createSqliteDb({ path: "./.db/local.sqlite" });
119
+
120
+ const runner = new WorkflowRunner({
121
+ router,
122
+ heapClient: new SqliteHeapClient(db),
123
+ schedulerClient: new SqliteSchedulerClient({
124
+ taskQueueClient: new SqliteTaskQueueClient(db),
125
+ timersClient: new SqliteTimersClient(db),
126
+ }),
127
+ logger,
128
+ });
129
+
130
+ createWorkflowWorker(runner, logger).listen();
131
+ ```
132
+
133
+ 3) Drive the worker from your app and call workflows locally:
134
+
135
+ ```ts
136
+ // app.ts
137
+ import pino from "pino";
138
+ import { createWorkflowInvoker } from "@yieldstar/bun-worker-invoker";
139
+ import { createLocalSdk } from "yieldstar";
140
+ import { SqliteEventLoop, createSqliteDb } from "@yieldstar/bun-sqlite-runtime";
141
+
142
+ const logger = pino();
143
+ const workerPath = new URL("./worker.ts", import.meta.url).href;
144
+ const invoker = createWorkflowInvoker({ workerPath, logger });
145
+
146
+ const db = createSqliteDb({ path: "./.db/local.sqlite" });
147
+ const eventLoop = new SqliteEventLoop(db);
148
+ eventLoop.start({ onNewEvent: invoker.execute, logger });
149
+
150
+ const sdk = createLocalSdk<{ "dynamic-workflow": any }>(invoker);
151
+ const res = await sdk.triggerAndWait({ workflowId: "dynamic-workflow", params: { msg: "hello" } });
152
+ console.log(res);
153
+ ```
154
+
155
+ See a working reference in `examples/local-execution/`.
156
+
157
+ ### Option B: HTTP Server (Bun)
158
+
159
+ Expose routes to trigger and await workflow results. Use the HTTP SDK as a client.
160
+
161
+ Server:
162
+
163
+ ```ts
164
+ import pino from "pino";
165
+ import { createRoutes, createMiddleware } from "@yieldstar/bun-http-server";
166
+ import { createWorkflowInvoker } from "@yieldstar/bun-worker-invoker";
167
+ import { SqliteEventLoop, createSqliteDb } from "@yieldstar/bun-sqlite-runtime";
168
+ import { router } from "./router";
169
+
170
+ const logger = pino();
171
+ const workerPath = new URL("./worker.ts", import.meta.url).href;
172
+ const invoker = createWorkflowInvoker({ workerPath, logger });
173
+
174
+ const db = createSqliteDb({ path: "./.db/http.sqlite" });
175
+ new SqliteEventLoop(db).start({ onNewEvent: invoker.execute, logger });
176
+
177
+ const auth = createMiddleware(async (req, event, next) => {
178
+ if (!req.headers.get("Authorization")) return new Response("Unauthorized", { status: 401 });
179
+ return next();
180
+ });
181
+
182
+ Bun.serve({
183
+ port: 8080,
184
+ routes: { "/status": new Response("OK"), ...createRoutes({ invoker, logger, middleware: [auth] }) },
185
+ });
186
+ ```
187
+
188
+ Client:
189
+
190
+ ```ts
191
+ import { createHttpSdkFactory } from "yieldstar";
192
+ const createSdk = createHttpSdkFactory<{ "dynamic-workflow": any }>();
193
+ const sdk = createSdk({ url: "http://localhost:8080" });
194
+
195
+ const exec = await sdk.trigger({ workflowId: "dynamic-workflow", params: { msg: "world" } });
196
+ await exec.ack();
197
+ const result = await exec.waitForResult();
198
+ ```
199
+
200
+ See `examples/http-server/` for a complete setup.
201
+
202
+ ## Patterns You’ll Use
203
+
204
+ ### Parameters
205
+
206
+ Pass data into a run and access it inside the workflow via `event.params`.
207
+
208
+ ```ts
209
+ const result = await sdk.triggerAndWait({ workflowId: "w", params: { userId: "123" } });
210
+
211
+ export const w = workflow(async function* (step, event, logger) {
212
+ logger.info("user:", event.params?.userId);
213
+ return yield* step.run(() => event.params);
214
+ });
215
+ ```
216
+
217
+ See: `test/params.test.ts`.
218
+
219
+ ### Retries
220
+
221
+ Throw `RetryableError` inside `step.run` to retry the step with the configured policy.
222
+
223
+ ```ts
224
+ import { RetryableError } from "yieldstar";
225
+
226
+ yield* step.run(async () => {
227
+ // e.g. flaky fetch
228
+ throw new RetryableError("temporary", { maxAttempts: 4, retryInterval: 1000 });
229
+ });
230
+ ```
231
+
232
+ See: `test/retries.test.ts`.
233
+
234
+ ### Delays and Resumption
235
+
236
+ Pause until a time in the future and automatically resume.
237
+
238
+ ```ts
239
+ yield* step.delay(5000);
240
+ ```
241
+
242
+ See: `test/async.test.ts`.
243
+
244
+ ### Polling
245
+
246
+ Keep checking a predicate until it passes or times out using structured retries.
247
+
248
+ ```ts
249
+ yield* step.poll({ retryInterval: 1000, maxAttempts: 10 }, () => isReady());
250
+ ```
251
+
252
+ See: `test/polling.test.ts`.
253
+
254
+ ### Cache Keys and Loops
255
+
256
+ In loops, add explicit cache keys so each iteration is distinct.
257
+
258
+ ```ts
259
+ for (let i = 0; i < 2; i++) {
260
+ yield* step.run(`step:${i}`, () => i);
261
+ }
262
+ ```
263
+
264
+ If omitted, loop detection will raise: “Each step in a loop must have a unique cache key.” See: `test/loop-detection.test.ts`, `test/cache-keys.test.ts`.
265
+
266
+ ### Custom Step‑Like Helpers
267
+
268
+ Compose your own helpers by yielding built‑in steps under the hood.
269
+
270
+ ```ts
271
+ const waitForState = (step: any, logger: any) => async function* (state: string) {
272
+ yield* step.poll({ maxAttempts: 10, retryInterval: 1000 }, () => /* check */ true);
273
+ };
274
+
275
+ export const coordinator = workflow(async function* (step) {
276
+ const a = yield* step.run(() => 2);
277
+ yield* (waitForState(step, console) as any)("enabled");
278
+ return yield* step.run(() => a * 3);
279
+ });
280
+ ```
281
+
282
+ See: `examples/workflows/coordinator.ts`.
283
+
284
+ ## API Reference
285
+
286
+ Top‑level exports from `yieldstar`:
287
+
288
+ ```ts
289
+ export { createLocalSdk } from "yieldstar";
290
+ export { createHttpSdkFactory } from "yieldstar";
291
+ export { createWorkflowRouter } from "yieldstar";
292
+ export { RetryableError } from "yieldstar";
293
+ export { createWorkflow, workflow } from "yieldstar";
294
+ export type { WorkflowFn } from "yieldstar";
295
+ ```
296
+
297
+ ### `workflow(fn)` / `createWorkflow(fn)`
298
+
299
+ Define a workflow. The function receives `(step, event, logger)` and must be an async generator that yields steps.
300
+
301
+ ```ts
302
+ type WorkflowFn<Params, Result, Context> = (
303
+ step: StepRunner,
304
+ event: WorkflowEvent<Params, Context>,
305
+ logger: Logger
306
+ ) => AsyncGenerator<any, Result>;
307
+ ```
308
+
309
+ Return type is inferred; type parameters allow strong typing of `params` and `result`.
310
+
311
+ ### Step Runner
312
+
313
+ Available on the first argument of a workflow:
314
+
315
+ ```ts
316
+ // run
317
+ yield* step.run(fn);
318
+ yield* step.run("key", fn);
319
+
320
+ // delay
321
+ yield* step.delay(ms);
322
+ yield* step.delay("key", ms);
323
+
324
+ // poll
325
+ yield* step.poll({ retryInterval, maxAttempts }, predicate);
326
+ yield* step.poll("key", { retryInterval, maxAttempts }, predicate);
327
+ ```
328
+
329
+ `RetryableError` inside `fn` signals structured retries:
330
+
331
+ ```ts
332
+ new RetryableError(message, { maxAttempts: number, retryInterval: number });
333
+ ```
334
+
335
+ ### Local SDK
336
+
337
+ Create a client that triggers workflows using an in‑process `WorkflowInvoker`.
338
+
339
+ ```ts
340
+ const sdk = createLocalSdk<Router>(invoker);
341
+ await sdk.triggerAndWait({ workflowId, params? });
342
+ ```
343
+
344
+ ### HTTP SDK
345
+
346
+ Factory returning a client bound to a base URL.
347
+
348
+ ```ts
349
+ const createSdk = createHttpSdkFactory<Router>();
350
+ const sdk = createSdk({ url, fetchOptions? });
351
+
352
+ const exec = await sdk.trigger({ workflowId, params?, context?, executionId? });
353
+ await exec.ack();
354
+ const result = await exec.waitForResult();
355
+ ```
356
+
357
+ ### Router
358
+
359
+ Register your workflows with IDs:
360
+
361
+ ```ts
362
+ const router = createWorkflowRouter({ "id": workflow, ... });
363
+ type Router = typeof router;
364
+ ```
365
+
366
+ Use `Router` to type your SDKs so `workflowId` and `params` are enforced.
367
+
368
+ ## Testing Your Workflows
369
+
370
+ For repository development we use an in‑memory runtime driven by `@yieldstar/test-utils` to `triggerAndWait` in tests. This package is internal; application users should test against the same runtime they use in production where possible. Example (internal):
371
+
372
+ ```ts
373
+ import { test, expect } from "bun:test";
374
+ import { createTestSdkFactory } from "@yieldstar/test-utils";
375
+ import { createWorkflow } from "yieldstar";
376
+
377
+ test("data flow between steps", async () => {
378
+ const w = createWorkflow(async function* (step) {
379
+ let n = yield* step.run(() => 1);
380
+ n = yield* step.run(() => n * 2);
381
+ return n;
382
+ });
383
+ const createSdk = createTestSdkFactory();
384
+ const sdk = createSdk({ workflow: w });
385
+ expect(await sdk.triggerAndWait({ workflowId: "workflow" })).toBe(2);
386
+ });
387
+ ```
388
+
389
+ See more examples in `test/*.test.ts`.
390
+
391
+ ## Notes
392
+
393
+ - In loops, always add cache keys. Missing keys trigger loop detection.
394
+ - `logger` is a Pino logger passed into workflows for observability.
395
+ - HTTP middleware can read/set `event.context` before invoking your workflow.
396
+
397
+ ## Contributing
398
+
399
+ - Please open an issue to discuss significant changes before submitting a PR.
400
+ - See CONTRIBUTING.md for repo layout, dev workflow, testing, and release details.
401
+
402
+ ## License
403
+
404
+ MIT
405
+
406
+ ## Author
407
+
408
+ [Daniel Grant](danielgrant.co)
@@ -1 +1 @@
1
- {"version":3,"file":"sdk-http.d.ts","sourceRoot":"","sources":["../../src/exports/sdk-http.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,aAAa,EACb,cAAc,EACd,YAAY,EAEZ,2BAA2B,EAC5B,MAAM,iBAAiB,CAAC;AAKzB,KAAK,UAAU,GAAG;IAChB,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,KAAK,eAAe,CAAC,CAAC,SAAS,cAAc,EAAE,CAAC,SAAS,MAAM,GAAG,MAAM,CAAC,IAAI;IAC3E,WAAW,EAAE,MAAM,CAAC;IACpB,GAAG,EAAE,MAAM,OAAO,CAAC,UAAU,CAAC,CAAC;IAC/B,aAAa,EAAE,MAAM,OAAO,CAAC,2BAA2B,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;CACjE,CAAC;AAEF,wBAAgB,oBAAoB,CAAC,CAAC,SAAS,cAAc,cAC3C;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,GAAG,QAAQ,CAAC,CAAC;CACrD;YAEiB,CAAC,SAAS,MAAM,GAAG,MAAM,CAAC,SAC/B,YAAY,CAAC,CAAC,EAAE,aAAa,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAC1C,OAAO,CAAC,eAAe,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;kBA2Bb,CAAC,SAAS,MAAM,GAAG,MAAM,CAAC,OACvC,UAAU,GACd,OAAO,CAAC,2BAA2B,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;EAoBlD"}
1
+ {"version":3,"file":"sdk-http.d.ts","sourceRoot":"","sources":["../../src/exports/sdk-http.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,aAAa,EACb,cAAc,EACd,YAAY,EAEZ,2BAA2B,EAC5B,MAAM,iBAAiB,CAAC;AAKzB,KAAK,UAAU,GAAG;IAChB,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,KAAK,eAAe,CAAC,CAAC,SAAS,cAAc,EAAE,CAAC,SAAS,MAAM,GAAG,MAAM,CAAC,IAAI;IAC3E,WAAW,EAAE,MAAM,CAAC;IACpB,GAAG,EAAE,MAAM,OAAO,CAAC,UAAU,CAAC,CAAC;IAC/B,aAAa,EAAE,MAAM,OAAO,CAAC,2BAA2B,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;CACjE,CAAC;AAEF,wBAAgB,oBAAoB,CAAC,CAAC,SAAS,cAAc,MACnD,QAAQ;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,GAAG,QAAQ,CAAC,CAAC;CACrD;YAEiB,CAAC,SAAS,MAAM,GAAG,MAAM,CAAC,SAC/B,YAAY,CAAC,CAAC,EAAE,aAAa,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAC1C,OAAO,CAAC,eAAe,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;kBA2Bb,CAAC,SAAS,MAAM,GAAG,MAAM,CAAC,OACvC,UAAU,GACd,OAAO,CAAC,2BAA2B,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;EAoBlD"}
package/dist/index.js CHANGED
@@ -84,9 +84,22 @@ class RetryableError extends Error {
84
84
  }
85
85
  }
86
86
  // src/internal/utils.ts
87
+ import { createHash } from "crypto";
87
88
  var isIterable = (i) => {
88
89
  return typeof i === "object" && i != null && Symbol.iterator in i;
89
90
  };
91
+ function getCallSiteHash(stepFn) {
92
+ const originalStack = stepFn.stack;
93
+ Error.captureStackTrace(stepFn, getCallSiteHash);
94
+ const stack = stepFn.stack;
95
+ const stepFnCallSite = stack.split(")")[1];
96
+ if (originalStack === undefined) {
97
+ delete stepFn.stack;
98
+ } else {
99
+ stepFn.stack = originalStack;
100
+ }
101
+ return createHash("sha1").update(stepFnCallSite).digest("hex");
102
+ }
90
103
 
91
104
  // src/internal/serialise.ts
92
105
  import {
@@ -146,6 +159,8 @@ async function* run(arg1, arg2) {
146
159
  } else {
147
160
  fn = arg1;
148
161
  }
162
+ if (!key)
163
+ key = getCallSiteHash(run);
149
164
  yield new StepKey(key);
150
165
  const cached = yield new StepCacheCheck;
151
166
  if (cached) {
@@ -180,6 +195,8 @@ async function* delay(arg1, arg2) {
180
195
  } else {
181
196
  retryInterval = arg1;
182
197
  }
198
+ if (!key)
199
+ key = getCallSiteHash(delay);
183
200
  yield new StepKey(key);
184
201
  const cached = yield new StepCacheCheck;
185
202
  if (cached) {
@@ -200,6 +217,8 @@ async function* poll(arg1, arg2, arg3) {
200
217
  opts = arg1;
201
218
  predicate = arg2;
202
219
  }
220
+ if (!key)
221
+ key = getCallSiteHash(poll);
203
222
  const task = async () => {
204
223
  if (!await predicate()) {
205
224
  throw new RetryableError("Polling reached max retries", {
@@ -208,11 +227,7 @@ async function* poll(arg1, arg2, arg3) {
208
227
  });
209
228
  }
210
229
  };
211
- if (key) {
212
- yield* run(key, task);
213
- } else {
214
- yield* run(task);
215
- }
230
+ yield* run(key, task);
216
231
  }
217
232
 
218
233
  // src/exports/workflow.ts
@@ -230,9 +245,9 @@ function workflow(workflowFn) {
230
245
  return async function* workflowGenerator({ event, heapClient, logger }) {
231
246
  const workflowIterator = workflowFn(stepRunner, event, logger);
232
247
  const { executionId } = event;
233
- let keylessStepIndex = -1;
234
248
  let iteratorResult = null;
235
249
  let nextIteratorResult = null;
250
+ const stepKeys = new Set;
236
251
  while (true) {
237
252
  let stepAttempt = 0;
238
253
  let stepKey;
@@ -246,11 +261,12 @@ function workflow(workflowFn) {
246
261
  stepKey = "$$workflow-result$$";
247
262
  } else if (!(iteratorResult.value instanceof StepKey2)) {
248
263
  throw new Error("Step runners must yield a StepKey object");
249
- } else if (iteratorResult.value.key) {
250
- stepKey = iteratorResult.value.key;
251
264
  } else {
252
- keylessStepIndex++;
253
- stepKey = `$$step-index-${keylessStepIndex}$$`;
265
+ stepKey = iteratorResult.value.key;
266
+ if (stepKeys.has(stepKey)) {
267
+ throw new Error("Each step in a loop must have a unique cache key.");
268
+ }
269
+ stepKeys.add(stepKey);
254
270
  }
255
271
  let cached = await heapClient.readStep({
256
272
  executionId,
@@ -1 +1 @@
1
- {"version":3,"file":"step-runner.d.ts","sourceRoot":"","sources":["../../src/internal/step-runner.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EAEZ,SAAS,EACT,UAAU,EAGX,MAAM,iBAAiB,CAAC;AAGzB;;;;;;;GAOG;AACH,eAAO,MAAM,UAAU;;;;CAAuB,CAAC;AAE/C,MAAM,MAAM,UAAU,GAAG,OAAO,UAAU,CAAC;AAE3C,iBAAS,GAAG,CAAC,CAAC,SAAS,GAAG,EACxB,EAAE,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,GACvB,cAAc,CAAC,YAAY,EAAE,CAAC,EAAE,UAAU,GAAG,SAAS,CAAC,CAAC;AAE3D,iBAAS,GAAG,CAAC,CAAC,SAAS,GAAG,EACxB,GAAG,EAAE,MAAM,EACX,EAAE,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,GACvB,cAAc,CAAC,YAAY,EAAE,CAAC,EAAE,UAAU,GAAG,SAAS,CAAC,CAAC;AA+C3D,iBAAS,KAAK,CAAC,aAAa,EAAE,MAAM,GAAG,GAAG,CAAC;AAC3C,iBAAS,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,GAAG,CAAC;AA2BxD,KAAK,QAAQ,GAAG;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAA;CAAE,CAAC;AAC/D,KAAK,aAAa,GAAG,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAEtD,iBAAS,IAAI,CAAC,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAE,aAAa,GAAG,GAAG,CAAC;AAC7D,iBAAS,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAE,aAAa,GAAG,GAAG,CAAC"}
1
+ {"version":3,"file":"step-runner.d.ts","sourceRoot":"","sources":["../../src/internal/step-runner.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EAEZ,SAAS,EACT,UAAU,EAGX,MAAM,iBAAiB,CAAC;AAIzB;;;;;;;GAOG;AACH,eAAO,MAAM,UAAU;;;;CAAuB,CAAC;AAE/C,MAAM,MAAM,UAAU,GAAG,OAAO,UAAU,CAAC;AAE3C,iBAAS,GAAG,CAAC,CAAC,SAAS,GAAG,EACxB,EAAE,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,GACvB,cAAc,CAAC,YAAY,EAAE,CAAC,EAAE,UAAU,GAAG,SAAS,CAAC,CAAC;AAE3D,iBAAS,GAAG,CAAC,CAAC,SAAS,GAAG,EACxB,GAAG,EAAE,MAAM,EACX,EAAE,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,GACvB,cAAc,CAAC,YAAY,EAAE,CAAC,EAAE,UAAU,GAAG,SAAS,CAAC,CAAC;AAiD3D,iBAAS,KAAK,CAAC,aAAa,EAAE,MAAM,GAAG,GAAG,CAAC;AAC3C,iBAAS,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,GAAG,CAAC;AA6BxD,KAAK,QAAQ,GAAG;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAA;CAAE,CAAC;AAC/D,KAAK,aAAa,GAAG,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAEtD,iBAAS,IAAI,CAAC,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAE,aAAa,GAAG,GAAG,CAAC;AAC7D,iBAAS,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAE,aAAa,GAAG,GAAG,CAAC"}
@@ -1,2 +1,3 @@
1
1
  export declare const isIterable: <T>(i: T | Iterable<T>) => i is Iterable<T>;
2
+ export declare function getCallSiteHash(stepFn: Function): string;
2
3
  //# sourceMappingURL=utils.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/internal/utils.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,UAAU,GAAI,CAAC,KAAK,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,KAAG,CAAC,IAAI,QAAQ,CAAC,CAAC,CAIjE,CAAC"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/internal/utils.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,UAAU,GAAI,CAAC,EAAE,GAAG,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,KAAG,CAAC,IAAI,QAAQ,CAAC,CAAC,CAIjE,CAAC;AAGF,wBAAgB,eAAe,CAAC,MAAM,EAAE,QAAQ,UAe/C"}
package/package.json CHANGED
@@ -1,17 +1,19 @@
1
1
  {
2
2
  "name": "yieldstar",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
+ "license": "MIT",
5
+ "author": "Daniel Grant",
4
6
  "type": "module",
5
7
  "main": "dist/index.js",
6
8
  "types": "dist/index.d.ts",
7
9
  "dependencies": {
8
- "nanoid": "^5.1.3",
9
- "pino": "^9.6.0",
10
+ "nanoid": "^5.1.5",
11
+ "pino": "^9.9.0",
10
12
  "serialize-error": "^11.0.3",
11
- "@yieldstar/core": "0.4.4"
13
+ "@yieldstar/core": "0.4.6"
12
14
  },
13
15
  "devDependencies": {
14
- "@types/node": "^22.13.10"
16
+ "@types/node": "^22.18.0"
15
17
  },
16
18
  "scripts": {
17
19
  "build": "bun build src/index.ts --outfile dist/index.js --target bun --packages=external"
@@ -49,9 +49,9 @@ export function workflow<
49
49
  const workflowIterator = workflowFn(stepRunner, event, logger);
50
50
  const { executionId } = event;
51
51
 
52
- let keylessStepIndex = -1;
53
52
  let iteratorResult: IteratorResult<any> | null = null;
54
53
  let nextIteratorResult: IteratorResult<any> | null = null;
54
+ const stepKeys = new Set<string>();
55
55
 
56
56
  while (true) {
57
57
  let stepAttempt = 0;
@@ -71,20 +71,20 @@ export function workflow<
71
71
 
72
72
  /**
73
73
  * Get the StepKey from the step runner.
74
- * If the StepKey value is null, assume deterministic ordering, and use
75
- * the step index as a key.
76
74
  * If the generator is done, the workflow has returned, so we set a
77
75
  * special key for that.
76
+ * Finally, check that this call site wasn't reached before.
78
77
  */
79
78
  if (iteratorResult.done) {
80
79
  stepKey = "$$workflow-result$$";
81
80
  } else if (!(iteratorResult.value instanceof StepKey)) {
82
81
  throw new Error("Step runners must yield a StepKey object");
83
- } else if (iteratorResult.value.key) {
84
- stepKey = iteratorResult.value.key;
85
82
  } else {
86
- keylessStepIndex++;
87
- stepKey = `$$step-index-${keylessStepIndex}$$`;
83
+ stepKey = iteratorResult.value.key;
84
+ if (stepKeys.has(stepKey)) {
85
+ throw new Error("Each step in a loop must have a unique cache key.");
86
+ }
87
+ stepKeys.add(stepKey)
88
88
  }
89
89
 
90
90
  /**
@@ -7,6 +7,7 @@ import {
7
7
  StepCacheCheck,
8
8
  } from "@yieldstar/core";
9
9
  import { RetryableError } from "../exports/errors";
10
+ import { getCallSiteHash } from "./utils";
10
11
 
11
12
  /**
12
13
  * @description A library of step generators, each of which:
@@ -21,28 +22,30 @@ export const stepRunner = { run, delay, poll };
21
22
  export type StepRunner = typeof stepRunner;
22
23
 
23
24
  function run<T extends any>(
24
- fn: () => T | Promise<T>
25
+ fn: () => T | Promise<T>,
25
26
  ): AsyncGenerator<StepResponse, T, StepResult | StepError>;
26
27
 
27
28
  function run<T extends any>(
28
29
  key: string,
29
- fn: () => T | Promise<T>
30
+ fn: () => T | Promise<T>,
30
31
  ): AsyncGenerator<StepResponse, T, StepResult | StepError>;
31
32
 
32
33
  async function* run<T extends any>(
33
34
  arg1: string | (() => T | Promise<T>),
34
- arg2?: () => T | Promise<T>
35
+ arg2?: (() => T | Promise<T>) | string,
35
36
  ): AsyncGenerator<StepResponse, T, StepResult | StepError> {
36
37
  let key: string | null = null;
37
38
  let fn: () => T | Promise<T>;
38
39
 
39
40
  if (typeof arg1 === "string") {
40
41
  key = arg1;
41
- fn = arg2!;
42
+ fn = arg2 as () => T | Promise<T>;
42
43
  } else {
43
44
  fn = arg1;
44
45
  }
45
46
 
47
+ if (!key) key = getCallSiteHash(run);
48
+
46
49
  yield new StepKey(key);
47
50
 
48
51
  const cached = yield new StepCacheCheck();
@@ -91,6 +94,8 @@ async function* delay(
91
94
  retryInterval = arg1;
92
95
  }
93
96
 
97
+ if (!key) key = getCallSiteHash(delay);
98
+
94
99
  yield new StepKey(key);
95
100
 
96
101
  const cached = yield new StepCacheCheck();
@@ -126,6 +131,8 @@ async function* poll(
126
131
  predicate = arg2 as PollPredicate;
127
132
  }
128
133
 
134
+ if (!key) key = getCallSiteHash(poll);
135
+
129
136
  const task = async () => {
130
137
  if (!(await predicate())) {
131
138
  throw new RetryableError("Polling reached max retries", {
@@ -135,9 +142,5 @@ async function* poll(
135
142
  }
136
143
  };
137
144
 
138
- if (key) {
139
- yield* run(key, task);
140
- } else {
141
- yield* run(task);
142
- }
145
+ yield* run(key, task, );
143
146
  }
@@ -1,5 +1,25 @@
1
+ import { createHash } from "crypto";
2
+
1
3
  export const isIterable = <T>(i: T | Iterable<T>): i is Iterable<T> => {
2
4
  return (
3
5
  typeof i === "object" && i != null && Symbol.iterator in (i as Iterable<T>)
4
6
  );
5
7
  };
8
+
9
+
10
+ export function getCallSiteHash(stepFn: Function) {
11
+ const originalStack = (stepFn as any).stack;
12
+
13
+ Error.captureStackTrace(stepFn, getCallSiteHash);
14
+
15
+ const stack = (stepFn as any).stack
16
+ const stepFnCallSite = stack.split(')')[1]
17
+
18
+ if (originalStack === undefined) {
19
+ delete (stepFn as any).stack;
20
+ } else {
21
+ (stepFn as any).stack = originalStack;
22
+ }
23
+
24
+ return createHash("sha1").update(stepFnCallSite).digest("hex");
25
+ }