@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,191 @@
1
+ // An in-process transport, for unit tests and fault injection.
2
+ //
3
+ // Not a toy: it implements the same delivery semantics as the real adapters — attempts,
4
+ // backoff, delayed availability, hand-back that spends no attempt, a real abort signal on
5
+ // close — and it runs the same conformance suite. Its purpose is to make failures *reachable*
6
+ // (duplicate a delivery, stall one, drop one) that a real broker only produces by accident.
7
+ //
8
+ // Explicitly not for production: nothing survives the process.
9
+
10
+ import { deliveryId } from '../core/ids'
11
+ import type { Delivery, RetrySettings } from '../core/types'
12
+ import type {
13
+ BindOptions,
14
+ BoundWorker,
15
+ DeliveryHandler,
16
+ DeliveryState,
17
+ EnqueueOptions,
18
+ TransportAdapter,
19
+ } from './types'
20
+
21
+ type Entry = {
22
+ id: string
23
+ queue: string
24
+ delivery: Delivery
25
+ availableAt: number
26
+ attempt: number
27
+ retry: RetrySettings
28
+ state: DeliveryState
29
+ }
30
+
31
+ function backoffFor(retry: RetrySettings, attempt: number): number {
32
+ const { type, delayMs, maxDelayMs } = retry.backoff
33
+ const raw = type === 'fixed' ? delayMs : delayMs * 2 ** Math.max(0, attempt - 1)
34
+ return Math.min(raw, maxDelayMs)
35
+ }
36
+
37
+ export type MemoryFaults = {
38
+ /** Deliver each accepted delivery twice. The lease must refuse the second. */
39
+ duplicateDeliveries?: boolean
40
+ /** Swallow enqueues matching this predicate, simulating a broker that lost the message.
41
+ * The reconciler is what must notice. */
42
+ dropEnqueue?: (delivery: Delivery) => boolean
43
+ /** Hold a delivery indefinitely instead of running it. */
44
+ stall?: (delivery: Delivery) => boolean
45
+ }
46
+
47
+ export class MemoryTransport implements TransportAdapter {
48
+ readonly name = 'memory' as const
49
+ readonly supportsTransactionalEnqueue = false
50
+
51
+ private readonly entries = new Map<string, Entry>()
52
+ private readonly handlers = new Map<string, { handler: DeliveryHandler; opts: BindOptions }>()
53
+ private readonly ticks = new Map<string, { queue: string; everyMs: number; timer: NodeJS.Timeout }>()
54
+ private readonly inFlight = new Set<Promise<void>>()
55
+ private readonly abort = new AbortController()
56
+ private pump: NodeJS.Timeout | null = null
57
+ private closed = false
58
+
59
+ constructor(private readonly faults: MemoryFaults = {}) {}
60
+
61
+ async enqueue(queue: string, delivery: Delivery, opts: EnqueueOptions): Promise<{ transportJobId: string }> {
62
+ const id = deliveryId(delivery)
63
+ if (this.faults.dropEnqueue?.(delivery)) return { transportJobId: id }
64
+ // Same id, same delivery: the broker deduplicates, which is what a caller-supplied job id
65
+ // buys. The database refuses a duplicate anyway; this only saves the wasted claim.
66
+ this.entries.set(id, {
67
+ id,
68
+ queue,
69
+ delivery,
70
+ availableAt: Date.now() + (opts.delayMs ?? 0),
71
+ attempt: 0,
72
+ retry: opts.retry,
73
+ state: (opts.delayMs ?? 0) > 0 ? 'delayed' : 'waiting',
74
+ })
75
+ this.start()
76
+ return { transportJobId: id }
77
+ }
78
+
79
+ async remove(_queue: string, transportJobId: string): Promise<void> {
80
+ this.entries.delete(transportJobId)
81
+ }
82
+
83
+ async getState(_queue: string, transportJobId: string): Promise<DeliveryState> {
84
+ return this.entries.get(transportJobId)?.state ?? 'unknown'
85
+ }
86
+
87
+ async upsertTick(opts: { id: string; queue: string; everyMs: number }): Promise<void> {
88
+ this.ticks.get(opts.id)?.timer.unref?.()
89
+ clearInterval(this.ticks.get(opts.id)?.timer)
90
+ const timer = setInterval(() => {
91
+ void this.enqueue(opts.queue, { jobId: opts.id, seq: 0, redrives: 0 }, { retry: TICK_RETRY })
92
+ }, opts.everyMs)
93
+ timer.unref?.()
94
+ this.ticks.set(opts.id, { queue: opts.queue, everyMs: opts.everyMs, timer })
95
+ }
96
+
97
+ async bind(queue: string, handler: DeliveryHandler, opts: BindOptions): Promise<BoundWorker> {
98
+ this.handlers.set(queue, { handler, opts })
99
+ this.start()
100
+ return {
101
+ queue,
102
+ close: async () => {
103
+ this.handlers.delete(queue)
104
+ },
105
+ }
106
+ }
107
+
108
+ async close(opts: { timeoutMs?: number } = {}): Promise<void> {
109
+ this.closed = true
110
+ this.abort.abort()
111
+ for (const tick of this.ticks.values()) clearInterval(tick.timer)
112
+ this.ticks.clear()
113
+ if (this.pump) clearInterval(this.pump)
114
+ this.pump = null
115
+ // Bounded: a handler that ignores its abort signal must not be able to hang shutdown
116
+ // forever. Dropping the wait is the honest outcome — the lease still protects the row.
117
+ const deadline = new Promise<void>((resolve) => setTimeout(resolve, opts.timeoutMs ?? 30_000).unref?.())
118
+ await Promise.race([Promise.allSettled([...this.inFlight]).then(() => undefined), deadline])
119
+ }
120
+
121
+ /** Test hook: how many deliveries the broker still holds. */
122
+ size(): number {
123
+ return this.entries.size
124
+ }
125
+
126
+ private start(): void {
127
+ if (this.pump || this.closed) return
128
+ this.pump = setInterval(() => void this.drain(), 5)
129
+ this.pump.unref?.()
130
+ }
131
+
132
+ private async drain(): Promise<void> {
133
+ if (this.closed) return
134
+ const now = Date.now()
135
+ for (const entry of [...this.entries.values()]) {
136
+ if (entry.state === 'active' || entry.availableAt > now) continue
137
+ const bound = this.handlers.get(entry.queue)
138
+ if (!bound) continue
139
+ if (this.faults.stall?.(entry.delivery)) continue
140
+ const running = [...this.inFlight].length
141
+ if (running >= bound.opts.concurrency) return
142
+ entry.state = 'active'
143
+ entry.attempt += 1
144
+ const promise = this.run(entry, bound.handler, bound.opts).finally(() => this.inFlight.delete(promise))
145
+ this.inFlight.add(promise)
146
+ if (this.faults.duplicateDeliveries) {
147
+ const twin = this.run({ ...entry }, bound.handler, bound.opts).finally(() => this.inFlight.delete(twin))
148
+ this.inFlight.add(twin)
149
+ }
150
+ }
151
+ }
152
+
153
+ private async run(entry: Entry, handler: DeliveryHandler, opts: BindOptions): Promise<void> {
154
+ let handedBack = false
155
+ try {
156
+ await handler(entry.delivery, {
157
+ transportJobId: entry.id,
158
+ attempt: entry.attempt,
159
+ maxAttempts: entry.retry.attempts,
160
+ signal: this.abort.signal,
161
+ handBack: async (next, handBackOpts) => {
162
+ handedBack = true
163
+ this.entries.delete(entry.id)
164
+ await this.enqueue(entry.queue, next, { retry: entry.retry, delayMs: handBackOpts?.delayMs })
165
+ },
166
+ })
167
+ if (!handedBack) {
168
+ entry.state = 'completed'
169
+ this.entries.delete(entry.id)
170
+ }
171
+ } catch (error) {
172
+ if ((error as { name?: string })?.name === 'NoFurtherAttempts') {
173
+ entry.state = 'failed'
174
+ this.entries.delete(entry.id)
175
+ return
176
+ }
177
+ if (entry.attempt >= entry.retry.attempts) {
178
+ entry.state = 'failed'
179
+ this.entries.delete(entry.id)
180
+ return
181
+ }
182
+ entry.state = 'delayed'
183
+ entry.availableAt = Date.now() + backoffFor(entry.retry, entry.attempt)
184
+ } finally {
185
+ void opts
186
+ }
187
+ }
188
+ }
189
+
190
+ /** The tick is a heartbeat, not work: one attempt, no backoff to reason about. */
191
+ const TICK_RETRY: RetrySettings = { attempts: 1, backoff: { type: 'fixed', delayMs: 0, maxDelayMs: 0 } }
@@ -0,0 +1,250 @@
1
+ // The pg-boss adapter: production without Redis, and the only adapter that can enqueue a
2
+ // delivery inside the caller's transaction.
3
+ //
4
+ // That one capability is why it exists. `send(..., { db })` composes its statements on a
5
+ // client the caller supplies, so a domain row, its job row and its delivery all commit or all
6
+ // roll back. With any other transport there is a window between commit and enqueue where a
7
+ // crash leaves a job nobody will ever deliver — the reconciler closes it, but closing it after
8
+ // fifteen minutes is not the same as never opening it.
9
+ //
10
+ // pg-boss is a peer dependency and is imported lazily.
11
+
12
+ import { deliveryId } from '../core/ids'
13
+ import type { Delivery, SqlExecutor } from '../core/types'
14
+ import type {
15
+ BindOptions,
16
+ BoundWorker,
17
+ DeliveryHandler,
18
+ DeliveryState,
19
+ EnqueueOptions,
20
+ TransportAdapter,
21
+ } from './types'
22
+
23
+ type PgBossModule = typeof import('pg-boss')
24
+ // pg-boss 12 exports the class by name, not as a default.
25
+ type PgBossInstance = InstanceType<PgBossModule['PgBoss']>
26
+ type PgBossJob = import('pg-boss').Job<Delivery> & { signal?: AbortSignal }
27
+
28
+ export type PgBossTransportOptions = {
29
+ connectionString: string
30
+ /** Keeps pg-boss's own tables out of `public`, so they are obviously not the app's. */
31
+ schema?: string
32
+ /** Reuse an already-started instance instead of owning its lifecycle. */
33
+ instance?: PgBossInstance
34
+ }
35
+
36
+ let cached: PgBossModule | null = null
37
+ async function pgboss(): Promise<PgBossModule> {
38
+ if (cached) return cached
39
+ try {
40
+ cached = await import('pg-boss')
41
+ return cached
42
+ } catch (error) {
43
+ throw new Error(
44
+ 'The pgboss transport requires the optional peer dependency `pg-boss`. Install it, or use DURABLE_WORK_TRANSPORT=bullmq.',
45
+ { cause: error },
46
+ )
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Adapts a `SqlExecutor` to the shape pg-boss expects from a caller-supplied client.
52
+ *
53
+ * pg-boss only ever calls `executeSql`, so the whole surface is one method. Passing our own
54
+ * executor through — rather than requiring a raw `pg` client — is what lets the caller's
55
+ * transaction be a MikroORM one, a node-postgres one, or the harness's, without any of them
56
+ * knowing about the others.
57
+ */
58
+ function asDb(tx: SqlExecutor) {
59
+ return {
60
+ async executeSql(text: string, values: unknown[]) {
61
+ const result = await tx.query(text, values)
62
+ return { rows: result.rows as Record<string, unknown>[], rowCount: result.rowCount }
63
+ },
64
+ }
65
+ }
66
+
67
+ /** A tick, in the same shape as any other delivery. */
68
+ const tickDelivery = (id: string): Delivery => ({ jobId: id, seq: 0, redrives: 0 })
69
+
70
+ export class PgBossTransport implements TransportAdapter {
71
+ readonly name = 'pgboss' as const
72
+ readonly supportsTransactionalEnqueue = true
73
+
74
+ private boss: PgBossInstance | null = null
75
+ private starting: Promise<PgBossInstance> | null = null
76
+ private readonly ownsInstance: boolean
77
+ private readonly ensuredQueues = new Set<string>()
78
+ private readonly workerIds: Array<{ queue: string; id: string }> = []
79
+ private readonly ticks: NodeJS.Timeout[] = []
80
+ private readonly shutdown = new AbortController()
81
+
82
+ constructor(private readonly options: PgBossTransportOptions) {
83
+ this.boss = options.instance ?? null
84
+ this.ownsInstance = !options.instance
85
+ }
86
+
87
+ private async ready(): Promise<PgBossInstance> {
88
+ if (this.boss) return this.boss
89
+ if (!this.starting) {
90
+ this.starting = (async () => {
91
+ const { PgBoss } = await pgboss()
92
+ const instance = new PgBoss({ connectionString: this.options.connectionString, schema: this.options.schema ?? 'durable_work_boss' })
93
+ await instance.start()
94
+ this.boss = instance
95
+ return instance
96
+ })()
97
+ }
98
+ return this.starting
99
+ }
100
+
101
+ /** pg-boss 10+ requires a queue to exist before anything is sent to it. `expireInSeconds`
102
+ * belongs to the queue, not to the worker: it is how long a delivery may stay active before
103
+ * pg-boss reclaims it, so it must exceed a whole slice or work that is still running gets
104
+ * handed to a second worker — which the lease then refuses, wasting the slice. */
105
+ private async ensureQueue(name: string, expireInSeconds?: number): Promise<PgBossInstance> {
106
+ const boss = await this.ready()
107
+ if (this.ensuredQueues.has(name)) return boss
108
+ await boss.createQueue(name, expireInSeconds ? { expireInSeconds } : undefined)
109
+ this.ensuredQueues.add(name)
110
+ return boss
111
+ }
112
+
113
+ async enqueue(queue: string, delivery: Delivery, opts: EnqueueOptions): Promise<{ transportJobId: string }> {
114
+ const boss = await this.ensureQueue(queue)
115
+ const key = deliveryId(delivery)
116
+ const sent = await boss.send(queue, delivery as unknown as object, {
117
+ // pg-boss job ids are uuids, so the delivery identity travels as the singleton key —
118
+ // which is also what makes a re-enqueue of the same delivery a no-op.
119
+ singletonKey: key,
120
+ startAfter: opts.delayMs && opts.delayMs > 0 ? Math.ceil(opts.delayMs / 1000) : undefined,
121
+ retryLimit: opts.retry.attempts,
122
+ retryDelay: Math.max(1, Math.round(opts.retry.backoff.delayMs / 1000)),
123
+ // pg-boss rejects a max delay unless backoff is on, so the cap travels only with it.
124
+ ...(opts.retry.backoff.type === 'exponential'
125
+ ? { retryBackoff: true, retryDelayMax: Math.max(1, Math.round(opts.retry.backoff.maxDelayMs / 1000)) }
126
+ : { retryBackoff: false }),
127
+ ...(opts.tx ? { db: asDb(opts.tx) } : {}),
128
+ })
129
+ // `send` returns null when the singleton key collapsed this into an existing job. That is
130
+ // the intended outcome, not a failure: the delivery is already scheduled.
131
+ return { transportJobId: sent ?? key }
132
+ }
133
+
134
+ async remove(queue: string, transportJobId: string): Promise<void> {
135
+ const boss = await this.ready()
136
+ await boss.deleteJob(queue, transportJobId).catch(() => undefined)
137
+ }
138
+
139
+ async getState(queue: string, transportJobId: string): Promise<DeliveryState> {
140
+ const boss = await this.ready()
141
+ const job = await boss.getJobById(queue, transportJobId).catch(() => null)
142
+ if (!job) return 'unknown'
143
+ switch (job.state) {
144
+ case 'created':
145
+ case 'retry':
146
+ return 'waiting'
147
+ case 'active':
148
+ return 'active'
149
+ case 'completed':
150
+ return 'completed'
151
+ case 'cancelled':
152
+ case 'failed':
153
+ return 'failed'
154
+ default:
155
+ return 'unknown'
156
+ }
157
+ }
158
+
159
+ async upsertTick(opts: { id: string; queue: string; everyMs: number }): Promise<void> {
160
+ await this.ensureQueue(opts.queue)
161
+ // pg-boss's own scheduler is cron-based, so its finest granularity is a minute — too
162
+ // coarse for a repair loop. A per-process timer with a singleton key gives the cadence we
163
+ // need and still collapses the fleet's ticks into one job per window. The trade-off is
164
+ // stated rather than hidden: with zero workers up there is no tick, exactly as with a
165
+ // broker-owned schedule that nobody polls.
166
+ const everySeconds = Math.max(1, Math.round(opts.everyMs / 1000))
167
+ const fire = async () => {
168
+ if (this.shutdown.signal.aborted) return
169
+ const boss = await this.ready()
170
+ // Delivery-shaped, like every other payload on every adapter: a tick is a delivery
171
+ // whose handler happens to ignore it, not a second kind of message.
172
+ await boss
173
+ .send(opts.queue, tickDelivery(opts.id), { singletonKey: opts.id, singletonSeconds: everySeconds })
174
+ .catch(() => undefined)
175
+ }
176
+ void fire()
177
+ const timer = setInterval(() => void fire(), opts.everyMs)
178
+ timer.unref?.()
179
+ this.ticks.push(timer)
180
+ }
181
+
182
+ async bind(queue: string, handler: DeliveryHandler, opts: BindOptions): Promise<BoundWorker> {
183
+ const boss = await this.ensureQueue(queue, Math.ceil(opts.activeTimeoutMs / 1000))
184
+
185
+ const workerId = await boss.work<Delivery>(
186
+ queue,
187
+ { batchSize: opts.concurrency },
188
+ async (jobs: PgBossJob[]) => {
189
+ for (const job of jobs) {
190
+ // Whatever arrived is handed on unexamined. An adapter that inspects payloads
191
+ // decides what counts as a real delivery, and this one used to skip anything
192
+ // without a `jobId` — which silently swallowed every reconciler tick.
193
+ const delivery = job.data
194
+
195
+ const combined = new AbortController()
196
+ const relay = () => combined.abort()
197
+ job.signal?.addEventListener('abort', relay, { once: true })
198
+ this.shutdown.signal.addEventListener('abort', relay, { once: true })
199
+
200
+ try {
201
+ await handler(delivery, {
202
+ transportJobId: job.id,
203
+ // pg-boss does not expose the attempt on the job, so the adapter reports the
204
+ // first attempt and lets its own retry policy carry the rest. The consequence is
205
+ // narrow: the delay written to `next_run_at` is the base rather than a backed-off
206
+ // one, and pg-boss's own `retryBackoff` still spaces the real deliveries.
207
+ attempt: 1,
208
+ maxAttempts: 1,
209
+ signal: combined.signal,
210
+ handBack: async (next, handBackOpts) => {
211
+ // No native hand-back: send the next delivery and let this one complete. The
212
+ // row is already at `seq + 1`, so the new key cannot collide with this job.
213
+ await this.enqueue(queue, next, {
214
+ delayMs: handBackOpts?.delayMs,
215
+ retry: { attempts: 1, backoff: { type: 'fixed', delayMs: 0, maxDelayMs: 0 } },
216
+ })
217
+ },
218
+ })
219
+ } catch (error) {
220
+ if ((error as { name?: string })?.name === 'NoFurtherAttempts') continue // settled; no retry wanted
221
+ throw error
222
+ } finally {
223
+ job.signal?.removeEventListener('abort', relay)
224
+ this.shutdown.signal.removeEventListener('abort', relay)
225
+ }
226
+ }
227
+ },
228
+ )
229
+
230
+ this.workerIds.push({ queue, id: workerId })
231
+ return {
232
+ queue,
233
+ close: async () => {
234
+ const instance = await this.ready()
235
+ await instance.offWork(queue, { id: workerId }).catch(() => undefined)
236
+ },
237
+ }
238
+ }
239
+
240
+ async close(opts: { timeoutMs?: number } = {}): Promise<void> {
241
+ this.shutdown.abort()
242
+ for (const timer of this.ticks) clearInterval(timer)
243
+ this.ticks.length = 0
244
+ if (!this.boss) return
245
+ if (!this.ownsInstance) return
246
+ await this.boss.stop({ graceful: true, close: true, timeout: opts.timeoutMs ?? 30_000 }).catch(() => undefined)
247
+ this.boss = null
248
+ this.starting = null
249
+ }
250
+ }
@@ -0,0 +1,81 @@
1
+ // What the mechanism needs from a broker, and nothing more.
2
+ //
3
+ // The interface is small on purpose. Lease, epoch fencing, slices and budgets all live above
4
+ // it, in the job row — because none of the candidate brokers fences (pg-boss completes a job
5
+ // on `state = 'active'` alone; neither it nor Graphile Worker has an epoch column), so that
6
+ // guarantee had to be ours regardless. Once it is ours, what remains for a transport is
7
+ // genuinely just delivery, and a transport becomes a replaceable detail rather than the
8
+ // design's centre. See docs/adr/0001-transport-adapters.md.
9
+
10
+ import type { Delivery, RetrySettings, SqlExecutor } from '../core/types'
11
+
12
+ export type TransportName = 'memory' | 'bullmq' | 'pgboss'
13
+
14
+ /** The state of a delivery, as far as the broker knows. Used to tell "the broker still holds
15
+ * this" from "nothing is scheduled" — the reconciler's two very different situations. */
16
+ export type DeliveryState = 'waiting' | 'delayed' | 'active' | 'completed' | 'failed' | 'unknown'
17
+
18
+ export type EnqueueOptions = {
19
+ /** How long the broker should hold the delivery before making it available. */
20
+ delayMs?: number
21
+ retry: RetrySettings
22
+ /**
23
+ * Enqueue inside the caller's transaction, so the job row and its delivery commit together.
24
+ *
25
+ * Only pg-boss can honour this — it accepts a caller-supplied client on `send`. Adapters
26
+ * that cannot must ignore it, and the service must therefore enqueue *after* commit for
27
+ * them. This is the one capability difference between the adapters that callers can see.
28
+ */
29
+ tx?: SqlExecutor
30
+ }
31
+
32
+ export type HandlerContext = {
33
+ transportJobId: string
34
+ /** 1-based. Together with the kind's retry settings this decides the next delay. */
35
+ attempt: number
36
+ maxAttempts: number
37
+ /** Aborts when the process is shutting down, so a slice can stop at a batch boundary
38
+ * instead of being killed between two writes. */
39
+ signal: AbortSignal
40
+ /**
41
+ * Hands the rest of the work back as a fresh delivery, without spending a retry attempt.
42
+ *
43
+ * Native where the broker supports it (BullMQ moves the job to delayed and keeps its id);
44
+ * emulated elsewhere by enqueuing the next delivery and completing the current one. Either
45
+ * way the contract is the same: after `handBack` resolves, the handler must return.
46
+ */
47
+ handBack(next: Delivery, opts?: { delayMs?: number }): Promise<void>
48
+ }
49
+
50
+ export type DeliveryHandler = (delivery: Delivery, ctx: HandlerContext) => Promise<void>
51
+
52
+ export type BindOptions = {
53
+ concurrency: number
54
+ /** How long a delivery may be in flight before the broker considers the worker dead. Must
55
+ * exceed the slice budget, or the broker will redeliver work that is still running — which
56
+ * the lease then refuses, wasting a whole slice. */
57
+ activeTimeoutMs: number
58
+ }
59
+
60
+ export interface BoundWorker {
61
+ queue: string
62
+ close(opts?: { timeoutMs?: number }): Promise<void>
63
+ }
64
+
65
+ export interface TransportAdapter {
66
+ readonly name: TransportName
67
+ /** True when `enqueue` honours `tx`. Callers branch on this rather than on the name. */
68
+ readonly supportsTransactionalEnqueue: boolean
69
+
70
+ enqueue(queue: string, delivery: Delivery, opts: EnqueueOptions): Promise<{ transportJobId: string }>
71
+ remove(queue: string, transportJobId: string): Promise<void>
72
+ getState(queue: string, transportJobId: string): Promise<DeliveryState>
73
+
74
+ /** A repeating delivery, used for the reconciler tick. Idempotent by `id`. */
75
+ upsertTick(opts: { id: string; queue: string; everyMs: number }): Promise<void>
76
+
77
+ bind(queue: string, handler: DeliveryHandler, opts: BindOptions): Promise<BoundWorker>
78
+
79
+ /** Stops accepting work and waits, up to `timeoutMs`, for in-flight deliveries to end. */
80
+ close(opts?: { timeoutMs?: number }): Promise<void>
81
+ }