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 +291 -249
- package/dist/index.cjs +418 -520
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +365 -400
- package/dist/index.d.ts +365 -400
- package/dist/index.js +415 -513
- package/dist/index.js.map +1 -1
- package/llms.txt +93 -124
- package/package.json +2 -5
package/README.md
CHANGED
|
@@ -5,53 +5,181 @@
|
|
|
5
5
|
|
|
6
6
|
Type-safe, composable business logic pipelines for TypeScript.
|
|
7
7
|
|
|
8
|
-
|
|
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
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
can't accidentally
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
110
|
+
**TypeScript generics only** — compile-time composition safety, no runtime cost:
|
|
41
111
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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`).
|
|
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 {
|
|
206
|
+
import { step } from 'runsheet';
|
|
81
207
|
import { z } from 'zod';
|
|
82
208
|
|
|
83
|
-
const validateOrder =
|
|
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 =
|
|
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 =
|
|
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 {
|
|
252
|
+
import { pipeline } from 'runsheet';
|
|
131
253
|
|
|
132
|
-
const placeOrder =
|
|
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.
|
|
265
|
+
console.error(result.error); // what went wrong
|
|
144
266
|
console.log(result.rollback); // { completed: [...], failed: [...] }
|
|
145
267
|
}
|
|
146
268
|
```
|
|
147
269
|
|
|
148
|
-
|
|
149
|
-
|
|
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 {
|
|
341
|
+
import { pipeline } from 'runsheet';
|
|
158
342
|
import { z } from 'zod';
|
|
159
343
|
|
|
160
|
-
const placeOrder =
|
|
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 =
|
|
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 =
|
|
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
|
|
212
|
-
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
372
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
428
|
-
|
|
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
|
-
|
|
462
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
##
|
|
574
|
+
## Step result
|
|
505
575
|
|
|
506
|
-
Every
|
|
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
|
-
|
|
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
|
-
|
|
526
|
-
meta: {
|
|
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
|
-
### `
|
|
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
|
-
### `
|
|
618
|
+
### `pipeline(config)`
|
|
551
619
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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.
|
|
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
|
-
### `
|
|
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
|
-
|
|
610
|
-
|
|
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
|
-
[
|
|
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/
|