@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,786 @@
1
+ // Every statement that may write a job row lives here, and nowhere else.
2
+ //
3
+ // They are hand-written SQL rather than ORM calls because each one is a compare-and-set whose
4
+ // WHERE clause *is* the guarantee — "only the owner of epoch N may write this row", "only one
5
+ // live job per lock key". An ORM would put a translation layer between that predicate and the
6
+ // person reviewing it, and these predicates are exactly what has to be reviewable.
7
+ //
8
+ // Two rules hold throughout:
9
+ // 1. Time is the database's. A duration crosses the boundary; a worker-computed timestamp
10
+ // never does. A worker five minutes ahead of Postgres would otherwise push every
11
+ // reconciler predicate five minutes out.
12
+ // 2. A verdict is decided inside the statement that has the row locked, from the row's
13
+ // current counters — never from a value the caller read earlier.
14
+
15
+ import {
16
+ LOCK_KEY_INDEX,
17
+ NO_ORG,
18
+ TABLE,
19
+ } from './schema'
20
+ import { LockKeyHeldError } from './errors'
21
+ import type {
22
+ DurableJob,
23
+ DurableJobStatus,
24
+ ErrorClass,
25
+ Lease,
26
+ ParkReason,
27
+ Scope,
28
+ SqlExecutor,
29
+ SliceVerdict,
30
+ StartJobInput,
31
+ } from './types'
32
+
33
+ /** Selected by every statement that returns a row, so the mapper always sees every column. */
34
+ const COLUMNS = `
35
+ id, tenant_id, organization_id, kind, status, created_by, created_at, updated_at,
36
+ input, checkpoint, meta,
37
+ idempotency_key, lock_key, subject_type, subject_id, progress_job_id,
38
+ lease_owner, lease_epoch, lease_expires_at, heartbeat_at,
39
+ queue_name, queue_job_id, continuation_seq, redrives, next_run_at, pending_since,
40
+ redrives_since_commit, consecutive_failures, interruptions, mirror_attempts, last_committed_at,
41
+ started_at, finished_at, parked_at, cancel_requested_at, cancelled_by,
42
+ error_class, error_code, error_message, domain_mirrored_at,
43
+ processed_count, total_count`
44
+
45
+ type Row = Record<string, unknown>
46
+
47
+ const num = (value: unknown): number => (typeof value === 'number' ? value : Number(value ?? 0))
48
+ const date = (value: unknown): Date | null => (value == null ? null : value instanceof Date ? value : new Date(String(value)))
49
+
50
+ export function mapRow(row: Row): DurableJob {
51
+ return {
52
+ id: String(row.id),
53
+ tenantId: String(row.tenant_id),
54
+ organizationId: row.organization_id == null ? null : String(row.organization_id),
55
+ kind: String(row.kind),
56
+ status: String(row.status) as DurableJobStatus,
57
+ createdBy: row.created_by == null ? null : String(row.created_by),
58
+ createdAt: date(row.created_at)!,
59
+ updatedAt: date(row.updated_at)!,
60
+
61
+ input: row.input ?? null,
62
+ checkpoint: row.checkpoint ?? null,
63
+ meta: (row.meta ?? null) as Record<string, unknown> | null,
64
+
65
+ idempotencyKey: row.idempotency_key == null ? null : String(row.idempotency_key),
66
+ lockKey: row.lock_key == null ? null : String(row.lock_key),
67
+ subjectType: row.subject_type == null ? null : String(row.subject_type),
68
+ subjectId: row.subject_id == null ? null : String(row.subject_id),
69
+ progressJobId: row.progress_job_id == null ? null : String(row.progress_job_id),
70
+
71
+ leaseOwner: row.lease_owner == null ? null : String(row.lease_owner),
72
+ leaseEpoch: num(row.lease_epoch),
73
+ leaseExpiresAt: date(row.lease_expires_at),
74
+ heartbeatAt: date(row.heartbeat_at),
75
+
76
+ queueName: row.queue_name == null ? null : String(row.queue_name),
77
+ queueJobId: row.queue_job_id == null ? null : String(row.queue_job_id),
78
+ continuationSeq: num(row.continuation_seq),
79
+ redrives: num(row.redrives),
80
+ nextRunAt: date(row.next_run_at),
81
+ pendingSince: date(row.pending_since),
82
+
83
+ redrivesSinceCommit: num(row.redrives_since_commit),
84
+ consecutiveFailures: num(row.consecutive_failures),
85
+ interruptions: num(row.interruptions),
86
+ mirrorAttempts: num(row.mirror_attempts),
87
+ lastCommittedAt: date(row.last_committed_at),
88
+
89
+ startedAt: date(row.started_at),
90
+ finishedAt: date(row.finished_at),
91
+ parkedAt: date(row.parked_at),
92
+ cancelRequestedAt: date(row.cancel_requested_at),
93
+ cancelledBy: row.cancelled_by == null ? null : String(row.cancelled_by),
94
+ errorClass: row.error_class == null ? null : (String(row.error_class) as ErrorClass),
95
+ errorCode: row.error_code == null ? null : String(row.error_code),
96
+ errorMessage: row.error_message == null ? null : String(row.error_message),
97
+ domainMirroredAt: date(row.domain_mirrored_at),
98
+
99
+ processedCount: num(row.processed_count),
100
+ totalCount: row.total_count == null ? null : num(row.total_count),
101
+ }
102
+ }
103
+
104
+ const one = (result: { rows: Row[] }): DurableJob | null => (result.rows.length ? mapRow(result.rows[0]!) : null)
105
+
106
+ /** Scope predicate. A null organization matches only a null organization — it is a real value
107
+ * ("tenant-wide"), not a wildcard, and treating it as one would leak jobs across orgs. */
108
+ const SCOPE = `tenant_id = $2 and (organization_id = $3 or ($3::uuid is null and organization_id is null))`
109
+
110
+ function isUniqueViolation(error: unknown, index: string): boolean {
111
+ const e = error as { code?: unknown; constraint?: unknown; message?: unknown } | null
112
+ if (!e || e.code !== '23505') return false
113
+ if (typeof e.constraint === 'string') return e.constraint === index
114
+ return typeof e.message === 'string' && e.message.includes(index)
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------------------
118
+ // Creation
119
+ // ---------------------------------------------------------------------------------------
120
+
121
+ export type InsertResult = { job: DurableJob; created: boolean }
122
+
123
+ /**
124
+ * Inserts a job, or returns the existing one when `idempotencyKey` has been used before.
125
+ *
126
+ * Runs on the caller's executor so it can be part of their transaction: creating the domain
127
+ * row and the job row together is the entire point of the transactional-start guarantee, and
128
+ * an enqueue that happens before that transaction commits is a delivery for a job that may
129
+ * never exist.
130
+ */
131
+ export async function insertJob(
132
+ sql: SqlExecutor,
133
+ id: string,
134
+ scope: Scope,
135
+ input: StartJobInput,
136
+ queueName: string,
137
+ ): Promise<InsertResult> {
138
+ const params = [
139
+ id,
140
+ scope.tenantId,
141
+ scope.organizationId,
142
+ input.kind,
143
+ input.input ?? null,
144
+ input.meta ?? null,
145
+ input.idempotencyKey ?? null,
146
+ input.lockKey ?? null,
147
+ input.subject?.type ?? null,
148
+ input.subject?.id ?? null,
149
+ input.progressJobId ?? null,
150
+ input.createdBy ?? null,
151
+ queueName,
152
+ input.totalCount ?? null,
153
+ ]
154
+
155
+ // Checked before the insert, not after the violation.
156
+ //
157
+ // A failed statement aborts the whole transaction in Postgres — every subsequent command is
158
+ // refused until it ends. So a catch that queries for the existing job, or for the lock
159
+ // holder, works on an autocommit connection and fails on the caller's transaction, which is
160
+ // exactly where `start` is supposed to be called. The unique indexes are still the
161
+ // authority: they close the race between this check and the insert, and a violation that
162
+ // survives it is raised without touching the connection again.
163
+ if (input.idempotencyKey) {
164
+ const existing = await findByIdempotencyKey(sql, scope, input.idempotencyKey)
165
+ if (existing) return { job: existing, created: false }
166
+ }
167
+ if (input.lockKey) {
168
+ const holder = await findLiveByLockKey(sql, scope, input.lockKey)
169
+ if (holder) throw new LockKeyHeldError(input.lockKey, holder.id)
170
+ }
171
+
172
+ try {
173
+ const inserted = await sql.query<Row>(
174
+ `insert into ${TABLE} (
175
+ id, tenant_id, organization_id, kind, status,
176
+ input, meta, idempotency_key, lock_key, subject_type, subject_id, progress_job_id,
177
+ created_by, queue_name, total_count, pending_since, created_at, updated_at
178
+ ) values (
179
+ $1, $2, $3, $4, 'pending',
180
+ $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11,
181
+ $12, $13, $14, now(), now(), now()
182
+ ) returning ${COLUMNS}`,
183
+ params,
184
+ )
185
+ return { job: mapRow(inserted.rows[0]!), created: true }
186
+ } catch (error) {
187
+ // Lost the race with a concurrent start. No further query is issued here — the transaction
188
+ // is aborted, so any lookup would fail with "current transaction is aborted" and bury the
189
+ // real cause. The holder's id is unavailable in this narrow case; the refusal is not.
190
+ if (input.lockKey && isUniqueViolation(error, LOCK_KEY_INDEX)) {
191
+ throw new LockKeyHeldError(input.lockKey)
192
+ }
193
+ throw error
194
+ }
195
+ }
196
+
197
+ export async function findByIdempotencyKey(sql: SqlExecutor, scope: Scope, key: string): Promise<DurableJob | null> {
198
+ return one(await sql.query<Row>(`select ${COLUMNS} from ${TABLE} where tenant_id = $1 and idempotency_key = $2`, [scope.tenantId, key]))
199
+ }
200
+
201
+ export async function findLiveByLockKey(sql: SqlExecutor, scope: Scope, lockKey: string): Promise<DurableJob | null> {
202
+ return one(
203
+ await sql.query<Row>(
204
+ `select ${COLUMNS} from ${TABLE}
205
+ where lock_key = $1 and tenant_id = $2
206
+ and coalesce(organization_id, '${NO_ORG}'::uuid) = coalesce($3::uuid, '${NO_ORG}'::uuid)
207
+ and status in ('pending','running')
208
+ limit 1`,
209
+ [lockKey, scope.tenantId, scope.organizationId],
210
+ ),
211
+ )
212
+ }
213
+
214
+ export async function getJob(sql: SqlExecutor, id: string, scope: Scope): Promise<DurableJob | null> {
215
+ return one(await sql.query<Row>(`select ${COLUMNS} from ${TABLE} where id = $1 and ${SCOPE}`, [id, scope.tenantId, scope.organizationId]))
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------------------
219
+ // The lease
220
+ // ---------------------------------------------------------------------------------------
221
+
222
+ /**
223
+ * Accepts one delivery and takes the lease.
224
+ *
225
+ * Refuses unless `(continuation_seq, redrives)` still match what the delivery carries — that
226
+ * pair is the fence against a straggling redelivery of a slice that has already moved on.
227
+ *
228
+ * Reads no scheduled time. `next_run_at` is written on the database's clock but every delivery
229
+ * that carries one was scheduled by the *transport's* clock, so a `next_run_at <= now()` clause
230
+ * would refuse a retry that arrives a few milliseconds early and lose it permanently. Stale
231
+ * deliveries are refused by identity alone.
232
+ *
233
+ * Writes `next_run_at = null`: the scheduled delivery is now consumed. Without that, a worker
234
+ * SIGKILLed mid-slice would leave a timestamp for a delivery no broker holds, and the
235
+ * reconciler would wait out the whole pending TTL instead of the much shorter lease grace.
236
+ */
237
+ export async function claim(
238
+ sql: SqlExecutor,
239
+ id: string,
240
+ scope: Scope,
241
+ delivery: { seq: number; redrives: number },
242
+ owner: string,
243
+ ttlMs: number,
244
+ ): Promise<DurableJob | null> {
245
+ return one(
246
+ await sql.query<Row>(
247
+ `update ${TABLE}
248
+ set status = 'running',
249
+ lease_owner = $4,
250
+ lease_epoch = lease_epoch + 1,
251
+ lease_expires_at = now() + ($5::bigint * interval '1 millisecond'),
252
+ heartbeat_at = now(),
253
+ pending_since = null,
254
+ next_run_at = null,
255
+ started_at = coalesce(started_at, now()),
256
+ updated_at = now()
257
+ where id = $1 and ${SCOPE}
258
+ and status in ('pending','running')
259
+ and continuation_seq = $6 and redrives = $7
260
+ and (lease_expires_at is null or lease_expires_at < now())
261
+ returning ${COLUMNS}`,
262
+ [id, scope.tenantId, scope.organizationId, owner, ttlMs, delivery.seq, delivery.redrives],
263
+ ),
264
+ )
265
+ }
266
+
267
+ export type HeartbeatPatch = { processedCount?: number; totalCount?: number | null; committed?: boolean }
268
+
269
+ /**
270
+ * Extends the lease and optionally records progress. Returns null when the lease is gone —
271
+ * the slice must then abort, because someone else now owns the job.
272
+ *
273
+ * `committed: true` means the slice durably committed a unit of work. That resets both
274
+ * budgets: a job that is making progress has not earned any of the suspicion those counters
275
+ * represent, however many times it was interrupted getting there.
276
+ *
277
+ * Touches no indexed column, so it stays a HOT update. That matters at the cadence a
278
+ * multi-day run heartbeats at.
279
+ */
280
+ export async function heartbeat(
281
+ sql: SqlExecutor,
282
+ lease: Lease,
283
+ patch: HeartbeatPatch = {},
284
+ ): Promise<{ cancelRequested: boolean } | null> {
285
+ const result = await sql.query<Row>(
286
+ `update ${TABLE}
287
+ set lease_expires_at = now() + ($4::bigint * interval '1 millisecond'),
288
+ heartbeat_at = now(),
289
+ processed_count = coalesce($5::int, processed_count),
290
+ total_count = case when $6::boolean then $7::int else total_count end,
291
+ consecutive_failures = case when $8::boolean then 0 else consecutive_failures end,
292
+ redrives_since_commit = case when $8::boolean then 0 else redrives_since_commit end,
293
+ last_committed_at = case when $8::boolean then now() else last_committed_at end
294
+ where id = $1 and status = 'running' and lease_owner = $2 and lease_epoch = $3
295
+ returning cancel_requested_at`,
296
+ [
297
+ lease.jobId,
298
+ lease.owner,
299
+ lease.epoch,
300
+ lease.ttlMs,
301
+ patch.processedCount ?? null,
302
+ // `totalCount: null` is a meaningful value ("unknown"), so presence and value are
303
+ // carried separately rather than collapsing both onto SQL NULL.
304
+ Object.prototype.hasOwnProperty.call(patch, 'totalCount'),
305
+ patch.totalCount ?? null,
306
+ patch.committed === true,
307
+ ],
308
+ )
309
+ if (!result.rows.length) return null
310
+ return { cancelRequested: result.rows[0]!.cancel_requested_at != null }
311
+ }
312
+
313
+ /**
314
+ * Re-asserts the lease inside the caller's transaction.
315
+ *
316
+ * This is what makes `fencedWrite` a fence rather than a hope: the slice's domain writes and
317
+ * this check commit or roll back together, so a worker whose lease expired mid-transaction
318
+ * cannot land a write that outlives its right to make one.
319
+ */
320
+ export async function assertLease(tx: SqlExecutor, lease: Lease): Promise<boolean> {
321
+ const result = await tx.query<Row>(
322
+ `select 1 from ${TABLE}
323
+ where id = $1 and status = 'running' and lease_owner = $2 and lease_epoch = $3
324
+ for update`,
325
+ [lease.jobId, lease.owner, lease.epoch],
326
+ )
327
+ return result.rows.length > 0
328
+ }
329
+
330
+ /** Records a checkpoint under the fence, and counts as a committed unit. */
331
+ export async function writeCheckpoint(
332
+ tx: SqlExecutor,
333
+ lease: Lease,
334
+ checkpoint: unknown,
335
+ patch: HeartbeatPatch = {},
336
+ ): Promise<boolean> {
337
+ const result = await tx.query<Row>(
338
+ `update ${TABLE}
339
+ set checkpoint = $4::jsonb,
340
+ lease_expires_at = now() + ($5::bigint * interval '1 millisecond'),
341
+ heartbeat_at = now(),
342
+ processed_count = coalesce($6::int, processed_count),
343
+ total_count = case when $7::boolean then $8::int else total_count end,
344
+ consecutive_failures = 0,
345
+ redrives_since_commit = 0,
346
+ last_committed_at = now(),
347
+ updated_at = now()
348
+ where id = $1 and status = 'running' and lease_owner = $2 and lease_epoch = $3
349
+ returning id`,
350
+ [
351
+ lease.jobId,
352
+ lease.owner,
353
+ lease.epoch,
354
+ checkpoint ?? null,
355
+ lease.ttlMs,
356
+ patch.processedCount ?? null,
357
+ Object.prototype.hasOwnProperty.call(patch, 'totalCount'),
358
+ patch.totalCount ?? null,
359
+ ],
360
+ )
361
+ return result.rows.length > 0
362
+ }
363
+
364
+ /**
365
+ * Hands the remaining work back: the slice spent its budget or was asked to stop.
366
+ *
367
+ * Bumps `continuation_seq`, which invalidates the current delivery id and mints the next one.
368
+ * Spends no retry attempt and touches no failure counter — yielding is the mechanism working,
369
+ * not failing, and counting it would eventually park a perfectly healthy long job.
370
+ */
371
+ export async function yieldSlice(
372
+ sql: SqlExecutor,
373
+ lease: Lease,
374
+ opts: { interrupted: boolean },
375
+ ): Promise<{ seq: number; redrives: number } | null> {
376
+ const result = await sql.query<Row>(
377
+ `update ${TABLE}
378
+ set status = 'pending',
379
+ continuation_seq = continuation_seq + 1,
380
+ interruptions = interruptions + case when $4::boolean then 1 else 0 end,
381
+ lease_owner = null,
382
+ lease_expires_at = now(),
383
+ pending_since = now(),
384
+ next_run_at = now(),
385
+ updated_at = now()
386
+ where id = $1 and status = 'running' and lease_owner = $2 and lease_epoch = $3
387
+ returning continuation_seq, redrives`,
388
+ [lease.jobId, lease.owner, lease.epoch, opts.interrupted],
389
+ )
390
+ if (!result.rows.length) return null
391
+ return { seq: num(result.rows[0]!.continuation_seq), redrives: num(result.rows[0]!.redrives) }
392
+ }
393
+
394
+ /**
395
+ * The slice threw. Stays `running` and releases the lease so the transport's next attempt can
396
+ * claim the same `(seq, redrives)`.
397
+ *
398
+ * The verdict is decided *here*, from the counter this statement increments — never from a
399
+ * value read at claim time. A committed heartbeat earlier in this same slice reset
400
+ * `consecutive_failures` to zero, and the CASE below sees that reset, so a slice that made
401
+ * progress cannot be failed terminally on a stale count.
402
+ */
403
+ export async function failSlice(
404
+ sql: SqlExecutor,
405
+ lease: Lease,
406
+ error: { message: string; code: string | null; class: ErrorClass },
407
+ opts: { nextAttemptDelayMs: number | null; maxConsecutiveFailures: number },
408
+ ): Promise<{ consecutiveFailures: number; verdict: SliceVerdict | null } | null> {
409
+ const result = await sql.query<Row>(
410
+ `update ${TABLE}
411
+ set lease_owner = null,
412
+ lease_expires_at = now(),
413
+ next_run_at = case when $4::bigint is null then null else now() + ($4::bigint * interval '1 millisecond') end,
414
+ consecutive_failures = consecutive_failures + 1,
415
+ error_class = $5,
416
+ error_message = $6,
417
+ error_code = case
418
+ when $7::boolean then 'unrecoverable'
419
+ when consecutive_failures + 1 >= $8::int then 'retry_exhausted'
420
+ else $9 end,
421
+ updated_at = now()
422
+ where id = $1 and status = 'running' and lease_owner = $2 and lease_epoch = $3
423
+ returning consecutive_failures, error_code`,
424
+ [
425
+ lease.jobId,
426
+ lease.owner,
427
+ lease.epoch,
428
+ opts.nextAttemptDelayMs,
429
+ error.class,
430
+ error.message,
431
+ error.class === 'unrecoverable',
432
+ opts.maxConsecutiveFailures,
433
+ error.code,
434
+ ],
435
+ )
436
+ if (!result.rows.length) return null
437
+ const code = result.rows[0]!.error_code
438
+ const verdict = code === 'unrecoverable' || code === 'retry_exhausted' ? (code as SliceVerdict) : null
439
+ return { consecutiveFailures: num(result.rows[0]!.consecutive_failures), verdict }
440
+ }
441
+
442
+ /**
443
+ * Releases the lease without counting anything.
444
+ *
445
+ * Used when a terminal transaction rolled back while this delivery still held the lease. A
446
+ * mirror failure is not a slice failure: counting it would burn the retry budget for something
447
+ * the work itself did not do, and would clear a cancellation the operator is still waiting on.
448
+ */
449
+ export async function releaseLease(
450
+ sql: SqlExecutor,
451
+ lease: Lease,
452
+ opts: { nextAttemptDelayMs: number | null },
453
+ ): Promise<DurableJob | null> {
454
+ return one(
455
+ await sql.query<Row>(
456
+ `update ${TABLE}
457
+ set lease_owner = null,
458
+ lease_expires_at = now(),
459
+ next_run_at = case when $4::bigint is null then null else now() + ($4::bigint * interval '1 millisecond') end,
460
+ updated_at = now()
461
+ where id = $1 and status = 'running' and lease_owner = $2 and lease_epoch = $3
462
+ returning ${COLUMNS}`,
463
+ [lease.jobId, lease.owner, lease.epoch, opts.nextAttemptDelayMs],
464
+ ),
465
+ )
466
+ }
467
+
468
+ // ---------------------------------------------------------------------------------------
469
+ // Terminal transitions — run inside the terminal transaction (see terminal.ts)
470
+ // ---------------------------------------------------------------------------------------
471
+
472
+ /**
473
+ * Fenced on the epoch alone, not on the owner.
474
+ *
475
+ * The fence has to match both of its callers' lease states: on the ordinary failure path
476
+ * `failSlice` has already nulled `lease_owner`, while on the retry-of-a-verdict path the
477
+ * claim re-acquired it. The epoch is what both have in common and is what actually identifies
478
+ * the generation of the lease.
479
+ */
480
+ export async function completeCas(tx: SqlExecutor, lease: Lease, patch: { processedCount?: number; totalCount?: number | null } = {}): Promise<DurableJob | null> {
481
+ return one(
482
+ await tx.query<Row>(
483
+ `update ${TABLE}
484
+ set status = 'completed', finished_at = now(), lease_owner = null, lease_expires_at = now(),
485
+ next_run_at = null, error_class = null, error_code = null, error_message = null,
486
+ processed_count = coalesce($3::int, processed_count),
487
+ total_count = coalesce($4::int, total_count),
488
+ updated_at = now()
489
+ where id = $1 and status = 'running' and lease_epoch = $2
490
+ returning ${COLUMNS}`,
491
+ [lease.jobId, lease.epoch, patch.processedCount ?? null, patch.totalCount ?? null],
492
+ ),
493
+ )
494
+ }
495
+
496
+ export async function failTerminalCas(
497
+ tx: SqlExecutor,
498
+ lease: Lease,
499
+ verdict: { code: string; class: ErrorClass; message: string | null },
500
+ ): Promise<DurableJob | null> {
501
+ return one(
502
+ await tx.query<Row>(
503
+ `update ${TABLE}
504
+ set status = 'failed', finished_at = now(), lease_owner = null, lease_expires_at = now(),
505
+ next_run_at = null, error_code = $3, error_class = $4,
506
+ error_message = coalesce($5, error_message), updated_at = now()
507
+ where id = $1 and status = 'running' and lease_epoch = $2
508
+ returning ${COLUMNS}`,
509
+ [lease.jobId, lease.epoch, verdict.code, verdict.class, verdict.message],
510
+ ),
511
+ )
512
+ }
513
+
514
+ export async function cancelCas(tx: SqlExecutor, lease: Lease): Promise<DurableJob | null> {
515
+ return one(
516
+ await tx.query<Row>(
517
+ `update ${TABLE}
518
+ set status = 'cancelled', finished_at = now(), lease_owner = null, lease_expires_at = now(),
519
+ next_run_at = null, updated_at = now()
520
+ where id = $1 and status = 'running' and lease_epoch = $2
521
+ returning ${COLUMNS}`,
522
+ [lease.jobId, lease.epoch],
523
+ ),
524
+ )
525
+ }
526
+
527
+ /** Marks the domain mirror as landed. Called inside the same transaction as the CAS above. */
528
+ export async function markMirrored(tx: SqlExecutor, id: string): Promise<void> {
529
+ await tx.query(`update ${TABLE} set domain_mirrored_at = now() where id = $1`, [id])
530
+ }
531
+
532
+ /**
533
+ * Counts a failed mirror attempt.
534
+ *
535
+ * Deliberately its own autocommit statement, never part of the transaction it is counting —
536
+ * that transaction rolled back, and a counter written inside it would roll back with it,
537
+ * leaving a job that retries its mirror forever with nothing to show for it.
538
+ */
539
+ export async function bumpMirrorAttempts(sql: SqlExecutor, id: string): Promise<number> {
540
+ const result = await sql.query<Row>(
541
+ `update ${TABLE} set mirror_attempts = mirror_attempts + 1, updated_at = now() where id = $1 returning mirror_attempts`,
542
+ [id],
543
+ )
544
+ return result.rows.length ? num(result.rows[0]!.mirror_attempts) : 0
545
+ }
546
+
547
+ // ---------------------------------------------------------------------------------------
548
+ // Cancellation
549
+ // ---------------------------------------------------------------------------------------
550
+
551
+ /** Records the intent. A running slice observes it at its next heartbeat; a pending job is
552
+ * ended by the reconciler. Cancellation is never a write to `status` from outside. */
553
+ export async function requestCancel(sql: SqlExecutor, id: string, scope: Scope, by: string | null): Promise<DurableJob | null> {
554
+ return one(
555
+ await sql.query<Row>(
556
+ `update ${TABLE}
557
+ set cancel_requested_at = coalesce(cancel_requested_at, now()), cancelled_by = coalesce(cancelled_by, $4), updated_at = now()
558
+ where id = $1 and ${SCOPE} and status in ('pending','running')
559
+ returning ${COLUMNS}`,
560
+ [id, scope.tenantId, scope.organizationId, by],
561
+ ),
562
+ )
563
+ }
564
+
565
+ /** Ends a job that was cancelled while it was not running. Used by the reconciler's cancel
566
+ * query, where there is no lease to fence on — the `pending` status is the fence. */
567
+ export async function cancelPending(tx: SqlExecutor, id: string): Promise<DurableJob | null> {
568
+ return one(
569
+ await tx.query<Row>(
570
+ `update ${TABLE}
571
+ set status = 'cancelled', finished_at = now(), lease_owner = null, lease_expires_at = now(),
572
+ next_run_at = null, pending_since = null, updated_at = now()
573
+ where id = $1 and status = 'pending' and cancel_requested_at is not null
574
+ returning ${COLUMNS}`,
575
+ [id],
576
+ ),
577
+ )
578
+ }
579
+
580
+ // ---------------------------------------------------------------------------------------
581
+ // The re-drive family. Three statements, one core; they differ only in predicate and budget.
582
+ // ---------------------------------------------------------------------------------------
583
+
584
+ const REDRIVE_CORE = `status = 'pending', lease_owner = null, lease_epoch = lease_epoch + 1,
585
+ lease_expires_at = now(), redrives = redrives + 1, pending_since = now(), updated_at = now()`
586
+
587
+ /**
588
+ * The reconciler takes an orphan: a running job whose driver stopped heartbeating.
589
+ *
590
+ * Two tolerances, deliberately different. `lease_expires_at` is a database-clock fact about a
591
+ * driver, so a short grace suffices. `next_run_at` is when the *transport* makes a delivery
592
+ * available, not when a worker picks it up — behind a busy queue that can be minutes — so a
593
+ * scheduled delivery gets the same generous tolerance a pending job gets. Using the short
594
+ * grace for both would take healthy jobs whose retry is merely queued, spend their orphan
595
+ * budget, and park them as poison after a few busy periods.
596
+ */
597
+ export async function takeOrphan(
598
+ sql: SqlExecutor,
599
+ id: string,
600
+ opts: { graceMs: number; pendingTtlMs: number; backoffMs: number },
601
+ ): Promise<DurableJob | null> {
602
+ return one(
603
+ await sql.query<Row>(
604
+ `update ${TABLE}
605
+ set ${REDRIVE_CORE}, redrives_since_commit = redrives_since_commit + 1,
606
+ next_run_at = now() + ($4::bigint * interval '1 millisecond')
607
+ where id = $1 and status = 'running'
608
+ and lease_expires_at < now() - ($2::bigint * interval '1 millisecond')
609
+ and (next_run_at is null or next_run_at < now() - ($3::bigint * interval '1 millisecond'))
610
+ and cancel_requested_at is null
611
+ and (error_code is null or error_code not in ('unrecoverable','retry_exhausted'))
612
+ returning ${COLUMNS}`,
613
+ [id, opts.graceMs, opts.pendingTtlMs, opts.backoffMs],
614
+ ),
615
+ )
616
+ }
617
+
618
+ /** Re-drives a pending job whose delivery was never picked up — a lost hand-back or an
619
+ * enqueue that never reached the broker. */
620
+ export async function redrivePending(sql: SqlExecutor, id: string, opts: { pendingTtlMs: number }): Promise<DurableJob | null> {
621
+ return one(
622
+ await sql.query<Row>(
623
+ `update ${TABLE}
624
+ set ${REDRIVE_CORE}, redrives_since_commit = redrives_since_commit + 1, next_run_at = now()
625
+ where id = $1 and status = 'pending'
626
+ and cancel_requested_at is null
627
+ and greatest(pending_since, coalesce(next_run_at, pending_since)) < now() - ($2::bigint * interval '1 millisecond')
628
+ returning ${COLUMNS}`,
629
+ [id, opts.pendingTtlMs],
630
+ ),
631
+ )
632
+ }
633
+
634
+ /** Parks a job the reconciler will not re-drive again. `failed` with a reason, and re-drivable
635
+ * only by an operator — which is the point: something needs a human before it runs again. */
636
+ export async function park(sql: SqlExecutor, id: string, reason: ParkReason, message: string | null): Promise<DurableJob | null> {
637
+ return one(
638
+ await sql.query<Row>(
639
+ `update ${TABLE}
640
+ set status = 'failed', parked_at = now(), finished_at = now(),
641
+ lease_owner = null, lease_expires_at = now(), next_run_at = null, pending_since = null,
642
+ error_code = $2, error_class = coalesce(error_class, 'terminal'),
643
+ error_message = coalesce($3, error_message), updated_at = now()
644
+ where id = $1 and status in ('pending','running')
645
+ returning ${COLUMNS}`,
646
+ [id, reason, message],
647
+ ),
648
+ )
649
+ }
650
+
651
+ /**
652
+ * The operator's way out of `failed` — parked or terminal — and the manual take of an
653
+ * expired lease.
654
+ *
655
+ * Resets both budgets, because an operator asking for a re-drive is explicitly asking for more
656
+ * attempts. Does not reset `redrives`: that is identity, not budget, and a retained transport
657
+ * job may still carry the old pair. Clears `cancel_requested_at` too, so a job that failed
658
+ * before its slice ever observed the cancellation is not immediately cancelled again by the
659
+ * re-driven slice's first heartbeat — the explicit re-drive supersedes the never-honoured
660
+ * request.
661
+ *
662
+ * `completed` and `cancelled` are never re-drivable: the first is done, the second was asked
663
+ * for. Start a new job instead.
664
+ */
665
+ export async function operatorRedrive(
666
+ tx: SqlExecutor,
667
+ id: string,
668
+ scope: Scope,
669
+ opts: { graceMs: number; pendingTtlMs: number; force: boolean },
670
+ ): Promise<DurableJob | null> {
671
+ return one(
672
+ await tx.query<Row>(
673
+ `update ${TABLE}
674
+ set ${REDRIVE_CORE}, redrives_since_commit = 0, consecutive_failures = 0, mirror_attempts = 0,
675
+ parked_at = null, error_code = null, error_class = null, finished_at = null,
676
+ domain_mirrored_at = null, cancel_requested_at = null, cancelled_by = null, next_run_at = now()
677
+ where id = $1 and ${SCOPE}
678
+ and ($6::boolean or error_code is distinct from 'unrecoverable')
679
+ and (status = 'failed'
680
+ or (status = 'running'
681
+ and lease_expires_at < now() - ($4::bigint * interval '1 millisecond')
682
+ and (next_run_at is null or next_run_at < now() - ($5::bigint * interval '1 millisecond'))))
683
+ returning ${COLUMNS}`,
684
+ [id, scope.tenantId, scope.organizationId, opts.graceMs, opts.pendingTtlMs, opts.force],
685
+ ),
686
+ )
687
+ }
688
+
689
+ /** Records the delivery the transport accepted, so a later cancel can remove it. */
690
+ export async function recordEnqueue(sql: SqlExecutor, id: string, queueJobId: string, queueName: string): Promise<void> {
691
+ await sql.query(`update ${TABLE} set queue_job_id = $2, queue_name = $3 where id = $1`, [id, queueJobId, queueName])
692
+ }
693
+
694
+ // ---------------------------------------------------------------------------------------
695
+ // Reconciler candidate selection
696
+ // ---------------------------------------------------------------------------------------
697
+
698
+ /** `for update skip locked` is what lets two reconcilers run at once: each takes a disjoint
699
+ * slice of the candidates rather than one blocking the other or both acting on the same row. */
700
+ const SKIP_LOCKED = 'for update skip locked'
701
+
702
+ /** The reconciler is system-wide by default. The optional tenant filter exists so a fleet can
703
+ * shard the loop — one worker per tenant group — rather than having every worker scan every
704
+ * tenant's rows and skip-lock its way past them. */
705
+ export async function selectCancelling(tx: SqlExecutor, limit: number, tenantId?: string): Promise<DurableJob[]> {
706
+ const result = await tx.query<Row>(
707
+ `select ${COLUMNS} from ${TABLE}
708
+ where cancel_requested_at is not null and status in ('pending','running')
709
+ and ($2::uuid is null or tenant_id = $2)
710
+ order by cancel_requested_at asc limit $1 ${SKIP_LOCKED}`,
711
+ [limit, tenantId ?? null],
712
+ )
713
+ return result.rows.map(mapRow)
714
+ }
715
+
716
+ export async function selectOrphans(
717
+ tx: SqlExecutor,
718
+ opts: { graceMs: number; pendingTtlMs: number; limit: number; tenantId?: string },
719
+ ): Promise<DurableJob[]> {
720
+ const result = await tx.query<Row>(
721
+ `select ${COLUMNS} from ${TABLE}
722
+ where status = 'running'
723
+ and lease_expires_at < now() - ($1::bigint * interval '1 millisecond')
724
+ and (next_run_at is null or next_run_at < now() - ($2::bigint * interval '1 millisecond'))
725
+ and cancel_requested_at is null
726
+ and ($4::uuid is null or tenant_id = $4)
727
+ order by lease_expires_at asc limit $3 ${SKIP_LOCKED}`,
728
+ [opts.graceMs, opts.pendingTtlMs, opts.limit, opts.tenantId ?? null],
729
+ )
730
+ return result.rows.map(mapRow)
731
+ }
732
+
733
+ export async function selectStalePending(
734
+ tx: SqlExecutor,
735
+ opts: { pendingTtlMs: number; limit: number; tenantId?: string },
736
+ ): Promise<DurableJob[]> {
737
+ const result = await tx.query<Row>(
738
+ `select ${COLUMNS} from ${TABLE}
739
+ where status = 'pending'
740
+ and cancel_requested_at is null
741
+ and greatest(pending_since, coalesce(next_run_at, pending_since)) < now() - ($1::bigint * interval '1 millisecond')
742
+ and ($3::uuid is null or tenant_id = $3)
743
+ order by pending_since asc limit $2 ${SKIP_LOCKED}`,
744
+ [opts.pendingTtlMs, opts.limit, opts.tenantId ?? null],
745
+ )
746
+ return result.rows.map(mapRow)
747
+ }
748
+
749
+ // ---------------------------------------------------------------------------------------
750
+ // Read side
751
+ // ---------------------------------------------------------------------------------------
752
+
753
+ export type ListFilter = {
754
+ kind?: string
755
+ status?: DurableJobStatus
756
+ subject?: { type: string; id: string }
757
+ page?: number
758
+ pageSize?: number
759
+ }
760
+
761
+ export async function listJobs(sql: SqlExecutor, scope: Scope, filter: ListFilter = {}): Promise<{ items: DurableJob[]; total: number }> {
762
+ const page = Math.max(1, filter.page ?? 1)
763
+ const pageSize = Math.min(200, Math.max(1, filter.pageSize ?? 20))
764
+ // Written out rather than reusing SCOPE: that constant is numbered for statements whose
765
+ // first parameter is the job id, and renumbering it by string replacement is precisely the
766
+ // kind of cleverness that silently changes a predicate.
767
+ const where: string[] = ['tenant_id = $1 and (organization_id = $2 or ($2::uuid is null and organization_id is null))']
768
+ const params: unknown[] = [scope.tenantId, scope.organizationId]
769
+ const push = (clause: string, value: unknown) => {
770
+ params.push(value)
771
+ where.push(clause.replace('$?', `$${params.length}`))
772
+ }
773
+ if (filter.kind) push('kind = $?', filter.kind)
774
+ if (filter.status) push('status = $?', filter.status)
775
+ if (filter.subject) {
776
+ push('subject_type = $?', filter.subject.type)
777
+ push('subject_id = $?', filter.subject.id)
778
+ }
779
+ const clause = where.join(' and ')
780
+ const total = await sql.query<Row>(`select count(*)::int as n from ${TABLE} where ${clause}`, params)
781
+ const items = await sql.query<Row>(
782
+ `select ${COLUMNS} from ${TABLE} where ${clause} order by created_at desc limit ${pageSize} offset ${(page - 1) * pageSize}`,
783
+ params,
784
+ )
785
+ return { items: items.rows.map(mapRow), total: num(total.rows[0]?.n) }
786
+ }