@fullstackhouse/open-mercato-durable-work 0.1.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.
Files changed (105) hide show
  1. package/README.md +136 -0
  2. package/dist/core/errors.js +81 -0
  3. package/dist/core/errors.js.map +7 -0
  4. package/dist/core/ids.js +30 -0
  5. package/dist/core/ids.js.map +7 -0
  6. package/dist/core/reconciler.js +149 -0
  7. package/dist/core/reconciler.js.map +7 -0
  8. package/dist/core/registry.js +72 -0
  9. package/dist/core/registry.js.map +7 -0
  10. package/dist/core/run-slice.js +210 -0
  11. package/dist/core/run-slice.js.map +7 -0
  12. package/dist/core/schema.js +100 -0
  13. package/dist/core/schema.js.map +7 -0
  14. package/dist/core/service.js +161 -0
  15. package/dist/core/service.js.map +7 -0
  16. package/dist/core/store.js +516 -0
  17. package/dist/core/store.js.map +7 -0
  18. package/dist/core/terminal.js +53 -0
  19. package/dist/core/terminal.js.map +7 -0
  20. package/dist/core/types.js +1 -0
  21. package/dist/core/types.js.map +7 -0
  22. package/dist/core/worker.js +111 -0
  23. package/dist/core/worker.js.map +7 -0
  24. package/dist/index.js +99 -0
  25. package/dist/index.js.map +7 -0
  26. package/dist/modules/durable_work/acl.js +10 -0
  27. package/dist/modules/durable_work/acl.js.map +7 -0
  28. package/dist/modules/durable_work/api/jobs/[id]/redrive.js +20 -0
  29. package/dist/modules/durable_work/api/jobs/[id]/redrive.js.map +7 -0
  30. package/dist/modules/durable_work/api/jobs/[id]/route.js +26 -0
  31. package/dist/modules/durable_work/api/jobs/[id]/route.js.map +7 -0
  32. package/dist/modules/durable_work/api/jobs/route.js +24 -0
  33. package/dist/modules/durable_work/api/jobs/route.js.map +7 -0
  34. package/dist/modules/durable_work/cli.js +72 -0
  35. package/dist/modules/durable_work/cli.js.map +7 -0
  36. package/dist/modules/durable_work/data/entities.js +163 -0
  37. package/dist/modules/durable_work/data/entities.js.map +7 -0
  38. package/dist/modules/durable_work/di.js +34 -0
  39. package/dist/modules/durable_work/di.js.map +7 -0
  40. package/dist/modules/durable_work/events.js +26 -0
  41. package/dist/modules/durable_work/events.js.map +7 -0
  42. package/dist/modules/durable_work/index.js +17 -0
  43. package/dist/modules/durable_work/index.js.map +7 -0
  44. package/dist/modules/durable_work/lib/route-helpers.js +66 -0
  45. package/dist/modules/durable_work/lib/route-helpers.js.map +7 -0
  46. package/dist/modules/durable_work/migrations/Migration20260908120000.js +17 -0
  47. package/dist/modules/durable_work/migrations/Migration20260908120000.js.map +7 -0
  48. package/dist/modules/durable_work/setup.js +12 -0
  49. package/dist/modules/durable_work/setup.js.map +7 -0
  50. package/dist/om/config.js +49 -0
  51. package/dist/om/config.js.map +7 -0
  52. package/dist/om/progress-mirror.js +49 -0
  53. package/dist/om/progress-mirror.js.map +7 -0
  54. package/dist/om/sql-executor-mikro.js +48 -0
  55. package/dist/om/sql-executor-mikro.js.map +7 -0
  56. package/dist/transport/bullmq.js +144 -0
  57. package/dist/transport/bullmq.js.map +7 -0
  58. package/dist/transport/conformance.js +177 -0
  59. package/dist/transport/conformance.js.map +7 -0
  60. package/dist/transport/memory.js +139 -0
  61. package/dist/transport/memory.js.map +7 -0
  62. package/dist/transport/pgboss.js +176 -0
  63. package/dist/transport/pgboss.js.map +7 -0
  64. package/dist/transport/types.js +1 -0
  65. package/dist/transport/types.js.map +7 -0
  66. package/generated/entities/durable_work_job/index.ts +42 -0
  67. package/generated/entities.ids.generated.ts +9 -0
  68. package/package.json +145 -0
  69. package/src/core/__tests__/registry.test.ts +43 -0
  70. package/src/core/errors.ts +104 -0
  71. package/src/core/ids.ts +58 -0
  72. package/src/core/reconciler.ts +242 -0
  73. package/src/core/registry.ts +199 -0
  74. package/src/core/run-slice.ts +343 -0
  75. package/src/core/schema.ts +114 -0
  76. package/src/core/service.ts +222 -0
  77. package/src/core/store.ts +786 -0
  78. package/src/core/terminal.ts +107 -0
  79. package/src/core/types.ts +120 -0
  80. package/src/core/worker.ts +169 -0
  81. package/src/index.ts +100 -0
  82. package/src/modules/durable_work/__integration__/TC-DW-001.spec.ts +51 -0
  83. package/src/modules/durable_work/__tests__/metadata.test.ts +13 -0
  84. package/src/modules/durable_work/__tests__/schema-agreement.test.ts +52 -0
  85. package/src/modules/durable_work/acl.ts +6 -0
  86. package/src/modules/durable_work/api/jobs/[id]/redrive.ts +27 -0
  87. package/src/modules/durable_work/api/jobs/[id]/route.ts +27 -0
  88. package/src/modules/durable_work/api/jobs/route.ts +26 -0
  89. package/src/modules/durable_work/cli.ts +91 -0
  90. package/src/modules/durable_work/data/entities.ts +158 -0
  91. package/src/modules/durable_work/di.ts +41 -0
  92. package/src/modules/durable_work/events.ts +30 -0
  93. package/src/modules/durable_work/index.ts +16 -0
  94. package/src/modules/durable_work/lib/route-helpers.ts +83 -0
  95. package/src/modules/durable_work/migrations/Migration20260908120000.ts +24 -0
  96. package/src/modules/durable_work/setup.ts +10 -0
  97. package/src/om/__tests__/sql-executor-mikro.test.ts +83 -0
  98. package/src/om/config.ts +65 -0
  99. package/src/om/progress-mirror.ts +80 -0
  100. package/src/om/sql-executor-mikro.ts +104 -0
  101. package/src/transport/bullmq.ts +213 -0
  102. package/src/transport/conformance.ts +218 -0
  103. package/src/transport/memory.ts +191 -0
  104. package/src/transport/pgboss.ts +250 -0
  105. package/src/transport/types.ts +81 -0
@@ -0,0 +1,242 @@
1
+ // The server-side repair loop: what makes "no job stays running forever" true.
2
+ //
3
+ // Three queries, in a fixed order, each selecting a bounded batch with `for update skip
4
+ // locked` in its own short transaction. The order is not cosmetic — see the note on Q3.
5
+ //
6
+ // Everything here is decided from the row, never from anything a process remembers. That is
7
+ // what lets any process run the reconciler, lets two run at once, and lets the whole fleet
8
+ // restart without losing track of a single job.
9
+
10
+ import { errorMessageOf } from './errors'
11
+ import type { KindRegistry, ResolvedKind } from './registry'
12
+ import { runAfterTransition, runTerminalTransition } from './terminal'
13
+ import {
14
+ cancelPending,
15
+ markMirrored,
16
+ park,
17
+ redrivePending,
18
+ selectCancelling,
19
+ selectOrphans,
20
+ selectStalePending,
21
+ takeOrphan,
22
+ } from './store'
23
+ import type { DurableJob, Lease, Scope, SqlTransactor } from './types'
24
+
25
+ export type ReconcileReport = {
26
+ scanned: number
27
+ cancelled: number
28
+ redriven: number
29
+ parked: number
30
+ errors: number
31
+ }
32
+
33
+ export type ReconcilerDeps = {
34
+ sql: SqlTransactor
35
+ registry: KindRegistry
36
+ /** Enqueues a delivery for a job that has just been re-driven. */
37
+ enqueue: (job: DurableJob) => Promise<void>
38
+ /** How long past its expiry a lease is tolerated before the job counts as orphaned. Short,
39
+ * because lease expiry is a database-clock fact about a driver. */
40
+ graceMs?: number
41
+ batchSize?: number
42
+ /** Repair only this tenant's jobs. Unset means every tenant, which is what a single worker
43
+ * should do; set it to shard the loop across a fleet. */
44
+ tenantId?: string
45
+ log?: (event: string, fields: Record<string, unknown>) => void
46
+ }
47
+
48
+ const DEFAULT_GRACE_MS = 20_000
49
+ const DEFAULT_BATCH = 100
50
+ /** Backoff between successive re-drives of the same job, so a job that keeps orphaning does
51
+ * not spin. Doubles per re-drive since the last committed unit, capped. */
52
+ const REDRIVE_BASE_MS = 15_000
53
+ const REDRIVE_CAP_MS = 600_000
54
+
55
+ const scopeOf = (job: DurableJob): Scope => ({ tenantId: job.tenantId, organizationId: job.organizationId })
56
+ const leaseOf = (job: DurableJob): Lease => ({ jobId: job.id, owner: job.leaseOwner ?? 'reconciler', epoch: job.leaseEpoch, ttlMs: 0 })
57
+
58
+ /**
59
+ * One pass. Safe to run concurrently with itself: every query takes its rows with
60
+ * `skip locked`, so two reconcilers partition the work rather than fighting over it.
61
+ */
62
+ export async function reconcileOnce(deps: ReconcilerDeps): Promise<ReconcileReport> {
63
+ const report: ReconcileReport = { scanned: 0, cancelled: 0, redriven: 0, parked: 0, errors: 0 }
64
+ const limit = deps.batchSize ?? DEFAULT_BATCH
65
+ const graceMs = deps.graceMs ?? DEFAULT_GRACE_MS
66
+
67
+ // Q3 — cancellations first.
68
+ //
69
+ // A running job whose driver died after an operator asked to cancel it matches both "dead
70
+ // cancel" and "orphan". Without a precedence, the orphan query would park it as orphaned
71
+ // with the cancellation never honoured, or re-drive it — restarting work somebody
72
+ // explicitly asked to stop. So cancellations are settled before anything else looks.
73
+ for (const job of await select(deps, (tx) => selectCancelling(tx, limit, deps.tenantId))) {
74
+ report.scanned += 1
75
+ try {
76
+ if (await endCancelled(deps, job)) report.cancelled += 1
77
+ } catch (error) {
78
+ report.errors += 1
79
+ deps.log?.('durable_work.reconcile_cancel_failed', { jobId: job.id, error: errorMessageOf(error) })
80
+ }
81
+ }
82
+
83
+ // Q1 — orphans: a job whose driver stopped heartbeating.
84
+ for (const job of await select(deps, (tx) => selectOrphans(tx, { graceMs, pendingTtlMs: widestPendingTtl(deps), limit, tenantId: deps.tenantId }))) {
85
+ report.scanned += 1
86
+ try {
87
+ const outcome = await repairOrphan(deps, job, graceMs)
88
+ if (outcome === 'redriven') report.redriven += 1
89
+ if (outcome === 'parked') report.parked += 1
90
+ } catch (error) {
91
+ report.errors += 1
92
+ deps.log?.('durable_work.reconcile_orphan_failed', { jobId: job.id, error: errorMessageOf(error) })
93
+ }
94
+ }
95
+
96
+ // Q2 — pending jobs whose delivery never arrived: a lost hand-back, or an enqueue that
97
+ // never reached the broker because the process died between commit and enqueue.
98
+ for (const job of await select(deps, (tx) => selectStalePending(tx, { pendingTtlMs: widestPendingTtl(deps), limit, tenantId: deps.tenantId }))) {
99
+ report.scanned += 1
100
+ try {
101
+ const outcome = await repairPending(deps, job)
102
+ if (outcome === 'redriven') report.redriven += 1
103
+ if (outcome === 'parked') report.parked += 1
104
+ } catch (error) {
105
+ report.errors += 1
106
+ deps.log?.('durable_work.reconcile_pending_failed', { jobId: job.id, error: errorMessageOf(error) })
107
+ }
108
+ }
109
+
110
+ return report
111
+ }
112
+
113
+ /** Selection commits — and so releases its locks — before any per-row work runs. Holding a
114
+ * row lock across a domain mirror would block a second reconciler for the length of that
115
+ * mirror, and turn a slow domain into a stalled repair loop. */
116
+ async function select(deps: ReconcilerDeps, query: (tx: Parameters<Parameters<SqlTransactor['transaction']>[0]>[0]) => Promise<DurableJob[]>): Promise<DurableJob[]> {
117
+ return deps.sql.transaction(query)
118
+ }
119
+
120
+ /** Selection is a coarse filter, so it uses the widest tolerance any registered kind declares
121
+ * and lets the per-row statements re-check with that row's own kind. Selecting on the
122
+ * narrowest instead would silently exclude jobs of a more tolerant kind from being repaired
123
+ * at all; over-selecting only costs a re-check. */
124
+ function widestPendingTtl(deps: ReconcilerDeps): number {
125
+ const kinds = deps.registry.list()
126
+ return kinds.length ? Math.max(...kinds.map((k) => k.lease.pendingTtlMs)) : 900_000
127
+ }
128
+
129
+ async function endCancelled(deps: ReconcilerDeps, job: DurableJob): Promise<boolean> {
130
+ const kind = deps.registry.get(job.kind)
131
+
132
+ if (job.status === 'pending') {
133
+ // No lease to fence on; `pending` is the fence.
134
+ const ended = await deps.sql.transaction(async (tx) => {
135
+ const row = await cancelPending(tx, job.id)
136
+ if (!row) return null
137
+ if (kind?.onCancel) await kind.onCancel(row, scopeOf(row), tx)
138
+ if (kind?.onTransition) {
139
+ const { matched } = await kind.onTransition(row, scopeOf(row), tx)
140
+ if (matched < 1) throw new Error(`Domain mirror matched no rows for job ${row.id}`)
141
+ }
142
+ await markMirrored(tx, row.id)
143
+ return row
144
+ })
145
+ if (!ended) return false
146
+ if (kind) await runAfterTransition(kind, ended, scopeOf(ended))
147
+ deps.log?.('durable_work.job_cancelled', { jobId: job.id, by: 'reconciler' })
148
+ return true
149
+ }
150
+
151
+ // A `running` job whose lease has expired: nobody is driving it, so the reconciler settles
152
+ // the cancellation on its behalf. A live lease is left alone — its own slice will observe
153
+ // the request at the next heartbeat, which is both faster and safer.
154
+ if (job.leaseExpiresAt && job.leaseExpiresAt.getTime() > Date.now()) return false
155
+
156
+ if (!kind) {
157
+ const parked = await park(deps.sql, job.id, 'no_handler', 'No handler registered for this kind')
158
+ return parked != null
159
+ }
160
+ const result = await runTerminalTransition(deps.sql, kind, leaseOf(job), scopeOf(job), { type: 'cancel' })
161
+ if (!result) return false
162
+ await runAfterTransition(kind, result.job, scopeOf(result.job))
163
+ deps.log?.('durable_work.job_cancelled', { jobId: job.id, by: 'reconciler' })
164
+ return true
165
+ }
166
+
167
+ async function repairOrphan(deps: ReconcilerDeps, job: DurableJob, graceMs: number): Promise<'redriven' | 'parked' | 'skipped'> {
168
+ const kind = deps.registry.get(job.kind)
169
+
170
+ // In order; the first match wins.
171
+ if (!kind) return (await parkJob(deps, job, undefined, 'no_handler', 'No handler registered for this kind')) ? 'parked' : 'skipped'
172
+ if (job.errorCode === 'unrecoverable' || job.errorCode === 'retry_exhausted') {
173
+ // The slice already reached a conclusion but could not commit it. Park with that verdict
174
+ // preserved — the orphan policy is never consulted for a job that has already decided.
175
+ return (await parkJob(deps, job, kind, job.errorCode, job.errorMessage)) ? 'parked' : 'skipped'
176
+ }
177
+ if (kind.orphanPolicy !== 'redrive') {
178
+ return (await parkJob(deps, job, kind, 'orphaned', job.errorMessage ?? 'Worker stopped without releasing the lease')) ? 'parked' : 'skipped'
179
+ }
180
+ if (job.redrivesSinceCommit >= kind.budget.poisonRedrivesWithoutCommit) {
181
+ // Re-driven this many times without committing anything: the job is not making progress
182
+ // and re-running it again is guessing. A human decides from here.
183
+ return (await parkJob(deps, job, kind, 'poison', 'Re-driven repeatedly without committing progress')) ? 'parked' : 'skipped'
184
+ }
185
+
186
+ const backoffMs = Math.min(REDRIVE_BASE_MS * 2 ** job.redrivesSinceCommit, REDRIVE_CAP_MS)
187
+ const taken = await takeOrphan(deps.sql, job.id, { graceMs, pendingTtlMs: kind.lease.pendingTtlMs, backoffMs })
188
+ if (!taken) return 'skipped' // the row moved under us; another pass will see it
189
+ await deps.enqueue(taken)
190
+ deps.log?.('durable_work.job_orphaned', { jobId: job.id, redrives: taken.redrives, backoffMs })
191
+ return 'redriven'
192
+ }
193
+
194
+ async function repairPending(deps: ReconcilerDeps, job: DurableJob): Promise<'redriven' | 'parked' | 'skipped'> {
195
+ const kind = deps.registry.get(job.kind)
196
+ if (!kind) return (await parkJob(deps, job, undefined, 'no_handler', 'No handler registered for this kind')) ? 'parked' : 'skipped'
197
+
198
+ // A lost hand-back is cheap and is not evidence that the work is bad, so this budget is
199
+ // deliberately wider than the poison budget the orphan path uses.
200
+ if (job.redrivesSinceCommit >= kind.budget.maxRedrives) {
201
+ return (await parkJob(deps, job, kind, 'never_started', 'Delivery never arrived after repeated re-drives')) ? 'parked' : 'skipped'
202
+ }
203
+
204
+ const redriven = await redrivePending(deps.sql, job.id, { pendingTtlMs: kind.lease.pendingTtlMs })
205
+ if (!redriven) return 'skipped'
206
+ await deps.enqueue(redriven)
207
+ deps.log?.('durable_work.job_redriven', { jobId: job.id, redrives: redriven.redrives, reason: 'never_started' })
208
+ return 'redriven'
209
+ }
210
+
211
+ /** Parks through the terminal protocol when the kind has a mirror to run, and through the
212
+ * plain statement when it does not — an unregistered kind has no domain row to agree with,
213
+ * so "no mirror" is a satisfied mirror rather than a pending one. */
214
+ async function parkJob(
215
+ deps: ReconcilerDeps,
216
+ job: DurableJob,
217
+ kind: ResolvedKind | undefined,
218
+ reason: string,
219
+ message: string | null,
220
+ ): Promise<boolean> {
221
+ if (!kind?.onTransition) {
222
+ const parked = await park(deps.sql, job.id, reason as never, message)
223
+ if (parked) {
224
+ await deps.sql.query(`update durable_work_jobs set domain_mirrored_at = now() where id = $1`, [job.id])
225
+ deps.log?.('durable_work.job_parked', { jobId: job.id, reason })
226
+ }
227
+ return parked != null
228
+ }
229
+
230
+ const parked = await deps.sql.transaction(async (tx) => {
231
+ const row = await park(tx, job.id, reason as never, message)
232
+ if (!row) return null
233
+ const { matched } = await kind.onTransition!(row, scopeOf(row), tx)
234
+ if (matched < 1) throw new Error(`Domain mirror matched no rows for job ${row.id}`)
235
+ await markMirrored(tx, row.id)
236
+ return row
237
+ })
238
+ if (!parked) return false
239
+ await runAfterTransition(kind, parked, scopeOf(parked))
240
+ deps.log?.('durable_work.job_parked', { jobId: job.id, reason })
241
+ return true
242
+ }
@@ -0,0 +1,199 @@
1
+ // What a caller declares when they hand work to the mechanism, and the process-wide map of
2
+ // those declarations.
3
+ //
4
+ // The registry is process-wide rather than per-container because a job's handler has to be
5
+ // resolvable in every process that can act on the job: the worker runs its slices, the web
6
+ // process re-drives and cancels it, the reconciler parks it. A registration attached to a
7
+ // request-scoped container would be invisible to the next request.
8
+
9
+ import { UnknownKindError } from './errors'
10
+ import type {
11
+ BudgetSettings,
12
+ DurableJob,
13
+ ErrorClass,
14
+ Lease,
15
+ LeaseSettings,
16
+ RetrySettings,
17
+ Scope,
18
+ SliceOutcome,
19
+ SqlExecutor,
20
+ } from './types'
21
+
22
+ /** What a slice is given. Everything it needs to make progress and to stop safely. */
23
+ export interface SliceContext<TInput = unknown, TCheckpoint = unknown> {
24
+ job: DurableJob
25
+ scope: Scope
26
+ lease: Lease
27
+ input: TInput
28
+ checkpoint: TCheckpoint | null
29
+
30
+ /** Aborts on shutdown, on cancellation, and when the lease is lost. A slice that checks it
31
+ * at batch boundaries is the difference between a clean stop and a killed process. */
32
+ signal: AbortSignal
33
+
34
+ /** Milliseconds this slice may run before it should hand back. */
35
+ budgetMs: number
36
+
37
+ /** Stable per (job, slice). Forward it to any external side effect so a redelivered slice
38
+ * is recognised as the same request rather than a second one. */
39
+ idempotencyKey: string
40
+
41
+ /** Extends the lease and reports progress. `committed: true` records that a unit of work
42
+ * is durably written, which resets the failure and orphan budgets. Throws `LeaseLostError`
43
+ * when the lease is gone. */
44
+ heartbeat(patch?: { processedCount?: number; totalCount?: number | null; committed?: boolean }): Promise<void>
45
+
46
+ /** Records resume state and counts as a committed unit, under the fence. */
47
+ checkpoint_(state: TCheckpoint, patch?: { processedCount?: number; totalCount?: number | null }): Promise<void>
48
+
49
+ /** Runs `fn` in a transaction that also re-asserts this lease. If the lease is gone the
50
+ * transaction rolls back and `LeaseLostError` is thrown — so a worker that lost its lease
51
+ * mid-write cannot land a write that outlives its right to make one. */
52
+ fencedWrite<T>(fn: (tx: SqlExecutor) => Promise<T>): Promise<T>
53
+
54
+ /** True once the slice budget is spent or a stop was requested. Check it at boundaries. */
55
+ shouldYield(): boolean
56
+ }
57
+
58
+ export interface KindDefinition<TInput = unknown, TCheckpoint = unknown> {
59
+ kind: string
60
+ /** Which queue carries this kind. Kinds sharing a queue share a worker's concurrency. */
61
+ queue: string
62
+ /** ACL features an operator needs to re-drive this kind, beyond `durable_work.operate`. */
63
+ requiredFeatures?: string[]
64
+ concurrency?: number
65
+
66
+ lease?: Partial<LeaseSettings>
67
+ budget?: Partial<BudgetSettings>
68
+ retry?: { attempts?: number; backoff?: Partial<RetrySettings['backoff']> }
69
+
70
+ /** What the reconciler does with an orphan. Defaults to `park`: a job nobody declared
71
+ * idempotent is not re-run automatically just because its worker died. */
72
+ orphanPolicy?: 'redrive' | 'park'
73
+
74
+ /** One slice under a held lease. Return when the budget is spent or the signal aborts. */
75
+ step(ctx: SliceContext<TInput, TCheckpoint>): Promise<SliceOutcome>
76
+
77
+ /**
78
+ * Mirrors a terminal transition onto the domain row, inside the terminal transaction.
79
+ *
80
+ * Must be idempotent and must return how many domain rows its update matched: `matched: 0`
81
+ * is treated exactly like a throw, because "mirrored" means "the domain row agrees", not
82
+ * "the callback ran". No events, no enqueues — those belong in `onAfterTransition`, which
83
+ * runs after the commit.
84
+ */
85
+ onTransition?(job: DurableJob, scope: Scope, tx: SqlExecutor): Promise<{ matched: number }>
86
+
87
+ /**
88
+ * Re-opens the domain row when an operator re-drives. The mirror image of `onTransition`,
89
+ * in the same transaction, with the same contract.
90
+ *
91
+ * Required whenever `onTransition` is declared — a mirror with no way back would leave an
92
+ * operator able to re-drive the job while the domain row stays terminal.
93
+ */
94
+ onRedrive?(job: DurableJob, scope: Scope, tx: SqlExecutor): Promise<{ matched: number }>
95
+
96
+ /** Release external resources before a cancellation commits. Same transaction, same
97
+ * idempotency rule as `onTransition`. */
98
+ onCancel?(job: DurableJob, scope: Scope, tx: SqlExecutor): Promise<void>
99
+
100
+ /** After-commit hooks. Best-effort and at-most-once: a throw is logged, never retried, and
101
+ * never affects the committed row. Domain events and log writes belong here. */
102
+ onAfterTransition?(job: DurableJob, scope: Scope): Promise<void>
103
+ onAfterRedrive?(job: DurableJob, scope: Scope): Promise<void>
104
+
105
+ /** Override the default classification for errors this kind understands. */
106
+ classify?(error: unknown): ErrorClass | null
107
+ }
108
+
109
+ export const DEFAULT_LEASE: LeaseSettings = { ttlMs: 60_000, sliceBudgetMs: 300_000, pendingTtlMs: 900_000 }
110
+ export const DEFAULT_BUDGET: BudgetSettings = { maxRedrives: 10, maxConsecutiveFailures: 5, poisonRedrivesWithoutCommit: 3 }
111
+ export const DEFAULT_RETRY: RetrySettings = { attempts: 5, backoff: { type: 'exponential', delayMs: 5_000, maxDelayMs: 300_000 } }
112
+
113
+ export type ResolvedKind<TInput = unknown, TCheckpoint = unknown> = KindDefinition<TInput, TCheckpoint> & {
114
+ lease: LeaseSettings
115
+ budget: BudgetSettings
116
+ retry: RetrySettings
117
+ orphanPolicy: 'redrive' | 'park'
118
+ concurrency: number
119
+ }
120
+
121
+ export function resolveKind<TInput, TCheckpoint>(definition: KindDefinition<TInput, TCheckpoint>): ResolvedKind<TInput, TCheckpoint> {
122
+ if (definition.onTransition && !definition.onRedrive) {
123
+ throw new Error(
124
+ `Kind ${JSON.stringify(definition.kind)} declares onTransition without onRedrive: a domain mirror with no way back would leave an operator able to re-drive the job while the domain row stays terminal.`,
125
+ )
126
+ }
127
+ return {
128
+ ...definition,
129
+ lease: { ...DEFAULT_LEASE, ...definition.lease },
130
+ budget: { ...DEFAULT_BUDGET, ...definition.budget },
131
+ retry: {
132
+ attempts: definition.retry?.attempts ?? DEFAULT_RETRY.attempts,
133
+ backoff: { ...DEFAULT_RETRY.backoff, ...definition.retry?.backoff },
134
+ },
135
+ orphanPolicy: definition.orphanPolicy ?? 'park',
136
+ concurrency: definition.concurrency ?? 1,
137
+ }
138
+ }
139
+
140
+ /** The delay before the transport's next attempt, or null when none is coming. */
141
+ export function nextAttemptDelayMs(kind: ResolvedKind, attempt: number): number | null {
142
+ if (attempt >= kind.retry.attempts) return null
143
+ const { type, delayMs, maxDelayMs } = kind.retry.backoff
144
+ const raw = type === 'fixed' ? delayMs : delayMs * 2 ** Math.max(0, attempt - 1)
145
+ return Math.min(raw, maxDelayMs)
146
+ }
147
+
148
+ export class KindRegistry {
149
+ private readonly kinds = new Map<string, ResolvedKind<never, never>>()
150
+
151
+ register<TInput, TCheckpoint>(definition: KindDefinition<TInput, TCheckpoint>): void {
152
+ const resolved = resolveKind(definition)
153
+ const existing = this.kinds.get(definition.kind)
154
+ // Re-registering the identical definition is a no-op so a module loaded twice (two entry
155
+ // points, a test re-import) is not a crash; a *different* definition under the same id is
156
+ // a genuine conflict and must not be resolved silently by last-write-wins.
157
+ if (existing && existing.step !== definition.step) {
158
+ throw new Error(`Duplicate durable job kind ${JSON.stringify(definition.kind)}: two different handlers registered under one id.`)
159
+ }
160
+ this.kinds.set(definition.kind, resolved as unknown as ResolvedKind<never, never>)
161
+ }
162
+
163
+ get(kind: string): ResolvedKind | undefined {
164
+ return this.kinds.get(kind) as ResolvedKind | undefined
165
+ }
166
+
167
+ require(kind: string): ResolvedKind {
168
+ const found = this.get(kind)
169
+ if (!found) throw new UnknownKindError(kind)
170
+ return found
171
+ }
172
+
173
+ has(kind: string): boolean {
174
+ return this.kinds.has(kind)
175
+ }
176
+
177
+ list(): ResolvedKind[] {
178
+ return [...this.kinds.values()] as ResolvedKind[]
179
+ }
180
+
181
+ queues(): string[] {
182
+ return [...new Set(this.list().map((k) => k.queue))]
183
+ }
184
+
185
+ clear(): void {
186
+ this.kinds.clear()
187
+ }
188
+ }
189
+
190
+ /**
191
+ * The process-wide registry. Modules register into this at import time.
192
+ *
193
+ * Module-scoped, so there is exactly one per copy of this package in the process — which is why
194
+ * anything registering kinds must depend on this package as a PEER, never as a dependency. A
195
+ * nested second copy would give the adopter its own registry: kinds would register into one,
196
+ * the worker would read the other, and nothing would run. No error, no warning, just jobs that
197
+ * sit pending forever while the reconciler eventually parks them `no_handler`.
198
+ */
199
+ export const registry = new KindRegistry()