runsheet 0.5.0 → 0.7.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.
package/README.md CHANGED
@@ -5,53 +5,181 @@
5
5
 
6
6
  Type-safe, composable business logic pipelines for TypeScript.
7
7
 
8
- Built on [composable-functions] for `Result` semantics and error handling.
8
+ ```typescript
9
+ import { step, pipeline } from 'runsheet';
10
+ import { z } from 'zod';
11
+
12
+ const validateOrder = step({
13
+ name: 'validateOrder',
14
+ requires: z.object({ orderId: z.string() }),
15
+ provides: z.object({
16
+ order: z.object({ id: z.string(), total: z.number() }),
17
+ }),
18
+ run: async (ctx) => ({ order: await db.orders.find(ctx.orderId) }),
19
+ });
20
+
21
+ const chargePayment = step({
22
+ name: 'chargePayment',
23
+ requires: z.object({ order: z.object({ total: z.number() }) }),
24
+ provides: z.object({ chargeId: z.string() }),
25
+ run: async (ctx) => {
26
+ const charge = await stripe.charges.create({ amount: ctx.order.total });
27
+ return { chargeId: charge.id };
28
+ },
29
+ rollback: async (_ctx, output) => {
30
+ await stripe.refunds.create({ charge: output.chargeId });
31
+ },
32
+ });
33
+
34
+ const placeOrder = pipeline({
35
+ name: 'placeOrder',
36
+ steps: [validateOrder, chargePayment],
37
+ });
38
+
39
+ const result = await placeOrder.run({ orderId: '123' });
40
+ // result.data.chargeId — fully typed, inferred from the steps
41
+ ```
9
42
 
10
43
  ## Why runsheet
11
44
 
12
45
  Business logic has a way of growing into tangled, hard-to-test code. A checkout
13
46
  flow starts as one function, then gains validation, payment processing,
14
- inventory reservation, email notifications, each with its own failure modes and
47
+ inventory reservation, email notifications each with its own failure modes and
15
48
  cleanup logic. Before long you're staring at a 300-line function with nested
16
49
  try/catch blocks and no clear way to reuse any of it.
17
50
 
18
51
  runsheet gives that logic structure. You break work into small, focused steps
19
52
  with explicit inputs and outputs, then compose them into pipelines. Each step is
20
- independently testable. The pipeline handles context passing, rollback on
21
- failure, and schema validation at every boundary. TypeScript enforces that steps
22
- fit together correctly at compile time, and immutable data semantics mean steps
23
- can't accidentally interfere with each other.
24
-
25
- It's an organizational layer for business logic that encourages reuse,
26
- testability, type safety, and immutable data flow, without the overhead of a
27
- full effect system or workflow engine.
53
+ independently testable. The pipeline handles context accumulation args
54
+ persist, outputs merge along with rollback on failure and (optionally) schema
55
+ validation at step boundaries. TypeScript enforces that steps fit together at
56
+ compile time, and immutable context boundaries mean steps can't accidentally
57
+ interfere with each other.
58
+
59
+ runsheet is for **in-process business logic orchestration** multi-step flows
60
+ inside an application service. It is not a distributed workflow engine, job
61
+ queue, or durable execution runtime. That said, the two are complementary: a
62
+ runsheet pipeline works well as the business logic inside a [Temporal] activity
63
+ or a serverless function handler.
28
64
 
29
65
  The name takes its inspiration from the world of stage productions and live
30
66
  broadcast events. A runsheet is the document that sequences every cue, handoff,
31
67
  and contingency so the show runs smoothly. Same idea here: you define the steps,
32
68
  and runsheet makes sure they execute in order with clear contracts between them.
33
69
 
34
- ## What this is
70
+ ## When to use it
71
+
72
+ **Good fit:**
73
+
74
+ - Multi-step business flows — checkout, onboarding, provisioning, data import
75
+ - Operations that need compensating actions (rollback) on failure
76
+ - Reusable orchestration shared across handlers, jobs, and routes
77
+ - Logic where schema-checked boundaries between steps add confidence
78
+ - Anywhere you'd otherwise write a long imperative function with a growing
79
+ number of intermediate variables and try/catch blocks
80
+
81
+ **Not the right tool:**
82
+
83
+ - Trivial one-off functions that don't benefit from step decomposition
84
+ - Long-running durable workflows that survive process restarts — use [Temporal]
85
+ or [Inngest]
86
+ - Cross-service event choreography — use a message bus
87
+ - Simple CRUD handlers with no orchestration complexity
88
+
89
+ ## What you get
90
+
91
+ ### Typed accumulated context
92
+
93
+ Args persist and outputs accumulate. Initial arguments flow through the entire
94
+ pipeline, each step's output merges into the context, and every step sees the
95
+ full picture of everything before it. TypeScript infers the accumulated type
96
+ from the steps you pass — `result.data` is a concrete intersection, not
97
+ `Record<string, unknown>`.
98
+
99
+ ### Immutable step boundaries
100
+
101
+ Context is frozen (`Object.freeze`) at every step boundary — this is a
102
+ guarantee, not an implementation detail. Each step receives a read-only snapshot
103
+ and returns only what it adds. Steps cannot mutate shared pipeline state; the
104
+ pipeline engine manages accumulation. This eliminates a class of bugs where step
105
+ B accidentally corrupts step A's data, and it makes rollback reliable because
106
+ each handler receives the exact snapshot from before the step ran.
35
107
 
36
- Args persist and outputs accumulate. That's the core model — initial arguments
37
- flow through the entire pipeline, each step's output merges into the context,
38
- and every step sees the full picture of everything before it.
108
+ ### Two modes of type safety
39
109
 
40
- A pipeline orchestration library with:
110
+ **TypeScript generics only** compile-time composition safety, no runtime cost:
41
111
 
42
- - **Strongly typed steps** — each step's `run`, `rollback`, `requires`, and
43
- `provides` carry concrete types. Your IDE shows exact input and output shapes
44
- on hover. Both sync and async `run` functions are supported.
45
- - **Type-safe accumulated context** — each step declares what it requires and
46
- provides. TypeScript enforces at compile time that requirements are satisfied,
47
- and `buildPipeline` infers the full output type from the steps you pass.
48
- - **Immutable step boundaries** — context is frozen between steps. Each step
49
- receives a snapshot and returns only what it adds.
50
- - **Rollback with snapshots** — on failure, rollback handlers execute in reverse
51
- order. Each receives the pre-step context and the step's output.
52
- - **Middleware**cross-cutting concerns (logging, timing, metrics) wrap the
53
- full step lifecycle.
54
- - **Standalone** — no framework dependencies. Works anywhere TypeScript runs.
112
+ ```typescript
113
+ const logOrder = step<{ order: { id: string } }, { loggedAt: Date }>({
114
+ name: 'logOrder',
115
+ run: async (ctx) => {
116
+ console.log(`Processing order ${ctx.order.id}`);
117
+ return { loggedAt: new Date() };
118
+ },
119
+ });
120
+ ```
121
+
122
+ **Zod schemas** — adds runtime validation at step boundaries (requires and/or
123
+ provides):
124
+
125
+ ```typescript
126
+ const chargePayment = step({
127
+ name: 'chargePayment',
128
+ requires: z.object({ order: z.object({ total: z.number() }) }),
129
+ provides: z.object({ chargeId: z.string() }),
130
+ run: async (ctx) => {
131
+ const charge = await stripe.charges.create({ amount: ctx.order.total });
132
+ return { chargeId: charge.id };
133
+ },
134
+ });
135
+ ```
136
+
137
+ Mix and match freely — some steps can use schemas while others use generics
138
+ alone. Schemas are per-step, not all-or-nothing.
139
+
140
+ ### Rollback with snapshots
141
+
142
+ On failure, rollback handlers execute in reverse order. Each handler receives
143
+ the pre-step context snapshot and the step's output, so it knows exactly what to
144
+ undo:
145
+
146
+ ```typescript
147
+ const reserveInventory = step({
148
+ name: 'reserveInventory',
149
+ requires: z.object({ order: z.object({ items: z.array(z.string()) }) }),
150
+ provides: z.object({ reservationId: z.string() }),
151
+ run: async (ctx) => {
152
+ const reservation = await inventory.reserve(ctx.order.items);
153
+ return { reservationId: reservation.id };
154
+ },
155
+ rollback: async (_ctx, output) => {
156
+ await inventory.release(output.reservationId);
157
+ },
158
+ });
159
+ ```
160
+
161
+ Rollback is best-effort: if a handler throws, remaining rollbacks still execute.
162
+ The result includes a structured report:
163
+
164
+ ```typescript
165
+ if (!result.success) {
166
+ result.rollback.completed; // ['chargePayment', 'reserveInventory']
167
+ result.rollback.failed; // [{ step: 'sendNotification', error: Error }]
168
+ }
169
+ ```
170
+
171
+ ### Compared to alternatives
172
+
173
+ | Capability | Plain functions | Ad hoc orchestration | runsheet |
174
+ | ------------------------------- | --------------- | -------------------- | ------------------------- |
175
+ | Reusable business steps | Manual | Manual | Built-in |
176
+ | Typed accumulated context | — | Manual | Inferred from steps |
177
+ | Rollback / compensation | Manual | Manual | Automatic, snapshot-based |
178
+ | Schema validation at boundaries | — | Manual | Optional (Zod) |
179
+ | Middleware (logging, timing) | Manual | Manual | Built-in |
180
+ | Control-flow combinators | Manual | Manual | Built-in |
181
+ | Composable (nest pipelines) | Manual | Difficult | Pipelines are steps |
182
+ | Immutable context boundaries | — | Rarely | Always (`Object.freeze`) |
55
183
 
56
184
  ## Install
57
185
 
@@ -72,15 +200,13 @@ pnpm add runsheet
72
200
  ### Define steps
73
201
 
74
202
  Each step declares what it reads from context (`requires`) and what it adds
75
- (`provides`). Schemas are optional — you can use TypeScript generics alone.
76
-
77
- Step `run` functions can be sync or async — both are supported.
203
+ (`provides`). Step `run` functions can be sync or async.
78
204
 
79
205
  ```typescript
80
- import { defineStep } from 'runsheet';
206
+ import { step } from 'runsheet';
81
207
  import { z } from 'zod';
82
208
 
83
- const validateOrder = defineStep({
209
+ const validateOrder = step({
84
210
  name: 'validateOrder',
85
211
  requires: z.object({ orderId: z.string() }),
86
212
  provides: z.object({
@@ -93,7 +219,7 @@ const validateOrder = defineStep({
93
219
  },
94
220
  });
95
221
 
96
- const chargePayment = defineStep({
222
+ const chargePayment = step({
97
223
  name: 'chargePayment',
98
224
  requires: z.object({ order: z.object({ total: z.number() }) }),
99
225
  provides: z.object({ chargeId: z.string() }),
@@ -106,7 +232,7 @@ const chargePayment = defineStep({
106
232
  },
107
233
  });
108
234
 
109
- const sendConfirmation = defineStep({
235
+ const sendConfirmation = step({
110
236
  name: 'sendConfirmation',
111
237
  requires: z.object({
112
238
  order: z.object({ id: z.string() }),
@@ -120,16 +246,12 @@ const sendConfirmation = defineStep({
120
246
  });
121
247
  ```
122
248
 
123
- Each step is fully typed — your IDE (or other favorite typechecker) can see its
124
- exact input and output types, allowing you to compose steps that maintain type
125
- integrity from one step to the next.
126
-
127
249
  ### Build and run a pipeline
128
250
 
129
251
  ```typescript
130
- import { buildPipeline } from 'runsheet';
252
+ import { pipeline } from 'runsheet';
131
253
 
132
- const placeOrder = buildPipeline({
254
+ const placeOrder = pipeline({
133
255
  name: 'placeOrder',
134
256
  steps: [validateOrder, chargePayment, sendConfirmation],
135
257
  });
@@ -140,13 +262,75 @@ if (result.success) {
140
262
  console.log(result.data.chargeId); // string — fully typed
141
263
  console.log(result.data.sentAt); // Date
142
264
  } else {
143
- console.error(result.errors); // what went wrong
265
+ console.error(result.error); // what went wrong
144
266
  console.log(result.rollback); // { completed: [...], failed: [...] }
145
267
  }
146
268
  ```
147
269
 
148
- The pipeline's result type is inferred from the steps — `result.data` carries
149
- the intersection of all step outputs, not an erased `Record<string, unknown>`.
270
+ ### A second example: workspace onboarding
271
+
272
+ Steps compose across domains. The same patterns — typed inputs, rollback on
273
+ failure, accumulated context — apply to any multi-step flow:
274
+
275
+ ```typescript
276
+ const createWorkspace = step({
277
+ name: 'createWorkspace',
278
+ requires: z.object({ ownerEmail: z.string(), plan: z.string() }),
279
+ provides: z.object({ workspaceId: z.string() }),
280
+ run: async (ctx) => {
281
+ const ws = await db.workspaces.create({
282
+ owner: ctx.ownerEmail,
283
+ plan: ctx.plan,
284
+ });
285
+ return { workspaceId: ws.id };
286
+ },
287
+ rollback: async (_ctx, output) => {
288
+ await db.workspaces.delete(output.workspaceId);
289
+ },
290
+ });
291
+
292
+ const provisionResources = step({
293
+ name: 'provisionResources',
294
+ requires: z.object({ workspaceId: z.string(), plan: z.string() }),
295
+ provides: z.object({ bucketArn: z.string(), dbUrl: z.string() }),
296
+ run: async (ctx) => {
297
+ const infra = await provisioner.create(ctx.workspaceId, ctx.plan);
298
+ return { bucketArn: infra.bucketArn, dbUrl: infra.dbUrl };
299
+ },
300
+ rollback: async (_ctx, output) => {
301
+ await provisioner.teardown(output.bucketArn, output.dbUrl);
302
+ },
303
+ });
304
+
305
+ const onboardWorkspace = pipeline({
306
+ name: 'onboardWorkspace',
307
+ steps: [createWorkspace, provisionResources, sendWelcomeEmail],
308
+ });
309
+
310
+ const result = await onboardWorkspace.run({
311
+ ownerEmail: 'alice@co.com',
312
+ plan: 'team',
313
+ });
314
+ ```
315
+
316
+ If `sendWelcomeEmail` fails, provisioned resources are torn down and the
317
+ workspace is deleted — automatically, in reverse order.
318
+
319
+ ### Pipeline composition
320
+
321
+ Pipelines are steps — use one pipeline as a step in another:
322
+
323
+ ```typescript
324
+ const checkout = pipeline({
325
+ name: 'checkout',
326
+ steps: [validateOrder, chargePayment, sendConfirmation],
327
+ });
328
+
329
+ const fullFlow = pipeline({
330
+ name: 'fullFlow',
331
+ steps: [checkout, shipOrder, notifyWarehouse],
332
+ });
333
+ ```
150
334
 
151
335
  ### Builder API
152
336
 
@@ -154,13 +338,13 @@ For complex pipelines, the builder gives progressive type narrowing — each
154
338
  `.step()` call extends the known context type:
155
339
 
156
340
  ```typescript
157
- import { createPipeline } from 'runsheet';
341
+ import { pipeline } from 'runsheet';
158
342
  import { z } from 'zod';
159
343
 
160
- const placeOrder = createPipeline(
161
- 'placeOrder',
162
- z.object({ orderId: z.string() }),
163
- )
344
+ const placeOrder = pipeline({
345
+ name: 'placeOrder',
346
+ argsSchema: z.object({ orderId: z.string() }),
347
+ })
164
348
  .step(validateOrder) // context now includes order
165
349
  .step(chargePayment) // context now includes chargeId
166
350
  .step(sendConfirmation) // context now includes sentAt
@@ -170,34 +354,19 @@ const placeOrder = createPipeline(
170
354
  Type-only args (no runtime validation of pipeline input):
171
355
 
172
356
  ```typescript
173
- const placeOrder = createPipeline<{ orderId: string }>('placeOrder')
357
+ const placeOrder = pipeline<{ orderId: string }>({ name: 'placeOrder' })
174
358
  .step(validateOrder)
175
359
  .step(chargePayment)
176
360
  .step(sendConfirmation)
177
361
  .build();
178
362
  ```
179
363
 
180
- ### Generics-only steps
181
-
182
- Steps don't need Zod schemas — TypeScript generics provide compile-time safety
183
- without runtime validation at step boundaries:
184
-
185
- ```typescript
186
- const logOrder = defineStep<{ order: { id: string } }, { loggedAt: Date }>({
187
- name: 'logOrder',
188
- run: async (ctx) => {
189
- console.log(`Processing order ${ctx.order.id}`);
190
- return { loggedAt: new Date() };
191
- },
192
- });
193
- ```
194
-
195
364
  ### Conditional steps
196
365
 
197
366
  ```typescript
198
367
  import { when } from 'runsheet';
199
368
 
200
- const placeOrder = buildPipeline({
369
+ const placeOrder = pipeline({
201
370
  name: 'placeOrder',
202
371
  steps: [
203
372
  validateOrder,
@@ -208,15 +377,15 @@ const placeOrder = buildPipeline({
208
377
  });
209
378
  ```
210
379
 
211
- Skipped steps produce no snapshot, no rollback entry. The pipeline result tracks
212
- which steps were skipped in `result.meta.stepsSkipped`.
380
+ Skipped steps produce no snapshot, no rollback entry, and do not appear in
381
+ `result.meta.stepsExecuted`.
213
382
 
214
383
  ### Middleware
215
384
 
216
385
  Middleware wraps the entire step lifecycle including schema validation:
217
386
 
218
387
  ```typescript
219
- import { buildPipeline } from 'runsheet';
388
+ import { pipeline } from 'runsheet';
220
389
  import type { StepMiddleware } from 'runsheet';
221
390
 
222
391
  const timing: StepMiddleware = (step, next) => async (ctx) => {
@@ -233,7 +402,7 @@ const logging: StepMiddleware = (step, next) => async (ctx) => {
233
402
  return result;
234
403
  };
235
404
 
236
- const placeOrder = buildPipeline({
405
+ const placeOrder = pipeline({
237
406
  name: 'placeOrder',
238
407
  steps: [validateOrder, chargePayment, sendConfirmation],
239
408
  middleware: [logging, timing],
@@ -243,7 +412,7 @@ const placeOrder = buildPipeline({
243
412
  Middleware with the builder:
244
413
 
245
414
  ```typescript
246
- const placeOrder = createPipeline<{ orderId: string }>('placeOrder')
415
+ const placeOrder = pipeline<{ orderId: string }>({ name: 'placeOrder' })
247
416
  .use(logging, timing)
248
417
  .step(validateOrder)
249
418
  .step(chargePayment)
@@ -256,7 +425,7 @@ const placeOrder = createPipeline<{ orderId: string }>('placeOrder')
256
425
  Steps can declare retry policies and timeouts directly:
257
426
 
258
427
  ```typescript
259
- const callExternalApi = defineStep({
428
+ const callExternalApi = step({
260
429
  name: 'callExternalApi',
261
430
  provides: z.object({ response: z.string() }),
262
431
  retry: { count: 3, delay: 200, backoff: 'exponential' },
@@ -289,7 +458,7 @@ Run steps concurrently with `parallel()`. Outputs merge in array order:
289
458
  ```typescript
290
459
  import { parallel } from 'runsheet';
291
460
 
292
- const placeOrder = buildPipeline({
461
+ const placeOrder = pipeline({
293
462
  name: 'placeOrder',
294
463
  steps: [
295
464
  validateOrder,
@@ -310,7 +479,7 @@ No special mechanism needed — pass dependencies as pipeline args and they're
310
479
  available to every step through the accumulated context:
311
480
 
312
481
  ```typescript
313
- const chargePayment = defineStep({
482
+ const chargePayment = step({
314
483
  name: 'chargePayment',
315
484
  requires: z.object({
316
485
  order: z.object({ total: z.number() }),
@@ -323,16 +492,16 @@ const chargePayment = defineStep({
323
492
  },
324
493
  });
325
494
 
326
- const pipeline = createPipeline<{
495
+ const placeOrder = pipeline<{
327
496
  orderId: string;
328
497
  stripe: Stripe;
329
498
  db: Database;
330
- }>('placeOrder')
499
+ }>({ name: 'placeOrder' })
331
500
  .step(validateOrder)
332
501
  .step(chargePayment)
333
502
  .build();
334
503
 
335
- await pipeline.run({
504
+ await placeOrder.run({
336
505
  orderId: '123',
337
506
  stripe: stripeClient,
338
507
  db: dbClient,
@@ -352,7 +521,7 @@ Functions Choice state:
352
521
  ```typescript
353
522
  import { choice } from 'runsheet';
354
523
 
355
- const placeOrder = buildPipeline({
524
+ const placeOrder = pipeline({
356
525
  name: 'placeOrder',
357
526
  steps: [
358
527
  validateOrder,
@@ -368,162 +537,61 @@ const placeOrder = buildPipeline({
368
537
 
369
538
  Predicates are evaluated in order — first match wins. A bare step (without a
370
539
  tuple) can be passed as the last argument to serve as a default — equivalent to
371
- `[() => true, step]`. If no predicate matches, the step fails with a
372
- `CHOICE_NO_MATCH` error. Only the matched branch participates in rollback.
373
-
374
- ## Map (collection iteration)
375
-
376
- Iterate over a collection and run a function or step per item, concurrently —
377
- like an AWS Step Functions Map state:
378
-
379
- ```typescript
380
- import { map } from 'runsheet';
381
-
382
- // Function form — items can be any type
383
- const pipeline = buildPipeline({
384
- name: 'notify',
385
- steps: [
386
- map(
387
- 'emails',
388
- (ctx) => ctx.users,
389
- async (user) => {
390
- await sendEmail(user.email);
391
- return { email: user.email, sentAt: new Date() };
392
- },
393
- ),
394
- ],
395
- });
396
-
397
- // Step form — reuse existing steps
398
- const pipeline = buildPipeline({
399
- name: 'process',
400
- steps: [map('results', (ctx) => ctx.items, processItem)],
401
- });
402
- ```
540
+ `[() => true, step]`. If no predicate matches, the step is skipped (empty data,
541
+ no rollback entry). Only the matched branch participates in rollback.
403
542
 
404
- Items run concurrently via `Promise.allSettled`. Results are collected into an
405
- array under the given key. In step form, each item is spread into the pipeline
406
- context (`{ ...ctx, ...item }`) so the step sees both pipeline-level and
407
- per-item values. On partial failure, succeeded items are rolled back (step form
408
- only).
543
+ ## Distribute (collection fan-out)
409
544
 
410
- ### Filter (collection filtering)
545
+ Fan out a step over one or more context collections, binding each item to the
546
+ step's named scalar inputs. With multiple collections, `distribute` computes the
547
+ cross product and runs the step once per combination.
411
548
 
412
549
  ```typescript
413
- import { filter, map } from 'runsheet';
414
-
415
- const pipeline = buildPipeline({
416
- name: 'notify',
417
- steps: [
418
- filter(
419
- 'eligible',
420
- (ctx) => ctx.users,
421
- (user) => user.optedIn,
422
- ),
423
- map('emails', (ctx) => ctx.eligible, sendEmail),
424
- ],
425
- });
550
+ import { distribute } from 'runsheet';
426
551
 
427
- // Async predicate
428
- filter(
429
- 'valid',
430
- (ctx) => ctx.orders,
431
- async (order) => {
432
- const inventory = await checkInventory(order.sku);
433
- return inventory.available >= order.quantity;
434
- },
435
- );
436
- ```
437
-
438
- Predicates run concurrently via `Promise.allSettled`. Original order is
439
- preserved. If any predicate throws, the step fails. No rollback (filtering is a
440
- pure operation).
441
-
442
- ### FlatMap (collection expansion)
443
-
444
- ```typescript
445
- import { flatMap } from 'runsheet';
446
-
447
- const pipeline = buildPipeline({
448
- name: 'process',
449
- steps: [
450
- flatMap(
451
- 'lineItems',
452
- (ctx) => ctx.orders,
453
- (order) => order.items,
454
- ),
455
- ],
456
- });
457
-
458
- // Async callback
459
- flatMap(
552
+ // Single collection — run sendEmail once per accountId
553
+ const sendEmails = distribute(
460
554
  'emails',
461
- (ctx) => ctx.teams,
462
- async (team) => {
463
- const members = await fetchMembers(team.id);
464
- return members.map((m) => m.email);
465
- },
555
+ { accountIds: 'accountId' },
556
+ sendEmailStep,
466
557
  );
558
+ // Requires: { orgId: string, accountIds: string[] }
559
+ // Provides: { emails: { emailId: string }[] }
560
+
561
+ // Cross product — run once per (accountId, regionId) pair
562
+ const generateReports = distribute(
563
+ 'reports',
564
+ { accountIds: 'accountId', regionIds: 'regionId' },
565
+ generateReportStep,
566
+ );
567
+ // 2 accounts × 3 regions = 6 concurrent executions
467
568
  ```
468
569
 
469
- Maps each item to an array, then flattens one level. Callbacks run concurrently
470
- via `Promise.allSettled`. If any callback throws, the step fails. No rollback
471
- (pure operation).
472
-
473
- ## Rollback
474
-
475
- When a step fails, rollback handlers for all previously completed steps execute
476
- in reverse order. Each handler receives the pre-step context snapshot and the
477
- step's output:
478
-
479
- ```typescript
480
- const reserveInventory = defineStep({
481
- name: 'reserveInventory',
482
- requires: z.object({ order: z.object({ items: z.array(z.string()) }) }),
483
- provides: z.object({ reservationId: z.string() }),
484
- run: async (ctx) => {
485
- const reservation = await inventory.reserve(ctx.order.items);
486
- return { reservationId: reservation.id };
487
- },
488
- rollback: async (_ctx, output) => {
489
- await inventory.release(output.reservationId);
490
- },
491
- });
492
- ```
493
-
494
- Rollback is best-effort: if a rollback handler throws, remaining rollbacks still
495
- execute. The result includes a structured report:
496
-
497
- ```typescript
498
- if (!result.success) {
499
- result.rollback.completed; // ['chargePayment', 'reserveInventory']
500
- result.rollback.failed; // [{ step: 'sendNotification', error: Error }]
501
- }
502
- ```
570
+ The mapping object connects context array keys to the step's scalar input keys.
571
+ All non-mapped context keys pass through unchanged. Items run concurrently and
572
+ support partial-failure rollback.
503
573
 
504
- ## Pipeline result
574
+ ## Step result
505
575
 
506
- Every pipeline returns a `PipelineResult` with execution metadata:
576
+ Every `run()` returns a `StepResult` with execution metadata:
507
577
 
508
578
  ```typescript
509
579
  // Success
510
580
  {
511
581
  success: true,
512
582
  data: { /* accumulated context — fully typed */ },
513
- errors: [],
514
583
  meta: {
515
- pipeline: 'placeOrder',
584
+ name: 'placeOrder',
516
585
  args: { orderId: '123' },
517
586
  stepsExecuted: ['validateOrder', 'chargePayment', 'sendConfirmation'],
518
- stepsSkipped: [],
519
587
  }
520
588
  }
521
589
 
522
590
  // Failure
523
591
  {
524
592
  success: false,
525
- errors: [Error],
526
- meta: { pipeline, args, stepsExecuted, stepsSkipped },
593
+ error: Error,
594
+ meta: { name, args, stepsExecuted },
527
595
  failedStep: 'chargePayment',
528
596
  rollback: { completed: [...], failed: [...] },
529
597
  }
@@ -531,7 +599,7 @@ Every pipeline returns a `PipelineResult` with execution metadata:
531
599
 
532
600
  ## API reference
533
601
 
534
- ### `defineStep(config)`
602
+ ### `step(config)`
535
603
 
536
604
  Define a pipeline step. Returns a strongly typed `TypedStep` — `run`,
537
605
  `rollback`, `requires`, and `provides` all carry concrete types matching the
@@ -547,42 +615,25 @@ schemas or generics you provide.
547
615
  | `retry` | `RetryPolicy` | Optional retry policy for transient failures |
548
616
  | `timeout` | `number` | Optional max duration in ms for the `run` function |
549
617
 
550
- ### `buildPipeline(config)`
618
+ ### `pipeline(config)`
551
619
 
552
- Build a pipeline from an array of steps. The result type is inferred from the
553
- steps `pipeline.run()` returns a `PipelineResult` whose `data` is the
554
- intersection of all step output types.
620
+ Create a pipeline. When `steps` is provided, returns an `AggregateStep`
621
+ immediately. When `steps` is omitted, returns a `PipelineBuilder` with
622
+ `.step()`, `.use()`, and `.build()` for progressive type narrowing.
555
623
 
556
624
  | Option | Type | Description |
557
625
  | ------------ | ------------------ | ----------------------------------------------------------------- |
558
626
  | `name` | `string` | Pipeline name |
559
- | `steps` | `Step[]` | Steps to execute in order |
627
+ | `steps` | `Step[]` | Steps to execute in order (omit for builder mode) |
560
628
  | `middleware` | `StepMiddleware[]` | Optional middleware |
561
629
  | `argsSchema` | `ZodSchema` | Optional schema for pipeline input validation |
562
630
  | `strict` | `boolean` | Optional — throws at build time if two steps provide the same key |
563
631
 
564
- ### `createPipeline(name, argsSchema?, options?)`
565
-
566
- Start a fluent pipeline builder. Returns a `PipelineBuilder` with:
567
-
568
- - `.step(step)` — add a step
569
- - `.use(...middleware)` — add middleware
570
- - `.build()` — produce the pipeline
571
-
572
- The second argument accepts a schema (for runtime args validation) or an options
573
- object:
574
-
575
- ```typescript
576
- createPipeline('order', z.object({ id: z.string() }));
577
- createPipeline('order', { strict: true });
578
- createPipeline('order', z.object({ id: z.string() }), { strict: true });
579
- ```
580
-
581
632
  ### `parallel(...steps)`
582
633
 
583
634
  Run steps concurrently and merge their outputs. Returns a single step usable
584
635
  anywhere a regular step is accepted. On partial failure, succeeded inner steps
585
- are rolled back before the error propagates. p
636
+ are rolled back before the error propagates.
586
637
 
587
638
  ### `choice(...branches)`
588
639
 
@@ -591,23 +642,13 @@ Execute the first branch whose predicate returns `true`. Each branch is a
591
642
  default. Returns a single step usable anywhere a regular step is accepted. Only
592
643
  the matched branch participates in rollback.
593
644
 
594
- ### `map(key, collection, fnOrStep)`
595
-
596
- Iterate over a collection and run a function or step per item, concurrently.
597
- Results are collected into `{ [key]: Result[] }`. Accepts a plain function
598
- `(item, ctx) => result` or a `TypedStep` (items must be objects, spread into
599
- context). Step form supports per-item rollback on partial and external failure.
600
-
601
- ### `filter(key, collection, predicate)`
602
-
603
- Filter a collection from context using a sync or async predicate. Predicates run
604
- concurrently. Items where the predicate returns `true` are kept; original order
605
- is preserved. Results are collected into `{ [key]: Item[] }`. No rollback.
606
-
607
- ### `flatMap(key, collection, fn)`
645
+ ### `distribute(key, mapping, step)`
608
646
 
609
- Map each item in a collection to an array, then flatten one level. Callbacks run
610
- concurrently. Results are collected into `{ [key]: Result[] }`. No rollback.
647
+ Distribute collections from context across a step. The mapping object connects
648
+ context array keys to the step's scalar input keys. Runs the step once per item
649
+ (single mapping) or once per cross-product combination (multiple mappings).
650
+ Non-mapped context keys pass through. Supports per-item rollback on partial and
651
+ external failure.
611
652
 
612
653
  ### `when(predicate, step)`
613
654
 
@@ -629,8 +670,9 @@ MIT
629
670
  [ci-badge]:
630
671
  https://github.com/shaug/runsheet-js/actions/workflows/ci.yml/badge.svg
631
672
  [ci-url]: https://github.com/shaug/runsheet-js/actions/workflows/ci.yml
632
- [composable-functions]: https://github.com/seasonedcc/composable-functions
673
+ [Inngest]: https://www.inngest.com/
633
674
  [license-badge]: https://img.shields.io/npm/l/runsheet
634
675
  [license-url]: https://github.com/shaug/runsheet-js/blob/main/LICENSE
635
676
  [npm-badge]: https://img.shields.io/npm/v/runsheet
636
677
  [npm-url]: https://www.npmjs.com/package/runsheet
678
+ [Temporal]: https://temporal.io/