@backendkit-labs/pipeline 0.1.1 → 0.3.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
@@ -1,447 +1,448 @@
1
- # @backendkit-labs/pipeline
2
-
3
- [![npm version](https://img.shields.io/npm/v/@backendkit-labs/pipeline?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@backendkit-labs/pipeline)
4
- [![CI](https://img.shields.io/github/actions/workflow/status/backendkit-dev/backendkit-monorepo/ci.yml?style=flat-square&label=CI)](https://github.com/backendkit-dev/backendkit-monorepo/actions/workflows/ci.yml)
5
- [![License](https://img.shields.io/npm/l/@backendkit-labs/pipeline?style=flat-square)](LICENSE)
6
- [![Node](https://img.shields.io/node/v/@backendkit-labs/pipeline?style=flat-square)](package.json)
7
-
8
- > Type-safe async pipeline for Node.js — Chain of Responsibility pattern with stop-on-first / collect-all modes, conditional steps, observability hooks, and optional NestJS integration.
9
-
10
- Each step in the pipeline receives the current context, transforms it, and returns a typed result. If a step fails, the pipeline can stop immediately or continue collecting all errors — your choice per pipeline.
11
-
12
- ---
13
-
14
- ## Installation
15
-
16
- ```bash
17
- npm install @backendkit-labs/pipeline
18
- ```
19
-
20
- NestJS peer dependencies (only for the `/nestjs` subpath):
21
-
22
- ```bash
23
- npm install @nestjs/common @nestjs/core rxjs
24
- ```
25
-
26
- ---
27
-
28
- ## TypeScript Configuration
29
-
30
- ### Subpath exports (`/nestjs`)
31
-
32
- This package uses the `exports` field in `package.json` to expose the `/nestjs` subpath. TypeScript's ability to resolve it depends on the `moduleResolution` setting in your `tsconfig.json`.
33
-
34
- **Modern resolution (recommended) — no extra config needed:**
35
-
36
- ```json
37
- // tsconfig.json
38
- {
39
- "compilerOptions": {
40
- "moduleResolution": "bundler"
41
- }
42
- }
43
- ```
44
-
45
- `"bundler"`, `"node16"`, and `"nodenext"` all understand the `exports` field natively. This is the recommended setting for any project using a bundler (Webpack, esbuild, Vite) or for NestJS projects on TypeScript ≥ 5.
46
-
47
- **Legacy resolution (`"node"`) — add `paths` aliases:**
48
-
49
- NestJS projects generated before ~2024 default to `"moduleResolution": "node"`, which ignores the `exports` field entirely. TypeScript won't find the types for `@backendkit-labs/pipeline/nestjs` unless you add explicit path aliases:
50
-
51
- ```json
52
- // tsconfig.json
53
- {
54
- "compilerOptions": {
55
- "moduleResolution": "node",
56
- "paths": {
57
- "@backendkit-labs/pipeline/nestjs": [
58
- "./node_modules/@backendkit-labs/pipeline/dist/nestjs/index"
59
- ]
60
- }
61
- }
62
- }
63
- ```
64
-
65
- > **Why does this happen?** The `"node"` resolver was designed before subpath exports existed. It only knows how to find `main` and `types` at the root of a package — it does not read the `exports` map. The `paths` alias manually points TypeScript to the right `.d.ts` file for the subpath.
66
- >
67
- > The `splitting: true` tsup option (which this package uses) and this `paths` configuration solve completely different problems. `splitting` fixes a **runtime** class identity issue — ensuring there is only one copy of a class in memory across both bundles. The `paths` alias fixes a **compile-time** issue — helping TypeScript find the types. Both may be needed in a legacy project.
68
-
69
- ---
70
-
71
- ### NestJS decorator support
72
-
73
- NestJS requires two compiler options to be enabled:
74
-
75
- ```json
76
- // tsconfig.json
77
- {
78
- "compilerOptions": {
79
- "experimentalDecorators": true,
80
- "emitDecoratorMetadata": true
81
- }
82
- }
83
- ```
84
-
85
- And `reflect-metadata` must be imported once at application startup, before any NestJS module is loaded:
86
-
87
- ```typescript
88
- // main.ts
89
- import 'reflect-metadata';
90
- import { NestFactory } from '@nestjs/core';
91
- import { AppModule } from './app.module';
92
-
93
- async function bootstrap() {
94
- const app = await NestFactory.create(AppModule);
95
- await app.listen(3000);
96
- }
97
- bootstrap();
98
- ```
99
-
100
- > NestJS CLI scaffolds both of these automatically. You only need to check this if you are setting up a project manually or if decorator-related DI errors appear at runtime.
101
-
102
- ---
103
-
104
- ## Quick Start — Framework-agnostic
105
-
106
- ```typescript
107
- import { pipeline, Ok, Err } from '@backendkit-labs/pipeline';
108
- import type { PipelineStep, StepResult } from '@backendkit-labs/pipeline';
109
-
110
- interface OrderCtx {
111
- productId: string;
112
- quantity: number;
113
- stock: number;
114
- price: number;
115
- total: number;
116
- }
117
-
118
- interface OrderError {
119
- code: string;
120
- message: string;
121
- }
122
-
123
- class StockStep implements PipelineStep<OrderCtx, OrderError> {
124
- async handle(ctx: OrderCtx): Promise<StepResult<OrderCtx, OrderError>> {
125
- if (ctx.stock < ctx.quantity) {
126
- return Err({ code: 'INSUFFICIENT_STOCK', message: 'Not enough stock' });
127
- }
128
- return Ok(ctx);
129
- }
130
- }
131
-
132
- class PricingStep implements PipelineStep<OrderCtx, OrderError> {
133
- async handle(ctx: OrderCtx): Promise<StepResult<OrderCtx, OrderError>> {
134
- return Ok({ ...ctx, total: ctx.price * ctx.quantity });
135
- }
136
- }
137
-
138
- // Build and run
139
- const result = await pipeline<OrderCtx, OrderError>()
140
- .pipe(new StockStep())
141
- .pipe(new PricingStep())
142
- .run({ productId: 'p1', quantity: 2, stock: 10, price: 50, total: 0 });
143
-
144
- if (result.ok) {
145
- console.log(result.value.total); // 100
146
- console.log(result.executedSteps); // ['StockStep', 'PricingStep']
147
- } else {
148
- console.log(result.error.failedStep); // 'StockStep'
149
- console.log(result.error.cause); // { code: 'INSUFFICIENT_STOCK', ... }
150
- }
151
- ```
152
-
153
- ---
154
-
155
- ## Quick Start — NestJS
156
-
157
- ```typescript
158
- // order.pipeline.ts
159
- import { definePipeline } from '@backendkit-labs/pipeline';
160
- import type { OrderCtx, OrderError } from './order.types';
161
-
162
- export const ORDER_PIPELINE = definePipeline<OrderCtx, OrderError>('order');
163
- ```
164
-
165
- ```typescript
166
- // app.module.ts
167
- import { Module } from '@nestjs/common';
168
- import { PipelineModule } from '@backendkit-labs/pipeline/nestjs';
169
- import { ORDER_PIPELINE } from './order.pipeline';
170
- import { StockStep, PricingStep, NotifyStep } from './steps';
171
-
172
- @Module({
173
- imports: [
174
- PipelineModule.forRoot({
175
- pipelines: [
176
- {
177
- token: ORDER_PIPELINE,
178
- steps: [StockStep, PricingStep, NotifyStep],
179
- options: {
180
- onError: (step, err) => logger.error(`Pipeline failed at ${step}`, err),
181
- },
182
- },
183
- ],
184
- }),
185
- ],
186
- })
187
- export class AppModule {}
188
- ```
189
-
190
- ```typescript
191
- // order.service.ts
192
- import { Injectable } from '@nestjs/common';
193
- import { InjectPipeline } from '@backendkit-labs/pipeline/nestjs';
194
- import { Pipeline } from '@backendkit-labs/pipeline';
195
- import { ORDER_PIPELINE } from './order.pipeline';
196
- import type { OrderCtx, OrderError } from './order.types';
197
-
198
- @Injectable()
199
- export class OrderService {
200
- constructor(
201
- @InjectPipeline(ORDER_PIPELINE)
202
- private readonly pipeline: Pipeline<OrderCtx, OrderError>,
203
- ) {}
204
-
205
- async processOrder(ctx: OrderCtx) {
206
- return this.pipeline.run(ctx);
207
- }
208
- }
209
- ```
210
-
211
- ---
212
-
213
- ## API
214
-
215
- ### `pipeline(options?)`
216
-
217
- Creates a new pipeline builder.
218
-
219
- ```typescript
220
- const p = pipeline<TContext, TError>(options?);
221
- ```
222
-
223
- #### Options
224
-
225
- ```typescript
226
- pipeline<Ctx, Err>({
227
- // 'stop-on-first' — stop and return on the first failure (default)
228
- // 'collect-all' run all steps, accumulate every failure
229
- mode: 'stop-on-first',
230
-
231
- onStep(stepName, ctx) {
232
- logger.debug(`[pipeline] ${stepName}`);
233
- },
234
-
235
- onStepComplete(stepName, ctx, durationMs) {
236
- metrics.timing(`step.${stepName}`, durationMs);
237
- },
238
-
239
- onError(stepName, error) {
240
- logger.error(`[pipeline] ✗ ${stepName}`, error);
241
- },
242
-
243
- onComplete(ctx, durationMs) {
244
- metrics.timing('pipeline.total', durationMs);
245
- },
246
- });
247
- ```
248
-
249
- ---
250
-
251
- ### `.pipe(step)`
252
-
253
- Adds a step that always runs.
254
-
255
- ```typescript
256
- p.pipe(new StockStep())
257
- .pipe(new PricingStep());
258
- ```
259
-
260
- ---
261
-
262
- ### `.pipeIf(condition, step)`
263
-
264
- Adds a step that runs only when `condition(ctx)` returns `true`. The condition receives the context **after** all previous steps have transformed it.
265
-
266
- ```typescript
267
- p.pipe(new BaseStep())
268
- .pipeIf(ctx => ctx.hasDiscount, new DiscountStep())
269
- .pipe(new FinalStep());
270
- ```
271
-
272
- ---
273
-
274
- ### `.run(ctx)`
275
-
276
- Executes the pipeline and returns a `PipelineRunResult`.
277
-
278
- ```typescript
279
- const result = await p.run(initialCtx);
280
-
281
- // Success
282
- result.ok // true
283
- result.value // final context
284
- result.executedSteps // ['StockStep', 'PricingStep']
285
- result.durationMs // total duration
286
-
287
- // Failure
288
- result.ok // false
289
- result.error.failedStep // 'StockStep'
290
- result.error.cause // original typed error
291
- result.error.executedSteps // steps that ran before the failure
292
- result.error.durationMs // total duration
293
- result.error.failures // all failures — one entry for stop-on-first, N for collect-all
294
- result.error.mode // 'stop-on-first' | 'collect-all'
295
- ```
296
-
297
- ---
298
-
299
- ### `Ok(value)` / `Err(error)`
300
-
301
- Helpers for returning step results.
302
-
303
- ```typescript
304
- import { Ok, Err } from '@backendkit-labs/pipeline';
305
-
306
- async handle(ctx): Promise<StepResult<Ctx, Err>> {
307
- if (!valid) return Err({ code: 'INVALID' });
308
- return Ok({ ...ctx, validated: true });
309
- }
310
- ```
311
-
312
- ---
313
-
314
- ### `PipelineStep<TContext, TError>`
315
-
316
- Interface your step classes implement.
317
-
318
- ```typescript
319
- import type { PipelineStep, StepResult } from '@backendkit-labs/pipeline';
320
-
321
- class MyStep implements PipelineStep<Ctx, MyError> {
322
- // Optional overrides constructor.name in error reports and hook calls
323
- readonly stepName = 'MyStep';
324
-
325
- async handle(ctx: Ctx): Promise<StepResult<Ctx, MyError>> {
326
- // ...
327
- }
328
- }
329
- ```
330
-
331
- ---
332
-
333
- ## Error Modes
334
-
335
- ### `stop-on-first` (default)
336
-
337
- Stops at the first failure. Use when later steps depend on earlier ones being successful.
338
-
339
- ```typescript
340
- pipeline({ mode: 'stop-on-first' })
341
- .pipe(new AuthStep()) // if this fails → stop, PaymentStep never runs
342
- .pipe(new PaymentStep())
343
- .run(ctx);
344
- ```
345
-
346
- ### `collect-all`
347
-
348
- Runs every step regardless of failures. Use when steps are independent and you want to report all errors at once — e.g., form validation.
349
-
350
- ```typescript
351
- pipeline({ mode: 'collect-all' })
352
- .pipe(new ValidateNameStep())
353
- .pipe(new ValidateEmailStep())
354
- .pipe(new ValidatePhoneStep())
355
- .run(formData);
356
-
357
- // result.error.failures → [{ step: 'ValidateEmailStep', cause: ... }, { step: 'ValidatePhoneStep', cause: ... }]
358
- ```
359
-
360
- ---
361
-
362
- ## NestJS Integration
363
-
364
- ### `definePipeline<TContext, TError>(name)`
365
-
366
- Creates a typed injection token. Define it once and share across module and service.
367
-
368
- ```typescript
369
- export const ORDER_PIPELINE = definePipeline<OrderCtx, OrderError>('order');
370
- // PipelineToken<OrderCtx, OrderError>
371
- ```
372
-
373
- ### `PipelineModule.forRoot(options)`
374
-
375
- Registers pipelines globally. Each step class is resolved via NestJS DI, so steps can inject other services.
376
-
377
- ```typescript
378
- PipelineModule.forRoot({
379
- pipelines: [
380
- {
381
- token: ORDER_PIPELINE,
382
- steps: [StockStep, PricingStep, NotifyStep], // resolved via DI
383
- options: { mode: 'stop-on-first', onError: ... },
384
- },
385
- ],
386
- })
387
- ```
388
-
389
- ### `@InjectPipeline(token)`
390
-
391
- Parameter decorator for injecting a pipeline into a service.
392
-
393
- ```typescript
394
- constructor(
395
- @InjectPipeline(ORDER_PIPELINE)
396
- private readonly orderPipeline: Pipeline<OrderCtx, OrderError>,
397
- ) {}
398
- ```
399
-
400
- ---
401
-
402
- ## Use Cases
403
-
404
- | Scenario | Mode |
405
- |---|---|
406
- | Order processing (stock → payment → notify) | `stop-on-first` |
407
- | Form / DTO validation (collect all field errors) | `collect-all` |
408
- | User onboarding (KYC plan welcome email) | `stop-on-first` |
409
- | File processing (validatescancompress → upload) | `stop-on-first` |
410
- | Webhook processing (verify signature parsededuplicateroute) | `stop-on-first` |
411
- | Pricing pipeline (basevolume discount taxcurrency) | `stop-on-first` |
412
-
413
- ---
414
-
415
- ## Design Notes
416
-
417
- ### Context is immutable by convention
418
-
419
- Each step returns a **new** context object rather than mutating the existing one. This makes each step's input/output explicit and easy to trace in logs.
420
-
421
- ```typescript
422
- // Do this
423
- return Ok({ ...ctx, total: ctx.price * ctx.quantity });
424
-
425
- // Not this
426
- ctx.total = ctx.price * ctx.quantity;
427
- return Ok(ctx);
428
- ```
429
-
430
- ### Steps are plain classes
431
-
432
- Steps don't extend a base class or require special decorators. They just implement `PipelineStep<TContext, TError>`. This makes them easy to test in isolation:
433
-
434
- ```typescript
435
- const result = await new StockStep().handle({ stock: 0, quantity: 5, ... });
436
- expect(result.ok).toBe(false);
437
- ```
438
-
439
- ### NestJS DI class identity
440
-
441
- `PipelineModule.forRoot()` resolves step classes via NestJS DI and wires them into the pipeline at startup. All steps share the same DI context — no class identity issues.
442
-
443
- ---
444
-
445
- ## License
446
-
447
- Apache-2.0 — [BackendKit Labs](https://github.com/backendkit-dev)
1
+ # @backendkit-labs/pipeline
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@backendkit-labs/pipeline?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@backendkit-labs/pipeline)
4
+ [![CI](https://img.shields.io/github/actions/workflow/status/BackendKit-labs/backendkit-monorepo/ci.yml?style=flat-square&label=CI)](https://github.com/BackendKit-labs/backendkit-monorepo/actions/workflows/ci.yml)
5
+ [![License](https://img.shields.io/npm/l/@backendkit-labs/pipeline?style=flat-square)](LICENSE)
6
+ [![Node](https://img.shields.io/node/v/@backendkit-labs/pipeline?style=flat-square)](package.json)
7
+ [![Docs](https://img.shields.io/badge/docs-backendkitlabs.dev-4f7eff?style=flat-square)](https://backendkitlabs.dev/docs/pipeline/)
8
+
9
+ > Type-safe async pipeline for Node.js — Chain of Responsibility pattern with stop-on-first / collect-all modes, conditional steps, observability hooks, and optional NestJS integration.
10
+
11
+ Each step in the pipeline receives the current context, transforms it, and returns a typed result. If a step fails, the pipeline can stop immediately or continue collecting all errors — your choice per pipeline.
12
+
13
+ ---
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install @backendkit-labs/pipeline
19
+ ```
20
+
21
+ NestJS peer dependencies (only for the `/nestjs` subpath):
22
+
23
+ ```bash
24
+ npm install @nestjs/common @nestjs/core rxjs
25
+ ```
26
+
27
+ ---
28
+
29
+ ## TypeScript Configuration
30
+
31
+ ### Subpath exports (`/nestjs`)
32
+
33
+ This package uses the `exports` field in `package.json` to expose the `/nestjs` subpath. TypeScript's ability to resolve it depends on the `moduleResolution` setting in your `tsconfig.json`.
34
+
35
+ **Modern resolution (recommended) — no extra config needed:**
36
+
37
+ ```json
38
+ // tsconfig.json
39
+ {
40
+ "compilerOptions": {
41
+ "moduleResolution": "bundler"
42
+ }
43
+ }
44
+ ```
45
+
46
+ `"bundler"`, `"node16"`, and `"nodenext"` all understand the `exports` field natively. This is the recommended setting for any project using a bundler (Webpack, esbuild, Vite) or for NestJS projects on TypeScript ≥ 5.
47
+
48
+ **Legacy resolution (`"node"`) — add `paths` aliases:**
49
+
50
+ NestJS projects generated before ~2024 default to `"moduleResolution": "node"`, which ignores the `exports` field entirely. TypeScript won't find the types for `@backendkit-labs/pipeline/nestjs` unless you add explicit path aliases:
51
+
52
+ ```json
53
+ // tsconfig.json
54
+ {
55
+ "compilerOptions": {
56
+ "moduleResolution": "node",
57
+ "paths": {
58
+ "@backendkit-labs/pipeline/nestjs": [
59
+ "./node_modules/@backendkit-labs/pipeline/dist/nestjs/index"
60
+ ]
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ > **Why does this happen?** The `"node"` resolver was designed before subpath exports existed. It only knows how to find `main` and `types` at the root of a package — it does not read the `exports` map. The `paths` alias manually points TypeScript to the right `.d.ts` file for the subpath.
67
+ >
68
+ > The `splitting: true` tsup option (which this package uses) and this `paths` configuration solve completely different problems. `splitting` fixes a **runtime** class identity issue — ensuring there is only one copy of a class in memory across both bundles. The `paths` alias fixes a **compile-time** issue — helping TypeScript find the types. Both may be needed in a legacy project.
69
+
70
+ ---
71
+
72
+ ### NestJS decorator support
73
+
74
+ NestJS requires two compiler options to be enabled:
75
+
76
+ ```json
77
+ // tsconfig.json
78
+ {
79
+ "compilerOptions": {
80
+ "experimentalDecorators": true,
81
+ "emitDecoratorMetadata": true
82
+ }
83
+ }
84
+ ```
85
+
86
+ And `reflect-metadata` must be imported once at application startup, before any NestJS module is loaded:
87
+
88
+ ```typescript
89
+ // main.ts
90
+ import 'reflect-metadata';
91
+ import { NestFactory } from '@nestjs/core';
92
+ import { AppModule } from './app.module';
93
+
94
+ async function bootstrap() {
95
+ const app = await NestFactory.create(AppModule);
96
+ await app.listen(3000);
97
+ }
98
+ bootstrap();
99
+ ```
100
+
101
+ > NestJS CLI scaffolds both of these automatically. You only need to check this if you are setting up a project manually or if decorator-related DI errors appear at runtime.
102
+
103
+ ---
104
+
105
+ ## Quick Start — Framework-agnostic
106
+
107
+ ```typescript
108
+ import { pipeline, Ok, Err } from '@backendkit-labs/pipeline';
109
+ import type { PipelineStep, StepResult } from '@backendkit-labs/pipeline';
110
+
111
+ interface OrderCtx {
112
+ productId: string;
113
+ quantity: number;
114
+ stock: number;
115
+ price: number;
116
+ total: number;
117
+ }
118
+
119
+ interface OrderError {
120
+ code: string;
121
+ message: string;
122
+ }
123
+
124
+ class StockStep implements PipelineStep<OrderCtx, OrderError> {
125
+ async handle(ctx: OrderCtx): Promise<StepResult<OrderCtx, OrderError>> {
126
+ if (ctx.stock < ctx.quantity) {
127
+ return Err({ code: 'INSUFFICIENT_STOCK', message: 'Not enough stock' });
128
+ }
129
+ return Ok(ctx);
130
+ }
131
+ }
132
+
133
+ class PricingStep implements PipelineStep<OrderCtx, OrderError> {
134
+ async handle(ctx: OrderCtx): Promise<StepResult<OrderCtx, OrderError>> {
135
+ return Ok({ ...ctx, total: ctx.price * ctx.quantity });
136
+ }
137
+ }
138
+
139
+ // Build and run
140
+ const result = await pipeline<OrderCtx, OrderError>()
141
+ .pipe(new StockStep())
142
+ .pipe(new PricingStep())
143
+ .run({ productId: 'p1', quantity: 2, stock: 10, price: 50, total: 0 });
144
+
145
+ if (result.ok) {
146
+ console.log(result.value.total); // 100
147
+ console.log(result.executedSteps); // ['StockStep', 'PricingStep']
148
+ } else {
149
+ console.log(result.error.failedStep); // 'StockStep'
150
+ console.log(result.error.cause); // { code: 'INSUFFICIENT_STOCK', ... }
151
+ }
152
+ ```
153
+
154
+ ---
155
+
156
+ ## Quick Start — NestJS
157
+
158
+ ```typescript
159
+ // order.pipeline.ts
160
+ import { definePipeline } from '@backendkit-labs/pipeline';
161
+ import type { OrderCtx, OrderError } from './order.types';
162
+
163
+ export const ORDER_PIPELINE = definePipeline<OrderCtx, OrderError>('order');
164
+ ```
165
+
166
+ ```typescript
167
+ // app.module.ts
168
+ import { Module } from '@nestjs/common';
169
+ import { PipelineModule } from '@backendkit-labs/pipeline/nestjs';
170
+ import { ORDER_PIPELINE } from './order.pipeline';
171
+ import { StockStep, PricingStep, NotifyStep } from './steps';
172
+
173
+ @Module({
174
+ imports: [
175
+ PipelineModule.forRoot({
176
+ pipelines: [
177
+ {
178
+ token: ORDER_PIPELINE,
179
+ steps: [StockStep, PricingStep, NotifyStep],
180
+ options: {
181
+ onError: (step, err) => logger.error(`Pipeline failed at ${step}`, err),
182
+ },
183
+ },
184
+ ],
185
+ }),
186
+ ],
187
+ })
188
+ export class AppModule {}
189
+ ```
190
+
191
+ ```typescript
192
+ // order.service.ts
193
+ import { Injectable } from '@nestjs/common';
194
+ import { InjectPipeline } from '@backendkit-labs/pipeline/nestjs';
195
+ import { Pipeline } from '@backendkit-labs/pipeline';
196
+ import { ORDER_PIPELINE } from './order.pipeline';
197
+ import type { OrderCtx, OrderError } from './order.types';
198
+
199
+ @Injectable()
200
+ export class OrderService {
201
+ constructor(
202
+ @InjectPipeline(ORDER_PIPELINE)
203
+ private readonly pipeline: Pipeline<OrderCtx, OrderError>,
204
+ ) {}
205
+
206
+ async processOrder(ctx: OrderCtx) {
207
+ return this.pipeline.run(ctx);
208
+ }
209
+ }
210
+ ```
211
+
212
+ ---
213
+
214
+ ## API
215
+
216
+ ### `pipeline(options?)`
217
+
218
+ Creates a new pipeline builder.
219
+
220
+ ```typescript
221
+ const p = pipeline<TContext, TError>(options?);
222
+ ```
223
+
224
+ #### Options
225
+
226
+ ```typescript
227
+ pipeline<Ctx, Err>({
228
+ // 'stop-on-first' stop and return on the first failure (default)
229
+ // 'collect-all' — run all steps, accumulate every failure
230
+ mode: 'stop-on-first',
231
+
232
+ onStep(stepName, ctx) {
233
+ logger.debug(`[pipeline] → ${stepName}`);
234
+ },
235
+
236
+ onStepComplete(stepName, ctx, durationMs) {
237
+ metrics.timing(`step.${stepName}`, durationMs);
238
+ },
239
+
240
+ onError(stepName, error) {
241
+ logger.error(`[pipeline] ✗ ${stepName}`, error);
242
+ },
243
+
244
+ onComplete(ctx, durationMs) {
245
+ metrics.timing('pipeline.total', durationMs);
246
+ },
247
+ });
248
+ ```
249
+
250
+ ---
251
+
252
+ ### `.pipe(step)`
253
+
254
+ Adds a step that always runs.
255
+
256
+ ```typescript
257
+ p.pipe(new StockStep())
258
+ .pipe(new PricingStep());
259
+ ```
260
+
261
+ ---
262
+
263
+ ### `.pipeIf(condition, step)`
264
+
265
+ Adds a step that runs only when `condition(ctx)` returns `true`. The condition receives the context **after** all previous steps have transformed it.
266
+
267
+ ```typescript
268
+ p.pipe(new BaseStep())
269
+ .pipeIf(ctx => ctx.hasDiscount, new DiscountStep())
270
+ .pipe(new FinalStep());
271
+ ```
272
+
273
+ ---
274
+
275
+ ### `.run(ctx)`
276
+
277
+ Executes the pipeline and returns a `PipelineRunResult`.
278
+
279
+ ```typescript
280
+ const result = await p.run(initialCtx);
281
+
282
+ // Success
283
+ result.ok // true
284
+ result.value // final context
285
+ result.executedSteps // ['StockStep', 'PricingStep']
286
+ result.durationMs // total duration
287
+
288
+ // Failure
289
+ result.ok // false
290
+ result.error.failedStep // 'StockStep'
291
+ result.error.cause // original typed error
292
+ result.error.executedSteps // steps that ran before the failure
293
+ result.error.durationMs // total duration
294
+ result.error.failures // all failures — one entry for stop-on-first, N for collect-all
295
+ result.error.mode // 'stop-on-first' | 'collect-all'
296
+ ```
297
+
298
+ ---
299
+
300
+ ### `Ok(value)` / `Err(error)`
301
+
302
+ Helpers for returning step results.
303
+
304
+ ```typescript
305
+ import { Ok, Err } from '@backendkit-labs/pipeline';
306
+
307
+ async handle(ctx): Promise<StepResult<Ctx, Err>> {
308
+ if (!valid) return Err({ code: 'INVALID' });
309
+ return Ok({ ...ctx, validated: true });
310
+ }
311
+ ```
312
+
313
+ ---
314
+
315
+ ### `PipelineStep<TContext, TError>`
316
+
317
+ Interface your step classes implement.
318
+
319
+ ```typescript
320
+ import type { PipelineStep, StepResult } from '@backendkit-labs/pipeline';
321
+
322
+ class MyStep implements PipelineStep<Ctx, MyError> {
323
+ // Optional overrides constructor.name in error reports and hook calls
324
+ readonly stepName = 'MyStep';
325
+
326
+ async handle(ctx: Ctx): Promise<StepResult<Ctx, MyError>> {
327
+ // ...
328
+ }
329
+ }
330
+ ```
331
+
332
+ ---
333
+
334
+ ## Error Modes
335
+
336
+ ### `stop-on-first` (default)
337
+
338
+ Stops at the first failure. Use when later steps depend on earlier ones being successful.
339
+
340
+ ```typescript
341
+ pipeline({ mode: 'stop-on-first' })
342
+ .pipe(new AuthStep()) // if this fails → stop, PaymentStep never runs
343
+ .pipe(new PaymentStep())
344
+ .run(ctx);
345
+ ```
346
+
347
+ ### `collect-all`
348
+
349
+ Runs every step regardless of failures. Use when steps are independent and you want to report all errors at once — e.g., form validation.
350
+
351
+ ```typescript
352
+ pipeline({ mode: 'collect-all' })
353
+ .pipe(new ValidateNameStep())
354
+ .pipe(new ValidateEmailStep())
355
+ .pipe(new ValidatePhoneStep())
356
+ .run(formData);
357
+
358
+ // result.error.failures → [{ step: 'ValidateEmailStep', cause: ... }, { step: 'ValidatePhoneStep', cause: ... }]
359
+ ```
360
+
361
+ ---
362
+
363
+ ## NestJS Integration
364
+
365
+ ### `definePipeline<TContext, TError>(name)`
366
+
367
+ Creates a typed injection token. Define it once and share across module and service.
368
+
369
+ ```typescript
370
+ export const ORDER_PIPELINE = definePipeline<OrderCtx, OrderError>('order');
371
+ // PipelineToken<OrderCtx, OrderError>
372
+ ```
373
+
374
+ ### `PipelineModule.forRoot(options)`
375
+
376
+ Registers pipelines globally. Each step class is resolved via NestJS DI, so steps can inject other services.
377
+
378
+ ```typescript
379
+ PipelineModule.forRoot({
380
+ pipelines: [
381
+ {
382
+ token: ORDER_PIPELINE,
383
+ steps: [StockStep, PricingStep, NotifyStep], // resolved via DI
384
+ options: { mode: 'stop-on-first', onError: ... },
385
+ },
386
+ ],
387
+ })
388
+ ```
389
+
390
+ ### `@InjectPipeline(token)`
391
+
392
+ Parameter decorator for injecting a pipeline into a service.
393
+
394
+ ```typescript
395
+ constructor(
396
+ @InjectPipeline(ORDER_PIPELINE)
397
+ private readonly orderPipeline: Pipeline<OrderCtx, OrderError>,
398
+ ) {}
399
+ ```
400
+
401
+ ---
402
+
403
+ ## Use Cases
404
+
405
+ | Scenario | Mode |
406
+ |---|---|
407
+ | Order processing (stock payment notify) | `stop-on-first` |
408
+ | Form / DTO validation (collect all field errors) | `collect-all` |
409
+ | User onboarding (KYCplanwelcome email) | `stop-on-first` |
410
+ | File processing (validatescancompressupload) | `stop-on-first` |
411
+ | Webhook processing (verify signature parsededuplicateroute) | `stop-on-first` |
412
+ | Pricing pipeline (base → volume discount → tax → currency) | `stop-on-first` |
413
+
414
+ ---
415
+
416
+ ## Design Notes
417
+
418
+ ### Context is immutable by convention
419
+
420
+ Each step returns a **new** context object rather than mutating the existing one. This makes each step's input/output explicit and easy to trace in logs.
421
+
422
+ ```typescript
423
+ // Do this
424
+ return Ok({ ...ctx, total: ctx.price * ctx.quantity });
425
+
426
+ // Not this
427
+ ctx.total = ctx.price * ctx.quantity;
428
+ return Ok(ctx);
429
+ ```
430
+
431
+ ### Steps are plain classes
432
+
433
+ Steps don't extend a base class or require special decorators. They just implement `PipelineStep<TContext, TError>`. This makes them easy to test in isolation:
434
+
435
+ ```typescript
436
+ const result = await new StockStep().handle({ stock: 0, quantity: 5, ... });
437
+ expect(result.ok).toBe(false);
438
+ ```
439
+
440
+ ### NestJS DI class identity
441
+
442
+ `PipelineModule.forRoot()` resolves step classes via NestJS DI and wires them into the pipeline at startup. All steps share the same DI context — no class identity issues.
443
+
444
+ ---
445
+
446
+ ## License
447
+
448
+ Apache-2.0 — [BackendKit Labs](https://github.com/BackendKit-labs)