@chidchanun/bcp 0.2.10 → 0.2.12

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.
@@ -0,0 +1,374 @@
1
+ # Workflow Orchestration
2
+
3
+ BCP `0.2.11` adds a server-only workflow orchestration layer through `bcp/workflow`.
4
+
5
+ The workflow runtime is designed for application processes that need to coordinate multiple backend operations as one observable run instead of manually chaining unrelated jobs.
6
+
7
+ ## Public entrypoint
8
+
9
+ ```ts
10
+ import {
11
+ createMemoryWorkflowStore,
12
+ createWorkflow,
13
+ } from "bcp/workflow";
14
+ ```
15
+
16
+ `bcp/workflow` is server-only and must not be imported into page/client bundles.
17
+
18
+ ## Basic workflow
19
+
20
+ ```ts
21
+ import {
22
+ createWorkflow,
23
+ } from "bcp/workflow";
24
+
25
+ export const onboarding =
26
+ createWorkflow<{
27
+ userId: number;
28
+ }>(
29
+ "user.onboarding",
30
+ workflow => {
31
+ workflow.step(
32
+ "create-profile",
33
+ async ({ input }) => {
34
+ await createProfile(
35
+ input.userId
36
+ );
37
+ }
38
+ );
39
+
40
+ workflow.step(
41
+ "send-email",
42
+ async ({ input }) => {
43
+ await sendWelcomeEmail(
44
+ input.userId
45
+ );
46
+ }
47
+ );
48
+ }
49
+ );
50
+ ```
51
+
52
+ Start a run:
53
+
54
+ ```ts
55
+ const run =
56
+ await onboarding.start({
57
+ userId: 42,
58
+ });
59
+ ```
60
+
61
+ Without a background queue, `start()` executes immediately until the workflow either succeeds, fails, is cancelled or reaches a delay.
62
+
63
+ ## Step retries
64
+
65
+ Each step can define its own retry policy:
66
+
67
+ ```ts
68
+ workflow.step(
69
+ "charge-card",
70
+ chargeCard,
71
+ {
72
+ maxAttempts: 3,
73
+ retryDelayMs:
74
+ attempt =>
75
+ attempt * 1_000,
76
+ }
77
+ );
78
+ ```
79
+
80
+ The runtime persists the step attempt count and terminal error in the workflow run record.
81
+
82
+ Step retries happen inside the workflow execution attempt. Long-running production workflows should prefer idempotent handlers so a process restart or at-least-once queue delivery can safely repeat work.
83
+
84
+ ## Parallel steps
85
+
86
+ Use `parallel()` when independent operations can run together:
87
+
88
+ ```ts
89
+ workflow.parallel(
90
+ "initialize",
91
+ parallel => {
92
+ parallel.step(
93
+ "preferences",
94
+ createPreferences
95
+ );
96
+
97
+ parallel.step(
98
+ "workspace",
99
+ createDefaultWorkspace
100
+ );
101
+ }
102
+ );
103
+ ```
104
+
105
+ All child step records are persisted under the parent parallel step.
106
+
107
+ The parallel group succeeds only when every child succeeds.
108
+
109
+ ## Delays
110
+
111
+ A workflow delay is persisted instead of blocking a process with a long timer:
112
+
113
+ ```ts
114
+ workflow.delay(
115
+ "cooldown",
116
+ 5 * 60 * 1000
117
+ );
118
+ ```
119
+
120
+ The run transitions to:
121
+
122
+ ```text
123
+ waiting
124
+ ```
125
+
126
+ with a persisted `waitUntil` timestamp.
127
+
128
+ For an in-process workflow, resume after the timestamp:
129
+
130
+ ```ts
131
+ await workflow.resume(
132
+ run.id
133
+ );
134
+ ```
135
+
136
+ Calling `resume()` before `waitUntil` returns the current waiting run unchanged.
137
+
138
+ For administrative tooling, a forced resume is available:
139
+
140
+ ```ts
141
+ await workflow.resume(
142
+ run.id,
143
+ {
144
+ force: true,
145
+ }
146
+ );
147
+ ```
148
+
149
+ ## Queue-backed execution
150
+
151
+ A workflow can use the BCP background job queue as its execution layer:
152
+
153
+ ```ts
154
+ import {
155
+ createJobQueue,
156
+ } from "bcp/jobs";
157
+ import {
158
+ createWorkflow,
159
+ } from "bcp/workflow";
160
+
161
+ const jobs =
162
+ createJobQueue({
163
+ adapter:
164
+ redisQueueAdapter,
165
+ });
166
+
167
+ export const workflow =
168
+ createWorkflow(
169
+ "order.fulfillment",
170
+ defineOrderWorkflow,
171
+ {
172
+ queue: jobs,
173
+ store:
174
+ workflowStore,
175
+ }
176
+ );
177
+ ```
178
+
179
+ When a queue is configured:
180
+
181
+ - `start()` persists the run and enqueues execution,
182
+ - `retry()` enqueues another execution attempt,
183
+ - `resume()` enqueues continuation,
184
+ - workflow delays enqueue a delayed continuation job,
185
+ - worker concurrency, visibility leases, heartbeat and stale-job recovery remain owned by `bcp/jobs`.
186
+
187
+ This keeps job delivery and workflow state as separate contracts.
188
+
189
+ ## Workflow store
190
+
191
+ `WorkflowStore` is the persistence boundary:
192
+
193
+ ```ts
194
+ interface WorkflowStore {
195
+ create(run): Promise<void>;
196
+ update(run): Promise<void>;
197
+ get(id): Promise<WorkflowRunRecord | null>;
198
+ list(workflowName?): Promise<WorkflowRunRecord[]>;
199
+
200
+ claim(
201
+ id,
202
+ {
203
+ ownerId,
204
+ now,
205
+ leaseMs,
206
+ }
207
+ ): Promise<boolean>;
208
+
209
+ release(
210
+ id,
211
+ ownerId
212
+ ): Promise<void>;
213
+ }
214
+ ```
215
+
216
+ The built-in:
217
+
218
+ ```ts
219
+ createMemoryWorkflowStore()
220
+ ```
221
+
222
+ is intended for local development and deterministic tests.
223
+
224
+ Production multi-instance deployments should use a shared durable store whose `claim()` operation is atomic.
225
+
226
+ The claim is the workflow concurrency boundary. Only one owner should execute a run while its lease is active.
227
+
228
+ ## Run states
229
+
230
+ Workflow runs use:
231
+
232
+ ```text
233
+ pending
234
+ running
235
+ waiting
236
+ succeeded
237
+ failed
238
+ cancelled
239
+ compensating
240
+ compensated
241
+ ```
242
+
243
+ Step records use the corresponding lifecycle states where applicable.
244
+
245
+ ## Inspect runs
246
+
247
+ ```ts
248
+ const run =
249
+ await workflow.get(
250
+ runId
251
+ );
252
+
253
+ const runs =
254
+ await workflow.list();
255
+ ```
256
+
257
+ A run includes:
258
+
259
+ - workflow name,
260
+ - input payload,
261
+ - current state,
262
+ - timestamps,
263
+ - current step,
264
+ - step attempts/errors,
265
+ - parallel child state,
266
+ - `waitUntil`,
267
+ - completion order,
268
+ - current workflow lease metadata.
269
+
270
+ ## Manual retry
271
+
272
+ A failed run can retry its failed step without rerunning already successful preceding steps:
273
+
274
+ ```ts
275
+ await workflow.retry(
276
+ runId
277
+ );
278
+ ```
279
+
280
+ The failed step attempt counter is reset for the new manual retry cycle.
281
+
282
+ ## Compensation
283
+
284
+ A successful step can provide a compensation handler:
285
+
286
+ ```ts
287
+ workflow.step(
288
+ "reserve-stock",
289
+ reserveStock,
290
+ {
291
+ compensate:
292
+ releaseStock,
293
+ }
294
+ );
295
+ ```
296
+
297
+ If a later step fails, application code can request compensation:
298
+
299
+ ```ts
300
+ await workflow.compensate(
301
+ runId
302
+ );
303
+ ```
304
+
305
+ Compensation runs successful compensatable steps in reverse completion order.
306
+
307
+ This is a saga-style compensation mechanism. It does not provide database transaction rollback across external services.
308
+
309
+ Cancellation can also request compensation:
310
+
311
+ ```ts
312
+ await workflow.cancel(
313
+ runId,
314
+ {
315
+ compensate: true,
316
+ }
317
+ );
318
+ ```
319
+
320
+ ## Idempotency
321
+
322
+ Workflow state and background queues provide orchestration and delivery coordination, but external side effects should still be idempotent.
323
+
324
+ For example, payment and email handlers should use application-level idempotency keys where duplicate execution would be harmful.
325
+
326
+ A durable queue provides at-least-once delivery semantics, not exactly-once external side effects.
327
+
328
+ ## Recommended production topology
329
+
330
+ ```text
331
+ API / application
332
+ |
333
+ v
334
+ WorkflowStore (shared durable state)
335
+ |
336
+ v
337
+ BCP Workflow
338
+ |
339
+ v
340
+ Durable JobQueueAdapter
341
+ |
342
+ +---+---+
343
+ | |
344
+ Worker A Worker B
345
+ ```
346
+
347
+ The workflow store and queue do not have to use the same backend.
348
+
349
+ ## Lifecycle
350
+
351
+ ```ts
352
+ await workflow.close();
353
+ await jobs.close();
354
+ ```
355
+
356
+ `workflow.close()` unregisters its internal queue handler and closes the configured workflow store when the store exposes `close()`.
357
+
358
+ The application remains responsible for lifecycle of external database/Redis clients used by custom stores/adapters.
359
+
360
+ ## Current scope
361
+
362
+ `0.2.11` intentionally focuses on the orchestration core:
363
+
364
+ - sequential steps,
365
+ - parallel steps,
366
+ - step retries,
367
+ - persisted delays,
368
+ - manual retry/resume/cancel,
369
+ - compensation,
370
+ - workflow state persistence,
371
+ - run leases,
372
+ - optional `bcp/jobs` execution.
373
+
374
+ Transactional outbox/event delivery remains a separate future milestone so workflow orchestration does not pretend to make a database transaction and external queue publish atomic.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chidchanun/bcp",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "BCP Framework - a React full-stack framework with file-based routing, SSR, APIs, middleware, islands, caching and standalone production builds.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -67,6 +67,16 @@
67
67
  "browser": "./packages/client/src/server-only.browser.mjs",
68
68
  "default": "./packages/client/src/jobs.mjs"
69
69
  },
70
+ "./workflow": {
71
+ "types": "./packages/client/src/workflow.ts",
72
+ "browser": "./packages/client/src/server-only.browser.mjs",
73
+ "default": "./packages/client/src/workflow.mjs"
74
+ },
75
+ "./events": {
76
+ "types": "./packages/client/src/events.ts",
77
+ "browser": "./packages/client/src/server-only.browser.mjs",
78
+ "default": "./packages/client/src/events.mjs"
79
+ },
70
80
  "./observability": {
71
81
  "types": "./packages/client/src/observability.ts",
72
82
  "browser": "./packages/client/src/server-only.browser.mjs",
@@ -29,6 +29,8 @@ const SERVER_ONLY_IMPORTS =
29
29
  "bcp/database",
30
30
  "bcp/auth",
31
31
  "bcp/jobs",
32
+ "bcp/workflow",
33
+ "bcp/events",
32
34
  "bcp/observability",
33
35
  ]);
34
36