runsheet 0.6.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
- Pipelines are steps — compose them freely, nest them arbitrarily.
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
35
90
 
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.
91
+ ### Typed accumulated context
39
92
 
40
- A pipeline orchestration library with:
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>`.
41
98
 
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 `pipeline` 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.
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.
107
+
108
+ ### Two modes of type safety
109
+
110
+ **TypeScript generics only** — compile-time composition safety, no runtime cost:
111
+
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,10 +246,6 @@ 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
@@ -145,8 +267,54 @@ if (result.success) {
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.
150
318
 
151
319
  ### Pipeline composition
152
320
 
@@ -193,21 +361,6 @@ const placeOrder = pipeline<{ orderId: string }>({ name: 'placeOrder' })
193
361
  .build();
194
362
  ```
195
363
 
196
- ### Generics-only steps
197
-
198
- Steps don't need Zod schemas — TypeScript generics provide compile-time safety
199
- without runtime validation at step boundaries:
200
-
201
- ```typescript
202
- const logOrder = defineStep<{ order: { id: string } }, { loggedAt: Date }>({
203
- name: 'logOrder',
204
- run: async (ctx) => {
205
- console.log(`Processing order ${ctx.order.id}`);
206
- return { loggedAt: new Date() };
207
- },
208
- });
209
- ```
210
-
211
364
  ### Conditional steps
212
365
 
213
366
  ```typescript
@@ -272,7 +425,7 @@ const placeOrder = pipeline<{ orderId: string }>({ name: 'placeOrder' })
272
425
  Steps can declare retry policies and timeouts directly:
273
426
 
274
427
  ```typescript
275
- const callExternalApi = defineStep({
428
+ const callExternalApi = step({
276
429
  name: 'callExternalApi',
277
430
  provides: z.object({ response: z.string() }),
278
431
  retry: { count: 3, delay: 200, backoff: 'exponential' },
@@ -326,7 +479,7 @@ No special mechanism needed — pass dependencies as pipeline args and they're
326
479
  available to every step through the accumulated context:
327
480
 
328
481
  ```typescript
329
- const chargePayment = defineStep({
482
+ const chargePayment = step({
330
483
  name: 'chargePayment',
331
484
  requires: z.object({
332
485
  order: z.object({ total: z.number() }),
@@ -384,138 +537,39 @@ const placeOrder = pipeline({
384
537
 
385
538
  Predicates are evaluated in order — first match wins. A bare step (without a
386
539
  tuple) can be passed as the last argument to serve as a default — equivalent to
387
- `[() => true, step]`. If no predicate matches, the step fails with a
388
- `CHOICE_NO_MATCH` error. Only the matched branch participates in rollback.
389
-
390
- ## Map (collection iteration)
391
-
392
- Iterate over a collection and run a function or step per item, concurrently —
393
- like an AWS Step Functions Map state:
394
-
395
- ```typescript
396
- import { map } from 'runsheet';
397
-
398
- // Function form — items can be any type
399
- const p = pipeline({
400
- name: 'notify',
401
- steps: [
402
- map(
403
- 'emails',
404
- (ctx) => ctx.users,
405
- async (user) => {
406
- await sendEmail(user.email);
407
- return { email: user.email, sentAt: new Date() };
408
- },
409
- ),
410
- ],
411
- });
540
+ `[() => true, step]`. If no predicate matches, the step is skipped (empty data,
541
+ no rollback entry). Only the matched branch participates in rollback.
412
542
 
413
- // Step form — reuse existing steps
414
- const p = pipeline({
415
- name: 'process',
416
- steps: [map('results', (ctx) => ctx.items, processItem)],
417
- });
418
- ```
543
+ ## Distribute (collection fan-out)
419
544
 
420
- Items run concurrently via `Promise.allSettled`. Results are collected into an
421
- array under the given key. In step form, each item is spread into the pipeline
422
- context (`{ ...ctx, ...item }`) so the step sees both pipeline-level and
423
- per-item values. On partial failure, succeeded items are rolled back (step form
424
- only).
425
-
426
- ### 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.
427
548
 
428
549
  ```typescript
429
- import { filter, map } from 'runsheet';
550
+ import { distribute } from 'runsheet';
430
551
 
431
- const p = pipeline({
432
- name: 'notify',
433
- steps: [
434
- filter(
435
- 'eligible',
436
- (ctx) => ctx.users,
437
- (user) => user.optedIn,
438
- ),
439
- map('emails', (ctx) => ctx.eligible, sendEmail),
440
- ],
441
- });
442
-
443
- // Async predicate
444
- filter(
445
- 'valid',
446
- (ctx) => ctx.orders,
447
- async (order) => {
448
- const inventory = await checkInventory(order.sku);
449
- return inventory.available >= order.quantity;
450
- },
451
- );
452
- ```
453
-
454
- Predicates run concurrently via `Promise.allSettled`. Original order is
455
- preserved. If any predicate throws, the step fails. No rollback (filtering is a
456
- pure operation).
457
-
458
- ### FlatMap (collection expansion)
459
-
460
- ```typescript
461
- import { flatMap } from 'runsheet';
462
-
463
- const p = pipeline({
464
- name: 'process',
465
- steps: [
466
- flatMap(
467
- 'lineItems',
468
- (ctx) => ctx.orders,
469
- (order) => order.items,
470
- ),
471
- ],
472
- });
473
-
474
- // Async callback
475
- flatMap(
552
+ // Single collection — run sendEmail once per accountId
553
+ const sendEmails = distribute(
476
554
  'emails',
477
- (ctx) => ctx.teams,
478
- async (team) => {
479
- const members = await fetchMembers(team.id);
480
- return members.map((m) => m.email);
481
- },
555
+ { accountIds: 'accountId' },
556
+ sendEmailStep,
482
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
483
568
  ```
484
569
 
485
- Maps each item to an array, then flattens one level. Callbacks run concurrently
486
- via `Promise.allSettled`. If any callback throws, the step fails. No rollback
487
- (pure operation).
488
-
489
- ## Rollback
490
-
491
- When a step fails, rollback handlers for all previously completed steps execute
492
- in reverse order. Each handler receives the pre-step context snapshot and the
493
- step's output:
494
-
495
- ```typescript
496
- const reserveInventory = defineStep({
497
- name: 'reserveInventory',
498
- requires: z.object({ order: z.object({ items: z.array(z.string()) }) }),
499
- provides: z.object({ reservationId: z.string() }),
500
- run: async (ctx) => {
501
- const reservation = await inventory.reserve(ctx.order.items);
502
- return { reservationId: reservation.id };
503
- },
504
- rollback: async (_ctx, output) => {
505
- await inventory.release(output.reservationId);
506
- },
507
- });
508
- ```
509
-
510
- Rollback is best-effort: if a rollback handler throws, remaining rollbacks still
511
- execute. The result includes a structured report:
512
-
513
- ```typescript
514
- if (!result.success) {
515
- result.rollback.completed; // ['chargePayment', 'reserveInventory']
516
- result.rollback.failed; // [{ step: 'sendNotification', error: Error }]
517
- }
518
- ```
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.
519
573
 
520
574
  ## Step result
521
575
 
@@ -545,7 +599,7 @@ Every `run()` returns a `StepResult` with execution metadata:
545
599
 
546
600
  ## API reference
547
601
 
548
- ### `defineStep(config)`
602
+ ### `step(config)`
549
603
 
550
604
  Define a pipeline step. Returns a strongly typed `TypedStep` — `run`,
551
605
  `rollback`, `requires`, and `provides` all carry concrete types matching the
@@ -588,23 +642,13 @@ Execute the first branch whose predicate returns `true`. Each branch is a
588
642
  default. Returns a single step usable anywhere a regular step is accepted. Only
589
643
  the matched branch participates in rollback.
590
644
 
591
- ### `map(key, collection, fnOrStep)`
592
-
593
- Iterate over a collection and run a function or step per item, concurrently.
594
- Results are collected into `{ [key]: Result[] }`. Accepts a plain function
595
- `(item, ctx) => result` or a `TypedStep` (items must be objects, spread into
596
- context). Step form supports per-item rollback on partial and external failure.
597
-
598
- ### `filter(key, collection, predicate)`
599
-
600
- Filter a collection from context using a sync or async predicate. Predicates run
601
- concurrently. Items where the predicate returns `true` are kept; original order
602
- is preserved. Results are collected into `{ [key]: Item[] }`. No rollback.
603
-
604
- ### `flatMap(key, collection, fn)`
645
+ ### `distribute(key, mapping, step)`
605
646
 
606
- Map each item in a collection to an array, then flatten one level. Callbacks run
607
- 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.
608
652
 
609
653
  ### `when(predicate, step)`
610
654
 
@@ -626,7 +670,9 @@ MIT
626
670
  [ci-badge]:
627
671
  https://github.com/shaug/runsheet-js/actions/workflows/ci.yml/badge.svg
628
672
  [ci-url]: https://github.com/shaug/runsheet-js/actions/workflows/ci.yml
673
+ [Inngest]: https://www.inngest.com/
629
674
  [license-badge]: https://img.shields.io/npm/l/runsheet
630
675
  [license-url]: https://github.com/shaug/runsheet-js/blob/main/LICENSE
631
676
  [npm-badge]: https://img.shields.io/npm/v/runsheet
632
677
  [npm-url]: https://www.npmjs.com/package/runsheet
678
+ [Temporal]: https://temporal.io/