@adonis-agora/durable 0.24.3 → 0.25.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.
@@ -0,0 +1,296 @@
1
+ ---
2
+ name: durable-transports-stores
3
+ description: >-
4
+ Wire @adonis-agora/durable drivers in config/durable.ts: transports
5
+ (memory/eventEmitter/queue over @adonisjs/queue, broker-less db over Lucid,
6
+ bullmq for cross-runtime), per-step transport pinning and failover pools,
7
+ controlPlanes.redis broadcast channel, the lucid StateStore (published
8
+ migration delegating to createDurableTables, autoSchema, repair warning,
9
+ updateRunIf compare-and-set), CodecStateStore payload encryption with a
10
+ PayloadCodec, and namespace vs partition isolation. Use when moving steps
11
+ across processes, persisting runs durably, sharing one database/broker
12
+ between pools, or encrypting payloads at rest.
13
+ license: MIT
14
+ metadata:
15
+ type: core
16
+ library: "@adonis-agora/durable"
17
+ library_version: "0.25.0"
18
+ framework: adonisjs
19
+ sources:
20
+ - 'DavideCarvalho/adonis-durable:docs/transports/index.mdx'
21
+ - 'DavideCarvalho/adonis-durable:docs/transports/queue.mdx'
22
+ - 'DavideCarvalho/adonis-durable:docs/transports/db.mdx'
23
+ - 'DavideCarvalho/adonis-durable:docs/transports/control-plane.mdx'
24
+ - 'DavideCarvalho/adonis-durable:docs/stores/index.mdx'
25
+ - 'DavideCarvalho/adonis-durable:docs/stores/lucid.mdx'
26
+ ---
27
+
28
+ # Transports & state stores
29
+
30
+ A transport carries a dispatched `ctx.step` to its handler and the result back;
31
+ a state store persists runs and checkpoints. They are independent pluggable,
32
+ config-selected drivers — mix any store with any transport. Drivers are named
33
+ from the `transports`/`stores` maps and lazily import their optional peer only
34
+ when selected.
35
+
36
+ ## Setup
37
+
38
+ ```bash
39
+ node ace configure @adonis-agora/durable # publishes config + migrations
40
+ node ace migration:run # durable tables (+ db-transport tables)
41
+ ```
42
+
43
+ ```ts title="config/durable.ts"
44
+ import { defineConfig, stores, transports } from '@adonis-agora/durable'
45
+
46
+ export default defineConfig({
47
+ transport: 'queue',
48
+ transports: {
49
+ memory: transports.memory(),
50
+ 'event-emitter': transports.eventEmitter(),
51
+ queue: transports.queue({ connection: 'redis' }), // names a config/queue.ts adapter
52
+ db: transports.db(), // pass { connection } for a non-default Lucid connection
53
+ },
54
+ store: 'lucid',
55
+ stores: { lucid: stores.lucid() },
56
+ })
57
+ ```
58
+
59
+ Switching transports is a one-line change; workflow code never changes.
60
+
61
+ ## Core patterns
62
+
63
+ ### Pattern 1 — cross-process steps without new infrastructure (`db`)
64
+
65
+ The `db` transport makes dispatched steps rows in the database you already run —
66
+ no Redis, no broker. Workers claim rows with an atomic portable lease (no
67
+ `FOR UPDATE SKIP LOCKED` needed):
68
+
69
+ ```ts title="start/worker.ts (a separate worker process)"
70
+ import db from '@adonisjs/lucid/services/db'
71
+ import { DbTransport } from '@adonis-agora/durable'
72
+
73
+ const transport = new DbTransport({ db }) // { partition } isolates this pool
74
+ transport.handle('pipeline:extract', async (input) => extract(input))
75
+ ```
76
+
77
+ Trade-off: throughput is bounded by polling + row contention — right for
78
+ workflow-scale modest rates, not high-fanout firehoses. Delivery is
79
+ at-least-once (crash between insert-result and delete-task), so handlers should
80
+ be idempotent on `step_id`. Source: `docs/transports/db.mdx`.
81
+
82
+ ### Pattern 2 — real brokers (`queue`, `bullmq`) and stalled-claim tuning
83
+
84
+ The `queue` transport rides any `@adonisjs/queue` adapter defined once in
85
+ `config/queue.ts`; `bullmq` is the cross-ecosystem wire shared byte-for-byte
86
+ with NestJS engines and Python workers:
87
+
88
+ ```ts title="config/durable.ts"
89
+ export default defineConfig({
90
+ transports: {
91
+ queue: transports.queue({
92
+ connection: 'redis',
93
+ // A claim is NEVER renewed while a worker processes it — its age is the
94
+ // step's elapsed time. Raise the threshold above your longest step:
95
+ stalledThresholdMs: 2 * 60 * 60 * 1000, // default 30min
96
+ maxStalledCount: 3, // bounds a poison job
97
+ onError: (err) => logger.error({ err }, 'durable queue transport'),
98
+ }),
99
+ },
100
+ })
101
+ ```
102
+
103
+ One instance consumes engine-side results; run one or more `node ace
104
+ durable:work` workers serving the step names. Source:
105
+ `docs/transports/queue.mdx`.
106
+
107
+ ### Pattern 3 — multi-replica fan-out: the control plane
108
+
109
+ The task transport is point-to-point; lifecycle events and cancellation need a
110
+ broadcast channel across every replica. Omit it and the engine is local-only
111
+ (correct for single-instance):
112
+
113
+ ```ts title="config/durable.ts"
114
+ import { controlPlanes, defineConfig } from '@adonis-agora/durable'
115
+
116
+ export default defineConfig({
117
+ // …
118
+ controlPlane: controlPlanes.redis({ connection: 'main' }), // config/redis.ts connection
119
+ })
120
+ ```
121
+
122
+ Channel is `${prefix}-control` (default prefix `durable`) — wire-compatible with
123
+ the NestJS fleet's channel, so an AdonisJS and NestJS deployment sharing one
124
+ Redis fan out across both runtimes. Source: `docs/transports/control-plane.mdx`.
125
+
126
+ ### Pattern 4 — schema ownership and encryption at rest
127
+
128
+ The migration **delegates** to the library instead of snapshotting DDL, so the
129
+ schema cannot drift from what the installed version writes:
130
+
131
+ ```ts title="database/migrations/…_create_durable_tables.ts"
132
+ import { BaseSchema } from '@adonisjs/lucid/schema'
133
+ import db from '@adonisjs/lucid/services/db'
134
+ import { createDurableTables, dropDurableTables } from '@adonis-agora/durable'
135
+
136
+ export default class extends BaseSchema {
137
+ static disableTransactions = true
138
+
139
+ async up() {
140
+ await createDurableTables(db, this.db.connectionName)
141
+ }
142
+
143
+ async down() {
144
+ await dropDurableTables(db, this.db.connectionName)
145
+ }
146
+ }
147
+ ```
148
+
149
+ By default the provider also provisions idempotently at boot (`ensureSchema`);
150
+ turn that off to own every change through reviewed migrations:
151
+
152
+ ```ts
153
+ export default defineConfig({ /* … */ autoSchema: false })
154
+ // then `node ace migration:run` after EVERY package upgrade
155
+ ```
156
+
157
+ Encrypt run/step/signal/event payloads by decorating any store with a codec
158
+ (searchable metadata and structured error stay plaintext):
159
+
160
+ ```ts title="config/durable.ts"
161
+ import { CodecStateStore, LucidStateStore, defineConfig } from '@adonis-agora/durable'
162
+ import type { PayloadCodec } from '@adonis-agora/durable'
163
+ import db from '@adonisjs/lucid/services/db'
164
+
165
+ const aesCodec: PayloadCodec = {
166
+ encode(value) {
167
+ const body = Buffer.from(JSON.stringify(value), 'utf8').toString('base64')
168
+ return { body }
169
+ },
170
+ decode(value) {
171
+ return JSON.parse(Buffer.from((value as { body: string }).body, 'base64').toString('utf8'))
172
+ },
173
+ }
174
+
175
+ export default defineConfig({
176
+ store: 'encrypted',
177
+ stores: { encrypted: async () => new CodecStateStore(new LucidStateStore(db), aesCodec) },
178
+ })
179
+ ```
180
+
181
+ Source: `docs/stores/lucid.mdx`, `docs/stores/index.mdx`.
182
+
183
+ ## Common mistakes
184
+
185
+ ### HIGH setting `stalledThresholdMs` below the longest legitimate step
186
+
187
+ Wrong:
188
+
189
+ ```ts
190
+ transports.queue({ connection: 'redis', stalledThresholdMs: 60_000 }) // imports take ~90min
191
+ ```
192
+
193
+ Correct:
194
+
195
+ ```ts
196
+ transports.queue({ connection: 'redis', stalledThresholdMs: 2 * 60 * 60 * 1000 })
197
+ ```
198
+
199
+ Mechanism: a claim's age equals the step's elapsed runtime because nothing
200
+ renews it mid-processing; a threshold below the longest step redelivers jobs
201
+ from merely-slow workers and double-runs the step. For tighter detection use
202
+ per-step `timeoutMs`, which IS heartbeat-aware.
203
+ Source: `docs/transports/queue.mdx` ("Stalled-job reclaim").
204
+
205
+ ### HIGH hand-copying the DDL into the migration
206
+
207
+ Wrong:
208
+
209
+ ```ts
210
+ export default class extends BaseSchema {
211
+ async up() {
212
+ this.schema.createTable('durable_workflow_runs', (t) => {
213
+ /* copied column list */
214
+ })
215
+ }
216
+ }
217
+ ```
218
+
219
+ Correct:
220
+
221
+ ```ts
222
+ export default class extends BaseSchema {
223
+ static disableTransactions = true
224
+ async up() {
225
+ await createDurableTables(db, this.db.connectionName)
226
+ }
227
+ }
228
+ ```
229
+
230
+ Mechanism: the snapshot is only right until the next release drifts past it, and
231
+ the two details are load-bearing — without `disableTransactions = true` a
232
+ `pool.max: 1` setup hangs until acquire timeout (createDurableTables checks out
233
+ its own connection), and `this.db` has no `.connection()` method, only
234
+ `.connectionName`. Source: `docs/stores/lucid.mdx`.
235
+
236
+ ### MEDIUM leaving `autoSchema: false` with no upgrade discipline
237
+
238
+ Wrong:
239
+
240
+ ```ts
241
+ export default defineConfig({ store: 'lucid', stores: { lucid: stores.lucid() }, autoSchema: false })
242
+ // …package upgraded twice, migrations never re-run
243
+ ```
244
+
245
+ Correct:
246
+
247
+ ```bash
248
+ node ace migration:run # after every @adonis-agora/durable upgrade
249
+ ```
250
+
251
+ Mechanism: with boot provisioning off, nothing repairs the schema; when the boot
252
+ path DOES repair (autoSchema on), it logs a one-time "repaired the durable
253
+ schema in place" warning meaning exactly this — the database was behind and the
254
+ fix was never recorded in migration history. Source: `docs/stores/lucid.mdx`
255
+ ("The repair warning").
256
+
257
+ ### MEDIUM using the deprecated `group` option to isolate worker pools
258
+
259
+ Wrong:
260
+
261
+ ```ts
262
+ transports.db({ group: 'tenant-a' }) // silently accepted and ignored
263
+ ```
264
+
265
+ Correct:
266
+
267
+ ```ts
268
+ transports.db({ partition: 'tenant-a' })
269
+ ```
270
+
271
+ Mechanism: routing is by handler name — an instance serves whatever names it
272
+ registers, so there is no group to declare. Isolation comes from `partition`
273
+ (suffixes routing tokens `<name>@<partition>`) plus `namespace` (scopes which
274
+ runs this engine polls). Source: `docs/transports/db.mdx` options table,
275
+ `docs/concepts/tenancy.mdx`.
276
+
277
+ ### MEDIUM wrapping a store in CodecStateStore and expecting heartbeats
278
+
279
+ Wrong:
280
+
281
+ ```ts
282
+ stores: { encrypted: async () => new CodecStateStore(new LucidStateStore(db), aesCodec) }
283
+ // then relying on persisted heartbeat progress for long remote steps
284
+ ```
285
+
286
+ Correct: accept the documented trade-off explicitly — the decorator does not
287
+ implement the optional `recordStepHeartbeat`, so persisted liveness progress is
288
+ traded for encryption at rest (the live `step.started` event still fires).
289
+
290
+ Mechanism: the optional member list defines what decorators can silently drop;
291
+ heartbeat persistence is one of them. Source: `docs/stores/index.mdx`
292
+ (optional members table + warning).
293
+
294
+ See also: `durable-reliability` — retries/timeouts interact with transport
295
+ claim semantics; the stuck-run playbook.
296
+ See also: `durable-cluster` — namespace/partition across topologies and tenants.
@@ -0,0 +1,399 @@
1
+ ---
2
+ name: durable-workflows
3
+ description: >-
4
+ Author durable workflows and steps with @adonis-agora/durable: BaseWorkflow
5
+ classes under app/workflows (static workflow, constructor @inject DI,
6
+ dispatch/start statics, make:workflow), step handlers via @Step decorators,
7
+ defineStep, string names or transport.handle, ctx.localStep vs always-
8
+ dispatched ctx.step, durable sleeps (ctx.sleep/sleepUntil), signals
9
+ (waitForSignal/signal/signalWithStart), named events (waitForEvent/
10
+ publishEvent/eventBatch/onEvent/idempotent opts.id), webhooks (ctx.webhook),
11
+ external tasks (ctx.task/completeTask/failTask), transactional steps
12
+ (ctx.transaction), breakpoints, child workflows (child/startChild/all/
13
+ continueAsNew/GatherError), singleton workflows, entities (registerEntity/
14
+ callEntity), scheduling (ScheduledWorkflow/static schedule/@Scheduled),
15
+ tags and search attributes. Use when writing or composing workflow logic.
16
+ license: MIT
17
+ metadata:
18
+ type: core
19
+ library: "@adonis-agora/durable"
20
+ library_version: "0.25.0"
21
+ framework: adonisjs
22
+ sources:
23
+ - 'DavideCarvalho/adonis-durable:docs/authoring/app-workflows.mdx'
24
+ - 'DavideCarvalho/adonis-durable:docs/concepts/workflows-and-steps.mdx'
25
+ - 'DavideCarvalho/adonis-durable:docs/concepts/sleep-and-signals.mdx'
26
+ - 'DavideCarvalho/adonis-durable:docs/authoring/child-workflows.mdx'
27
+ - 'DavideCarvalho/adonis-durable:docs/authoring/webhooks.mdx'
28
+ - 'DavideCarvalho/adonis-durable:docs/authoring/entities.mdx'
29
+ - 'DavideCarvalho/adonis-durable:docs/authoring/scheduling.mdx'
30
+ ---
31
+
32
+ # Authoring workflows & steps
33
+
34
+ A workflow is a class under `app/workflows/` extending `BaseWorkflow` with a
35
+ `static workflow = { name, version }`. The provider scans the directory at boot
36
+ and registers every exported class — you never call `engine.register` by hand
37
+ (it remains the low-level escape hatch). The convention mirrors `@adonisjs/queue`
38
+ jobs under `app/jobs`; scaffold with `node ace make:workflow order`.
39
+
40
+ ## Setup
41
+
42
+ ```ts title="app/workflows/checkout_workflow.ts"
43
+ import { inject } from '@adonisjs/core'
44
+ import { BaseWorkflow } from '@adonis-agora/durable'
45
+ import type { WorkflowCtx } from '@adonis-agora/durable'
46
+
47
+ export default class CheckoutWorkflow extends BaseWorkflow {
48
+ static workflow = { name: 'checkout', version: '1' }
49
+
50
+ async run(ctx: WorkflowCtx, order: { id: number; total: number }) {
51
+ await ctx.localStep('reserveStock', async () => ({ reserved: true }))
52
+ const charge = await ctx.step('payments:charge-card', {
53
+ orderId: order.id,
54
+ amountCents: order.total,
55
+ })
56
+ const approval = await ctx.waitForSignal<{ approved: boolean }>(`approve:${order.id}`)
57
+ if (!approval.approved) return { status: 'rejected', chargeId: charge.chargeId }
58
+ await ctx.localStep('ship', async () => ({ shipped: true }))
59
+ return { status: 'shipped', chargeId: charge.chargeId }
60
+ }
61
+ }
62
+ ```
63
+
64
+ Run it from anywhere: `CheckoutWorkflow.dispatch(input, { runId })` enqueues;
65
+ `CheckoutWorkflow.start(input)` awaits the settled result. Both statics are
66
+ context-aware — inside a running body they become a linked child /
67
+ fire-and-forget child instead of a top-level run.
68
+
69
+ ## Core patterns
70
+
71
+ ### Pattern 1 — typed steps with `@Step`, `defineStep`, and DI
72
+
73
+ Steps are discovered from `app/steps` and served by name automatically. A
74
+ `@Step` method ref passed to `ctx.step` gives full input/output typing:
75
+
76
+ ```ts title="app/steps/payment_steps.ts"
77
+ import { Step } from '@adonis-agora/durable'
78
+ import { z } from 'zod'
79
+
80
+ export default class PaymentSteps {
81
+ @Step({
82
+ name: 'payments:charge-card',
83
+ input: z.object({ orderId: z.number().int(), amountCents: z.number().int() }),
84
+ output: z.object({ chargeId: z.string() }),
85
+ retries: 3,
86
+ })
87
+ async chargeCard(input: { orderId: number; amountCents: number }) {
88
+ return { chargeId: await stripe.charge(input) }
89
+ }
90
+ }
91
+ ```
92
+
93
+ ```ts title="app/workflows/order_workflow.ts"
94
+ import { inject } from '@adonisjs/core'
95
+ import { BaseWorkflow } from '@adonis-agora/durable'
96
+ import type { WorkflowCtx } from '@adonis-agora/durable'
97
+ import PaymentSteps from '#steps/payment_steps'
98
+
99
+ @inject()
100
+ export default class OrderWorkflow extends BaseWorkflow {
101
+ static workflow = { name: 'order', version: '1' }
102
+
103
+ constructor(private payments: PaymentSteps) {
104
+ super()
105
+ }
106
+
107
+ async run(ctx: WorkflowCtx, order: Order) {
108
+ // Typed both ways — renaming chargeCard is a compile error here:
109
+ const charge = await ctx.step(this.payments.chargeCard, {
110
+ orderId: order.id,
111
+ amountCents: order.total,
112
+ })
113
+ return { chargeId: charge.chargeId }
114
+ }
115
+ }
116
+ ```
117
+
118
+ The instance is built once at registration and reused for every run — treat the
119
+ constructor as wiring; keep per-run state in input and step outputs, never on
120
+ `this`. Injected services are for **composing**, not doing work: reach them
121
+ through step refs so the work is checkpointed.
122
+
123
+ ### Pattern 2 — pausing durably: sleeps, signals, events
124
+
125
+ All three suspend the run with zero compute and survive restarts.
126
+
127
+ ```ts
128
+ // Time-based wait — duration string or ms:
129
+ await ctx.sleep('7 days')
130
+ await ctx.sleepUntil(new Date('2026-12-31T00:00:00Z'))
131
+
132
+ // Human-in-the-loop / webhook callback — point-to-point by token:
133
+ const decision = await ctx.waitForSignal<{ approved: boolean }>(`approve:${order.id}`, {
134
+ timeoutMs: 24 * 60 * 60 * 1000, // throws SignalTimeoutError when the deadline passes
135
+ })
136
+
137
+ // Named pub/sub — one publish fans out to every matching waiter:
138
+ const settled = await ctx.waitForEvent<{ orderId: string }>('payment.settled', {
139
+ match: { orderId: order.id },
140
+ })
141
+ ```
142
+
143
+ Deliver from controllers: `engine.signal(token, payload)` resumes one waiter;
144
+ signals sent before the waiter registers are buffered, not lost;
145
+ `engine.signalWithStart(workflow, input, runId, { token, payload })` starts the
146
+ run if missing and delivers in one race-free call. Publish events with
147
+ `engine.publishEvent(name, payload, { id: event.id })` — the upstream event id
148
+ makes redelivery a no-op for `onEvent` starts. Register a workflow to start on
149
+ events with `{ onEvent: ['payment.settled'] }`; coalesce chatty sources with
150
+ `eventBatch: { mode: 'debounce', windowMs: 5_000 }` (last payload wins) or
151
+ `{ mode: 'batch', maxSize: 50, windowMs: 60_000 }` (receives `{ events: [...] }`).
152
+
153
+ ```ts title="app/controllers/approvals_controller.ts"
154
+ import { inject } from '@adonisjs/core'
155
+ import { WorkflowEngine } from '@adonis-agora/durable'
156
+
157
+ @inject()
158
+ export default class ApprovalsController {
159
+ constructor(private engine: WorkflowEngine) {}
160
+
161
+ async approve({ params, request, response }: HttpContext) {
162
+ await this.engine.signal(`approve:${params.id}`, request.body())
163
+ return response.noContent()
164
+ }
165
+ }
166
+ ```
167
+
168
+ ### Pattern 3 — children, fan-out, and singletons
169
+
170
+ ```ts
171
+ const shipment = await ctx.child(ShippingWorkflow, { orderId: order.id }) // parent suspends
172
+ await ctx.startChild('reindex-search', { postId: post.id }) // fire-and-forget
173
+ const results = await ctx.all(ProcessItemWorkflow, batch.items) // outputs in input order
174
+ if (input.n < 3) await ctx.continueAsNew({ n: input.n + 1 }) // clean history per generation
175
+ ```
176
+
177
+ `ctx.all` default mode is `waitAll`: every sibling finishes, then the call throws
178
+ a `GatherError` carrying `.failures`; `{ mode: 'failFast' }` cancels remaining
179
+ siblings (plain cancel — no saga). Child ids derive from the call position and
180
+ are replay-stable; never pass a random one.
181
+
182
+ Serialize runs per subject with a singleton — runs sharing a key queue FIFO and
183
+ are race-free across instances:
184
+
185
+ ```ts
186
+ export default class SyncShopWorkflow extends BaseWorkflow {
187
+ static workflow = {
188
+ name: 'sync-shop',
189
+ version: '1',
190
+ singleton: { key: (input) => (input as { shopId: string }).shopId, limit: 1, maxQueueDepth: 20 },
191
+ }
192
+ // …
193
+ }
194
+ ```
195
+
196
+ Overflow beyond `limit + maxQueueDepth` rejects `dispatch` with
197
+ `SingletonQueueFullError` before anything is created.
198
+
199
+ ### Pattern 4 — long-lived state, callbacks, cadences
200
+
201
+ Entities serialize operations per key without DB locks (register from a
202
+ preload):
203
+
204
+ ```ts title="start/entities.ts"
205
+ import engine from '@adonis-agora/durable/services/main'
206
+
207
+ engine.registerEntity<{ balanceCents: number }>('account', {
208
+ initialState: () => ({ balanceCents: 0 }),
209
+ handlers: {
210
+ deposit: (state, amountCents: number) => {
211
+ state.balanceCents += amountCents
212
+ return state.balanceCents
213
+ },
214
+ balance: (state) => state.balanceCents,
215
+ },
216
+ })
217
+ // drive: engine.signalEntity('account', key, 'deposit', 500)
218
+ // read: engine.getEntityState('account', key)
219
+ // from a workflow: await ctx.callEntity('account', key, 'deposit', 500)
220
+ ```
221
+
222
+ Wait on a foreign system's own callback by id (`engine.completeTask(runId, name,
223
+ result)` / `failTask` resume the run):
224
+
225
+ ```ts
226
+ const result = await ctx.task<TranscodeResult>(job.id, async () => {
227
+ await sqs.send({ queueUrl, body: JSON.stringify({ jobId: job.id, src: job.src }) })
228
+ })
229
+ ```
230
+
231
+ Recurring cadences are colocated on the class (or set in `config/durable.ts`
232
+ `schedules`, which overrides by `key`); cron needs `cron-parser` installed where
233
+ the worker runs, and only the `durable:work` loop fires schedules:
234
+
235
+ ```ts
236
+ export default class DailyReportWorkflow extends BaseWorkflow {
237
+ static workflow = { name: 'daily-report', version: '1' }
238
+ static schedule = { cron: '0 2 * * *', timezone: 'America/Sao_Paulo' }
239
+ // or: @Scheduled({ everyMs: 60_000 }) above the class
240
+ }
241
+ ```
242
+
243
+ Each schedule window starts exactly once across a fleet via an idempotent
244
+ time-bucket run id.
245
+
246
+ Source: `docs/authoring/entities.mdx`, `docs/authoring/external-tasks.mdx`,
247
+ `docs/authoring/scheduling.mdx`.
248
+
249
+ ## Common mistakes
250
+
251
+ ### HIGH calling an injected service directly inside `run`
252
+
253
+ Wrong:
254
+
255
+ ```ts
256
+ async run(ctx, input) {
257
+ const receipt = await this.billing.charge(input) // I/O outside a step
258
+ return { receipt }
259
+ }
260
+ ```
261
+
262
+ Correct:
263
+
264
+ ```ts
265
+ async run(ctx, input) {
266
+ const receipt = await ctx.step(this.billing.charge, input)
267
+ return { receipt }
268
+ }
269
+ ```
270
+
271
+ Mechanism: work done outside `ctx.step`/`ctx.localStep` is never checkpointed,
272
+ so it re-executes on every replay and its result is not durable. The docs warn
273
+ explicitly that injected services exist "for composing the workflow, not for
274
+ doing its work". Source: `docs/authoring/app-workflows.mdx`.
275
+
276
+ ### HIGH swallowing control-flow signals in a hand-rolled try/catch
277
+
278
+ Wrong:
279
+
280
+ ```ts
281
+ try {
282
+ await ctx.step('chargeCard', input)
283
+ } catch (error) {
284
+ await ctx.step('refund', input) // also catches suspend/continue-as-new signals!
285
+ throw error
286
+ }
287
+ ```
288
+
289
+ Correct:
290
+
291
+ ```ts
292
+ import { isWorkflowControlFlowSignal } from '@adonis-agora/durable'
293
+
294
+ try {
295
+ await ctx.step('chargeCard', input)
296
+ } catch (error) {
297
+ if (isWorkflowControlFlowSignal(error)) throw error
298
+ await ctx.step('refund', input)
299
+ throw error
300
+ }
301
+ ```
302
+
303
+ Mechanism: `ctx.sleep`, `ctx.waitForSignal` and `ctx.continueAsNew` unwind the
304
+ turn by throwing; running your failure path on one records extra history a later
305
+ replay never produces → `NonDeterminismError` on resume. The predicate checks a
306
+ stamped marker, deliberately `false` for cancellations and real failures.
307
+ Source: `docs/concepts/workflows-and-steps.mdx` ("Catching errors").
308
+
309
+ ### HIGH passing a random child id inside a workflow
310
+
311
+ Wrong:
312
+
313
+ ```ts
314
+ await ctx.startChild('reindex-search', item, crypto.randomUUID())
315
+ ```
316
+
317
+ Correct:
318
+
319
+ ```ts
320
+ await ctx.startChild('reindex-search', item) // deterministic positional id
321
+ ```
322
+
323
+ Mechanism: inside a body, omitted child ids are derived deterministically from
324
+ the call position so replay re-attaches to the same child; a fresh random id on
325
+ each replay breaks that dedup and re-fires the child. Source:
326
+ `docs/authoring/child-workflows.mdx` ("opts.runId — outside vs inside").
327
+
328
+ ### MEDIUM blocking a hot HTTP path on `.start`
329
+
330
+ Wrong:
331
+
332
+ ```ts
333
+ const result = await CheckoutWorkflow.start(order) // request held open until settle
334
+ ```
335
+
336
+ Correct:
337
+
338
+ ```ts
339
+ const { runId } = await CheckoutWorkflow.dispatch(order, { runId: `checkout:${order.id}` })
340
+ return response.accepted({ runId })
341
+ ```
342
+
343
+ Mechanism: `.start` resolves only when the run reaches a terminal state or
344
+ `suspends` — hours later for a long workflow. Source: `docs/getting-started.mdx`
345
+ (warning callout).
346
+
347
+ ### MEDIUM awaiting a webhook with no timeout
348
+
349
+ Wrong:
350
+
351
+ ```ts
352
+ const result = await hook.wait() // suspends indefinitely if the provider never calls back
353
+ ```
354
+
355
+ Correct:
356
+
357
+ ```ts
358
+ import { SignalTimeoutError } from '@adonis-agora/durable'
359
+
360
+ try {
361
+ const result = await hook.wait({ timeoutMs: 30 * 60 * 1000 })
362
+ } catch (error) {
363
+ if (!(error instanceof SignalTimeoutError)) throw error
364
+ // reconcile: poll the provider instead of hanging forever
365
+ }
366
+ ```
367
+
368
+ Mechanism: without `timeoutMs` there is no wake timer — a dropped callback
369
+ leaves the run parked invisibly to the timer poller and crash recovery.
370
+ Source: `docs/authoring/webhooks.mdx` ("Not waiting forever").
371
+
372
+ ### MEDIUM throwing business rejections out of entity handlers
373
+
374
+ Wrong:
375
+
376
+ ```ts
377
+ withdraw: (state, amountCents) => {
378
+ if (amountCents > state.balanceCents) throw new Error('insufficient funds')
379
+ state.balanceCents -= amountCents
380
+ return state.balanceCents
381
+ },
382
+ ```
383
+
384
+ Correct:
385
+
386
+ ```ts
387
+ withdraw: (state, amountCents) =>
388
+ amountCents > state.balanceCents
389
+ ? { ok: false as const, reason: 'insufficient_funds' as const }
390
+ : ((state.balanceCents -= amountCents), { ok: true as const }),
391
+ ```
392
+
393
+ Mechanism: a throwing handler fails the entity's immortal run, stranding every
394
+ caller suspended on `ctx.callEntity`; model expected rejections as returned
395
+ discriminated results and reserve throws for genuine programmer errors.
396
+ Source: `docs/authoring/entities.mdx` (warning callout).
397
+
398
+ See also: `durable-determinism` — why the body must stay deterministic and how
399
+ to change it safely once live.