@celom/prose 0.1.1

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.
Files changed (2) hide show
  1. package/README.md +433 -0
  2. package/package.json +45 -0
package/README.md ADDED
@@ -0,0 +1,433 @@
1
+ # @celom/prose
2
+
3
+ Declarative workflow DSL for orchestrating complex business operations in Node.js.
4
+
5
+ Define multi-step business logic as type-safe pipelines with built-in retries, timeouts, transactions, event publishing, and observability — using plain async/await.
6
+
7
+ ```typescript
8
+ import { createFlow, ValidationError } from '@celom/prose';
9
+
10
+ const onboardUser = createFlow<{ email: string; name: string }>('onboard-user')
11
+ .validate('checkEmail', (ctx) => {
12
+ if (!ctx.input.email.includes('@'))
13
+ throw ValidationError.single('email', 'Invalid email');
14
+ })
15
+ .step('createAccount', async (ctx) => {
16
+ const user = await db.createUser(ctx.input);
17
+ return { user };
18
+ })
19
+ .withRetry({ maxAttempts: 3, delayMs: 200, backoffMultiplier: 2 })
20
+ .step('sendWelcome', async (ctx) => {
21
+ await mailer.send(ctx.state.user.email, 'Welcome!');
22
+ })
23
+ .event('users', (ctx) => ({
24
+ eventType: 'user.onboarded',
25
+ userId: ctx.state.user.id,
26
+ }))
27
+ .build();
28
+
29
+ const result = await onboardUser.execute(
30
+ { email: 'alice@example.com', name: 'Alice' },
31
+ { db, eventPublisher }
32
+ );
33
+ ```
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ npm install @celom/prose
39
+ ```
40
+
41
+ ## Features
42
+
43
+ - **Type-safe state threading** — each step's return type merges into `ctx.state`, giving you full autocomplete and compile-time checks across the entire pipeline
44
+ - **Retries with exponential backoff** — per-step retry policies with configurable delays, backoff multipliers, caps, and conditional retry predicates
45
+ - **Timeouts** — flow-level and step-level timeouts backed by `AbortSignal`, with actual interruption of async operations
46
+ - **Cooperative cancellation** — pass an external `AbortSignal` to cancel a running flow
47
+ - **Database transactions** — wrap steps in `db.transaction()` with any ORM (Drizzle, Knex, Prisma)
48
+ - **Event publishing** — emit domain events to named channels with automatic correlation IDs
49
+ - **Parallel execution** — run independent steps concurrently with configurable merge strategies
50
+ - **Conditional steps & early exit** — skip steps based on runtime conditions or short-circuit the flow entirely
51
+ - **Composable sub-flows** — extract and reuse step sequences via `.pipe()`
52
+ - **Observability hooks** — plug in logging, metrics, or tracing through the observer interface
53
+ - **Zero dependencies** — runs in-process with no external infrastructure
54
+
55
+ ## Guide
56
+
57
+ ### Creating a flow
58
+
59
+ `createFlow` returns a builder. Chain steps onto it and call `.build()` to get an executable flow.
60
+
61
+ ```typescript
62
+ import { createFlow } from '@celom/prose';
63
+
64
+ const flow = createFlow<{ orderId: string }>('process-order')
65
+ .step('fetch', async (ctx) => {
66
+ const order = await db.getOrder(ctx.input.orderId);
67
+ return { order };
68
+ })
69
+ .step('charge', async (ctx) => {
70
+ const receipt = await payments.charge(ctx.state.order.total);
71
+ return { receipt };
72
+ })
73
+ .build();
74
+ ```
75
+
76
+ The generic parameter defines the input shape. TypeScript infers the state type as steps accumulate — after the `fetch` step, `ctx.state.order` is available with full type information.
77
+
78
+ ### Running a flow
79
+
80
+ ```typescript
81
+ const result = await flow.execute(
82
+ { orderId: 'ord_123' }, // input
83
+ { db, eventPublisher }, // dependencies
84
+ { timeout: 30_000 } // options (optional)
85
+ );
86
+ ```
87
+
88
+ **Execution options:**
89
+
90
+ | Option | Type | Description |
91
+ |--------|------|-------------|
92
+ | `timeout` | `number` | Max duration for the entire flow (ms) |
93
+ | `stepTimeout` | `number` | Default max duration per step (ms) |
94
+ | `signal` | `AbortSignal` | External signal for cancellation |
95
+ | `observer` | `FlowObserver` | Lifecycle hooks for logging/metrics |
96
+ | `throwOnError` | `boolean` | `false` returns partial state instead of throwing |
97
+ | `correlationId` | `string` | Custom ID propagated to events and observers |
98
+ | `errorHandling` | `object` | Control behavior for missing deps (see below) |
99
+
100
+ ### Validation
101
+
102
+ Validation steps run before processing and are never retried. Throw `ValidationError` to fail fast.
103
+
104
+ ```typescript
105
+ import { ValidationError } from '@celom/prose';
106
+
107
+ flow.validate('checkInput', (ctx) => {
108
+ if (ctx.input.amount <= 0)
109
+ throw ValidationError.single('amount', 'Must be positive');
110
+ });
111
+ ```
112
+
113
+ `ValidationError` accepts an optional array of issues for multi-field validation:
114
+
115
+ ```typescript
116
+ throw new ValidationError('Validation failed', [
117
+ { field: 'email', message: 'Required' },
118
+ { field: 'age', message: 'Must be at least 18' },
119
+ ]);
120
+ ```
121
+
122
+ ### Retries
123
+
124
+ Chain `.withRetry()` after any step to add a retry policy.
125
+
126
+ ```typescript
127
+ flow
128
+ .step('callExternalApi', async (ctx) => {
129
+ const data = await api.fetch(ctx.input.url);
130
+ return { data };
131
+ })
132
+ .withRetry({
133
+ maxAttempts: 5,
134
+ delayMs: 100,
135
+ backoffMultiplier: 2,
136
+ maxDelayMs: 5_000,
137
+ shouldRetry: (err) => err.status !== 400,
138
+ stepTimeout: 10_000, // override the flow-level stepTimeout for this step
139
+ })
140
+ ```
141
+
142
+ | Option | Type | Default | Description |
143
+ |--------|------|---------|-------------|
144
+ | `maxAttempts` | `number` | — | Total attempts (including the first) |
145
+ | `delayMs` | `number` | — | Initial delay between retries |
146
+ | `backoffMultiplier` | `number` | `1` | Multiplier applied to delay after each retry |
147
+ | `maxDelayMs` | `number` | `Infinity` | Upper bound on delay |
148
+ | `shouldRetry` | `(error) => boolean` | — | Predicate to conditionally retry |
149
+ | `stepTimeout` | `number` | — | Timeout override for this step |
150
+
151
+ ### Timeouts & cancellation
152
+
153
+ ```typescript
154
+ const controller = new AbortController();
155
+
156
+ const result = await flow.execute(input, deps, {
157
+ timeout: 30_000, // abort if the flow exceeds 30s
158
+ stepTimeout: 5_000, // abort any step that exceeds 5s
159
+ signal: controller.signal, // cancel from outside
160
+ });
161
+
162
+ // later, to cancel:
163
+ controller.abort();
164
+ ```
165
+
166
+ Inside step handlers, `ctx.signal` exposes the combined signal so you can pass it to fetch, database calls, or check `ctx.signal.aborted` for cooperative cancellation.
167
+
168
+ ```typescript
169
+ flow.step('longOperation', async (ctx) => {
170
+ const resp = await fetch(url, { signal: ctx.signal });
171
+ return { data: await resp.json() };
172
+ });
173
+ ```
174
+
175
+ ### Conditional steps
176
+
177
+ `stepIf` runs the handler only when the condition returns `true`. Skipped steps don't affect state and don't consume retry attempts.
178
+
179
+ ```typescript
180
+ flow
181
+ .step('checkCache', (ctx) => {
182
+ return { cached: cache.has(ctx.input.key) };
183
+ })
184
+ .stepIf('fromCache', (ctx) => ctx.state.cached, (ctx) => {
185
+ return { value: cache.get(ctx.input.key) };
186
+ })
187
+ .stepIf('fromDb', (ctx) => !ctx.state.cached, async (ctx) => {
188
+ return { value: await db.get(ctx.input.key) };
189
+ })
190
+ ```
191
+
192
+ ### Early exit with breakIf
193
+
194
+ `breakIf` short-circuits the flow, skipping all remaining steps **and** the `.map()` transformer. An optional second argument defines the return value.
195
+
196
+ ```typescript
197
+ flow
198
+ .step('findUser', async (ctx) => {
199
+ const existing = await db.findByEmail(ctx.input.email);
200
+ return { existing };
201
+ })
202
+ .breakIf(
203
+ (ctx) => ctx.state.existing != null,
204
+ (ctx) => ({ user: ctx.state.existing, created: false })
205
+ )
206
+ .step('createUser', async (ctx) => {
207
+ const user = await db.createUser(ctx.input);
208
+ return { user };
209
+ })
210
+ .map((input, state) => ({ user: state.user, created: true }))
211
+ .build();
212
+ ```
213
+
214
+ ### Database transactions
215
+
216
+ Use `.transaction()` to wrap a step in `db.transaction()`. The transaction client is passed as the second argument.
217
+
218
+ ```typescript
219
+ flow.transaction('persist', async (ctx, tx) => {
220
+ const id = await tx.insert('users', { name: ctx.input.name });
221
+ return { userId: id };
222
+ });
223
+ ```
224
+
225
+ Requires a `db` dependency conforming to:
226
+
227
+ ```typescript
228
+ interface DatabaseClient {
229
+ transaction<T>(fn: (tx: TransactionClient) => Promise<T>): Promise<T>;
230
+ }
231
+ ```
232
+
233
+ Works with Drizzle, Knex, Prisma, or any ORM exposing a `transaction()` method.
234
+
235
+ ### Event publishing
236
+
237
+ Emit domain events to named channels. Events are automatically enriched with `correlationId`.
238
+
239
+ ```typescript
240
+ // single event
241
+ flow.event('orders', (ctx) => ({
242
+ eventType: 'order.created',
243
+ orderId: ctx.state.orderId,
244
+ }));
245
+
246
+ // multiple events on the same channel
247
+ flow.events('notifications', [
248
+ (ctx) => ({ eventType: 'email.send', to: ctx.input.email }),
249
+ (ctx) => ({ eventType: 'sms.send', to: ctx.input.phone }),
250
+ ]);
251
+ ```
252
+
253
+ Requires an `eventPublisher` dependency conforming to:
254
+
255
+ ```typescript
256
+ interface FlowEventPublisher {
257
+ publish(channel: string, event: FlowEvent): Promise<void> | void;
258
+ }
259
+ ```
260
+
261
+ ### Parallel execution
262
+
263
+ Run independent handlers concurrently and merge results into state.
264
+
265
+ ```typescript
266
+ flow.parallel('fetchAll', 'deep',
267
+ async (ctx) => ({ users: await fetchUsers() }),
268
+ async (ctx) => ({ posts: await fetchPosts() }),
269
+ );
270
+ // ctx.state now has both `users` and `posts`
271
+ ```
272
+
273
+ **Merge strategies:**
274
+
275
+ | Strategy | Behavior |
276
+ |----------|----------|
277
+ | `'shallow'` | `Object.assign()` — later results override earlier ones |
278
+ | `'error-on-conflict'` | Throws if any keys overlap between results |
279
+ | `'deep'` | Recursive merge; arrays are concatenated |
280
+
281
+ ### Output transformation
282
+
283
+ `.map()` transforms the accumulated state into a custom output shape.
284
+
285
+ ```typescript
286
+ flow
287
+ .step('fetch', async (ctx) => {
288
+ const user = await db.getUser(ctx.input.id);
289
+ return { user };
290
+ })
291
+ .map((input, state) => ({
292
+ id: state.user.id,
293
+ displayName: state.user.name,
294
+ }))
295
+ .build();
296
+ ```
297
+
298
+ ### Composable sub-flows with .pipe()
299
+
300
+ Extract reusable step sequences as functions and compose them with `.pipe()`.
301
+
302
+ ```typescript
303
+ function withAuth(builder) {
304
+ return builder
305
+ .step('validateToken', async (ctx) => {
306
+ const session = await auth.verify(ctx.input.token);
307
+ return { session };
308
+ })
309
+ .step('loadUser', async (ctx) => {
310
+ const user = await db.getUser(ctx.state.session.userId);
311
+ return { user };
312
+ });
313
+ }
314
+
315
+ const flow = createFlow<{ token: string }>('protected-action')
316
+ .pipe(withAuth)
317
+ .step('doAction', (ctx) => {
318
+ // ctx.state.user is fully typed here
319
+ return { result: `Hello, ${ctx.state.user.name}` };
320
+ })
321
+ .build();
322
+ ```
323
+
324
+ ### Observability
325
+
326
+ Pass an observer to hook into flow and step lifecycle events.
327
+
328
+ ```typescript
329
+ import { PinoFlowObserver } from '@celom/prose';
330
+ import pino from 'pino';
331
+
332
+ const logger = pino();
333
+ const observer = new PinoFlowObserver(logger);
334
+
335
+ await flow.execute(input, deps, { observer });
336
+ ```
337
+
338
+ **Observer hooks:**
339
+
340
+ | Hook | Called when |
341
+ |------|------------|
342
+ | `onFlowStart` | Flow begins |
343
+ | `onFlowComplete` | Flow finishes successfully |
344
+ | `onFlowError` | Flow fails |
345
+ | `onFlowBreak` | Flow exits early via `breakIf` |
346
+ | `onStepStart` | Step begins |
347
+ | `onStepComplete` | Step finishes |
348
+ | `onStepError` | Step fails (after exhausting retries) |
349
+ | `onStepRetry` | Step is about to be retried |
350
+ | `onStepSkipped` | Conditional step is skipped |
351
+
352
+ All hooks are optional — implement only what you need:
353
+
354
+ ```typescript
355
+ await flow.execute(input, deps, {
356
+ observer: {
357
+ onStepComplete: (name, _result, duration) =>
358
+ console.log(`${name} took ${duration}ms`),
359
+ },
360
+ });
361
+ ```
362
+
363
+ **Built-in observers:** `DefaultObserver` (console), `NoOpObserver` (silent), `PinoFlowObserver` (structured logging).
364
+
365
+ ### Error handling
366
+
367
+ By default, step errors are wrapped in `FlowExecutionError` and thrown.
368
+
369
+ ```typescript
370
+ import { FlowExecutionError, ValidationError, TimeoutError } from '@celom/prose';
371
+
372
+ try {
373
+ await flow.execute(input, deps);
374
+ } catch (err) {
375
+ if (err instanceof ValidationError) {
376
+ // fail-fast validation — err.issues has field-level details
377
+ } else if (err instanceof TimeoutError) {
378
+ // flow or step exceeded its timeout
379
+ } else if (err instanceof FlowExecutionError) {
380
+ // step execution failure — err.stepName, err.originalError
381
+ }
382
+ }
383
+ ```
384
+
385
+ Set `throwOnError: false` to return partial state instead of throwing:
386
+
387
+ ```typescript
388
+ const result = await flow.execute(input, deps, { throwOnError: false });
389
+ ```
390
+
391
+ Control behavior when optional dependencies are missing:
392
+
393
+ ```typescript
394
+ await flow.execute(input, deps, {
395
+ errorHandling: {
396
+ throwOnMissingDatabase: false, // warn instead of throwing
397
+ throwOnMissingEventPublisher: false, // warn instead of throwing
398
+ },
399
+ });
400
+ ```
401
+
402
+ ### Flow metadata
403
+
404
+ Every step handler receives `ctx.meta` with runtime metadata:
405
+
406
+ ```typescript
407
+ flow.step('example', (ctx) => {
408
+ ctx.meta.flowName; // 'process-order'
409
+ ctx.meta.currentStep; // 'example'
410
+ ctx.meta.startedAt; // Date
411
+ ctx.meta.correlationId; // auto-generated or custom
412
+ });
413
+ ```
414
+
415
+ ## What this isn't
416
+
417
+ Prose is an **in-process** workflow orchestration library. It runs inside your existing Node.js process with zero external dependencies. Before adopting it, it's worth understanding what it does _not_ try to be:
418
+
419
+ **Not a durable execution engine.** If you need workflows that survive process restarts, resume after hours or days, or coordinate across distributed services, look at [Temporal](https://temporal.io), [Inngest](https://www.inngest.com), or [Trigger.dev](https://trigger.dev). These require infrastructure (servers, queues, databases) but give you persistence and replay guarantees that an in-process library fundamentally cannot.
420
+
421
+ **Not a full effect system.** [Effect-TS](https://effect.website) is more powerful in every technical dimension — typed errors in the return signature, type-level dependency injection via Layers, fibers, streams, and a massive standard library. If your team can invest in learning its functional programming model, Effect is the more capable choice. Prose trades that power for simplicity: pure async/await, no monads, no new paradigms to learn.
422
+
423
+ **Not a state machine.** [XState](https://stately.ai/docs/xstate) models workflows as finite state machines with explicit states, transitions, and guards — ideal for complex non-linear flows with many possible state transitions. Prose is designed for sequential (or branching) business logic pipelines where a state machine's verbosity would be overhead.
424
+
425
+ **Not a result type library.** Libraries like [neverthrow](https://github.com/supermacro/neverthrow) or [fp-ts](https://github.com/gcanti/fp-ts) encode errors in return types (`Result<T, E>`, `Either<E, A>`). Prose does not — steps throw, and failures are wrapped in `FlowExecutionError`. If typed error channels are critical to you, Effect or neverthrow are better fits.
426
+
427
+ ### Where Prose fits
428
+
429
+ Prose is for teams building backend services with multi-step business logic (process an order, onboard a user, handle a payment) who want structured retries, timeouts, transactions, observability, and type-safe state threading — without adopting new infrastructure or a new programming paradigm.
430
+
431
+ ## License
432
+
433
+ MIT
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@celom/prose",
3
+ "version": "0.1.1",
4
+ "author": "Carlos Mimoso",
5
+ "description": "Declarative workflow DSL for orchestrating complex business operations in Javascript/Typescript.",
6
+ "keywords": [
7
+ "workflow",
8
+ "typescript",
9
+ "pipeline",
10
+ "async",
11
+ "dsl",
12
+ "workflow-engine",
13
+ "orchestration",
14
+ "type-safe",
15
+ "retry",
16
+ "business-logic",
17
+ "observability"
18
+ ],
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/celom/prose.git"
22
+ },
23
+ "private": false,
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "type": "module",
28
+ "main": "./dist/index.js",
29
+ "module": "./dist/index.js",
30
+ "types": "./dist/index.d.ts",
31
+ "exports": {
32
+ "./package.json": "./package.json",
33
+ ".": {
34
+ "@celom/source": "./src/index.ts",
35
+ "types": "./dist/index.d.ts",
36
+ "import": "./dist/index.js",
37
+ "default": "./dist/index.js"
38
+ }
39
+ },
40
+ "files": [
41
+ "dist",
42
+ "README.md"
43
+ ],
44
+ "dependencies": {}
45
+ }