@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,107 @@
1
+ // The one code path by which a job reaches `completed`, `failed` or `cancelled`.
2
+ //
3
+ // Everything terminal goes through here so there is a single place where "the job row and the
4
+ // domain row move together, or neither moves" is true. The job row is what every surface reads
5
+ // — the UI, the cancel route, the reconciler — so a domain row that went terminal while the
6
+ // job row still said `running` would be repaired by the reconciler as an orphan and re-driven,
7
+ // which is worse than the inconsistency it came from.
8
+
9
+ import { bumpMirrorAttempts, cancelCas, completeCas, failTerminalCas, markMirrored } from './store'
10
+ import type { ResolvedKind } from './registry'
11
+ import type { DurableJob, ErrorClass, Lease, Scope, SqlExecutor, SqlTransactor } from './types'
12
+
13
+ /** The domain mirror ran but matched no row: the domain record is gone, or another path
14
+ * already moved it. Treated exactly like a throw — "mirrored" means the domain row agrees,
15
+ * not that the callback was called. */
16
+ export class DomainMirrorMismatchError extends Error {
17
+ constructor(readonly jobId: string) {
18
+ super(`Domain mirror matched no rows for job ${jobId}`)
19
+ this.name = 'DomainMirrorMismatchError'
20
+ }
21
+ }
22
+
23
+ export type Transition =
24
+ | { type: 'complete'; patch?: { processedCount?: number; totalCount?: number | null } }
25
+ | { type: 'fail'; code: string; class: ErrorClass; message: string | null }
26
+ | { type: 'cancel' }
27
+
28
+ export type TerminalResult = { job: DurableJob; mirrored: boolean }
29
+
30
+ /**
31
+ * Runs the terminal CAS and the kind's domain mirror in one transaction.
32
+ *
33
+ * Three distinguishable outcomes, and keeping them distinguishable is the point:
34
+ * - `null` the CAS matched no rows. The lease was lost or taken; this delivery has no
35
+ * say any more and should end quietly.
36
+ * - a result committed.
37
+ * - a throw the CAS matched but the mirror failed, so everything rolled back. The caller
38
+ * decides whether that costs a retry (it does on the ordinary completion path,
39
+ * and deliberately does not on the cancellation path).
40
+ *
41
+ * An earlier draft returned `null` for both the refused fence and the rolled-back mirror. The
42
+ * caller then could not tell "someone else owns this" from "try again", which are opposite
43
+ * instructions.
44
+ */
45
+ export async function runTerminalTransition(
46
+ sql: SqlTransactor,
47
+ kind: ResolvedKind,
48
+ lease: Lease,
49
+ scope: Scope,
50
+ transition: Transition,
51
+ ): Promise<TerminalResult | null> {
52
+ let casMatched = false
53
+ try {
54
+ const result = await sql.transaction(async (tx) => {
55
+ const job = await applyCas(tx, lease, transition)
56
+ if (!job) return null
57
+ casMatched = true
58
+
59
+ if (transition.type === 'cancel' && kind.onCancel) await kind.onCancel(job, scope, tx)
60
+
61
+ if (kind.onTransition) {
62
+ const { matched } = await kind.onTransition(job, scope, tx)
63
+ if (matched < 1) throw new DomainMirrorMismatchError(job.id)
64
+ }
65
+
66
+ // Recorded inside the same transaction as the mirror it describes: a job that says its
67
+ // domain row agrees, when the write that made it agree rolled back, is the exact lie
68
+ // this protocol exists to prevent.
69
+ await markMirrored(tx, job.id)
70
+ return { job, mirrored: true }
71
+ })
72
+ return result
73
+ } catch (error) {
74
+ // Outside the rolled-back transaction on purpose — a counter written inside it would roll
75
+ // back with it, and the job would retry its mirror forever with nothing to show for it.
76
+ if (casMatched) await bumpMirrorAttempts(sql, lease.jobId).catch(() => undefined)
77
+ throw error
78
+ }
79
+ }
80
+
81
+ async function applyCas(tx: SqlExecutor, lease: Lease, transition: Transition): Promise<DurableJob | null> {
82
+ switch (transition.type) {
83
+ case 'complete':
84
+ return completeCas(tx, lease, transition.patch)
85
+ case 'fail':
86
+ return failTerminalCas(tx, lease, { code: transition.code, class: transition.class, message: transition.message })
87
+ case 'cancel':
88
+ return cancelCas(tx, lease)
89
+ }
90
+ }
91
+
92
+ /** Runs the kind's after-commit hook. Best-effort and at-most-once by design: it has already
93
+ * been decided that the job is terminal, and a hook that throws must not undo that or be
94
+ * retried into a duplicate side effect. */
95
+ export async function runAfterTransition(
96
+ kind: ResolvedKind,
97
+ job: DurableJob,
98
+ scope: Scope,
99
+ onError?: (error: unknown) => void,
100
+ ): Promise<void> {
101
+ if (!kind.onAfterTransition) return
102
+ try {
103
+ await kind.onAfterTransition(job, scope)
104
+ } catch (error) {
105
+ onError?.(error)
106
+ }
107
+ }
@@ -0,0 +1,120 @@
1
+ // The public vocabulary of the mechanism. Kept free of Open Mercato and of any transport:
2
+ // `core/` talks to Postgres through `SqlExecutor` and to a broker through `TransportAdapter`,
3
+ // so the same code runs in the OM module, in the CLI worker and in the failure harness.
4
+
5
+ export type DurableJobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
6
+
7
+ /** Why a slice returned. `budget` hands the rest of the work back without spending a retry. */
8
+ export type SliceOutcome = 'drained' | 'budget' | 'cancelled'
9
+
10
+ /** What the reconciler does with a job whose driver died. Default `park`: a job that nobody
11
+ * declared idempotent is not re-run automatically. */
12
+ export type OrphanPolicy = 'redrive' | 'park'
13
+
14
+ /** How an error was classified. `transient` is the default for anything unrecognised — the
15
+ * transport retries it. `terminal` fails the job now but leaves it re-drivable. `unrecoverable`
16
+ * fails it now and requires `{ force: true }` to re-drive. */
17
+ export type ErrorClass = 'transient' | 'terminal' | 'unrecoverable'
18
+
19
+ /** Written by the mechanism into `error_code`. Park reasons come from the reconciler, verdicts
20
+ * from a slice; an operator re-drive clears either. */
21
+ export type ParkReason = 'orphaned' | 'poison' | 'never_started' | 'no_handler'
22
+ export type SliceVerdict = 'unrecoverable' | 'retry_exhausted'
23
+
24
+ /** Every statement is tenant- and organization-scoped. A null organization is a real value
25
+ * (it means tenant-wide), which is why the lock-key index coalesces it to the zero uuid
26
+ * rather than relying on NULL-distinctness. */
27
+ export type Scope = { tenantId: string; organizationId: string | null }
28
+
29
+ /** What a worker holds while it runs a slice. There is deliberately no worker-clock expiry
30
+ * here: expiry is a fact about the database's clock, and only the database may judge it. */
31
+ export type Lease = { jobId: string; owner: string; epoch: number; ttlMs: number }
32
+
33
+ /** The identity of one delivery. A job may be delivered many times; only the delivery whose
34
+ * (seq, redrives) still matches the row may claim it. */
35
+ export type Delivery = { jobId: string; seq: number; redrives: number }
36
+
37
+ /** One row of `durable_work_jobs`, in camelCase. The row IS the authority for liveness:
38
+ * status, lease, budgets and cancellation intent are read from nowhere else. */
39
+ export type DurableJob = {
40
+ id: string
41
+ tenantId: string
42
+ organizationId: string | null
43
+ kind: string
44
+ status: DurableJobStatus
45
+ createdBy: string | null
46
+ createdAt: Date
47
+ updatedAt: Date
48
+
49
+ input: unknown
50
+ checkpoint: unknown
51
+ meta: Record<string, unknown> | null
52
+
53
+ idempotencyKey: string | null
54
+ lockKey: string | null
55
+ subjectType: string | null
56
+ subjectId: string | null
57
+ progressJobId: string | null
58
+
59
+ leaseOwner: string | null
60
+ leaseEpoch: number
61
+ leaseExpiresAt: Date | null
62
+ heartbeatAt: Date | null
63
+
64
+ queueName: string | null
65
+ queueJobId: string | null
66
+ continuationSeq: number
67
+ redrives: number
68
+ nextRunAt: Date | null
69
+ pendingSince: Date | null
70
+
71
+ redrivesSinceCommit: number
72
+ consecutiveFailures: number
73
+ interruptions: number
74
+ mirrorAttempts: number
75
+ lastCommittedAt: Date | null
76
+
77
+ startedAt: Date | null
78
+ finishedAt: Date | null
79
+ parkedAt: Date | null
80
+ cancelRequestedAt: Date | null
81
+ cancelledBy: string | null
82
+ errorClass: ErrorClass | null
83
+ errorCode: string | null
84
+ errorMessage: string | null
85
+ domainMirroredAt: Date | null
86
+
87
+ processedCount: number
88
+ totalCount: number | null
89
+ }
90
+
91
+ /** The minimum a caller must supply to create a job. `kind` selects the handler; `lockKey`
92
+ * is the single-runner key; `idempotencyKey` makes `start` safe to call twice. */
93
+ export type StartJobInput = {
94
+ kind: string
95
+ input?: unknown
96
+ idempotencyKey?: string | null
97
+ lockKey?: string | null
98
+ subject?: { type: string; id: string } | null
99
+ progressJobId?: string | null
100
+ createdBy?: string | null
101
+ meta?: Record<string, unknown> | null
102
+ totalCount?: number | null
103
+ }
104
+
105
+ /** A minimal SQL surface. Deliberately not MikroORM: the statements are hand-written because
106
+ * every one of them is a compare-and-set whose predicate is the actual specification, and an
107
+ * ORM would put a layer between that predicate and review. */
108
+ export interface SqlExecutor {
109
+ query<R = Record<string, unknown>>(text: string, params?: readonly unknown[]): Promise<{ rows: R[]; rowCount: number }>
110
+ }
111
+
112
+ export interface SqlTransactor extends SqlExecutor {
113
+ /** Runs `fn` inside one transaction. A throw rolls back and the throw propagates. */
114
+ transaction<T>(fn: (tx: SqlExecutor) => Promise<T>): Promise<T>
115
+ }
116
+
117
+ /** Effective per-kind settings after defaults are applied. */
118
+ export type LeaseSettings = { ttlMs: number; sliceBudgetMs: number; pendingTtlMs: number }
119
+ export type BudgetSettings = { maxRedrives: number; maxConsecutiveFailures: number; poisonRedrivesWithoutCommit: number }
120
+ export type RetrySettings = { attempts: number; backoff: { type: 'exponential' | 'fixed'; delayMs: number; maxDelayMs: number } }
@@ -0,0 +1,169 @@
1
+ // The worker process: binds each kind's queue, runs the reconciler tick, and drains on
2
+ // shutdown instead of being killed mid-batch.
3
+
4
+ import { deliveryId, makeOwnerId, parseDeliveryId, queueNameFor } from './ids'
5
+ import { errorMessageOf, NoFurtherAttempts } from './errors'
6
+ import { reconcileOnce, type ReconcileReport } from './reconciler'
7
+ import { registry as globalRegistry, type KindRegistry, type ResolvedKind } from './registry'
8
+ import { runSlice } from './run-slice'
9
+ import { getJob, recordEnqueue } from './store'
10
+ import type { DurableJob, Delivery, Scope, SqlTransactor } from './types'
11
+ import type { BoundWorker, TransportAdapter } from '../transport/types'
12
+
13
+ export const RECONCILE_TICK_ID = 'durable-work-reconcile'
14
+ export const RECONCILE_QUEUE = queueNameFor('reconcile')
15
+
16
+ export type WorkerOptions = {
17
+ sql: SqlTransactor
18
+ transport: TransportAdapter
19
+ registry?: KindRegistry
20
+ /** Restrict this process to a subset of kinds. Everything registered runs by default. */
21
+ kinds?: string[]
22
+ concurrency?: number
23
+ /** How often the reconciler runs. */
24
+ tickMs?: number
25
+ reconcilerGraceMs?: number
26
+ drainTimeoutMs?: number
27
+ owner?: string
28
+ log?: (event: string, fields: Record<string, unknown>) => void
29
+ }
30
+
31
+ export type DurableWorker = {
32
+ owner: string
33
+ /** Runs one reconciler pass immediately. Exposed for the CLI and for tests that would
34
+ * otherwise have to wait out a tick. */
35
+ reconcile(): Promise<ReconcileReport>
36
+ stop(): Promise<void>
37
+ }
38
+
39
+ /** Enqueues a delivery for a job, and records the id so a cancellation can remove it. */
40
+ export async function enqueueJob(
41
+ sql: SqlTransactor,
42
+ transport: TransportAdapter,
43
+ kind: ResolvedKind,
44
+ job: DurableJob,
45
+ opts: { tx?: Parameters<typeof recordEnqueue>[0] } = {},
46
+ ): Promise<void> {
47
+ const delivery: Delivery = { jobId: job.id, seq: job.continuationSeq, redrives: job.redrives }
48
+ // The delay comes from the row's own `next_run_at`, computed on the database clock. Reading
49
+ // it back as a duration here — rather than passing a timestamp to the broker — keeps the
50
+ // two clocks from having to agree.
51
+ const delayMs = job.nextRunAt ? Math.max(0, job.nextRunAt.getTime() - Date.now()) : 0
52
+ const { transportJobId } = await transport.enqueue(job.queueName ?? kind.queue, delivery, {
53
+ delayMs,
54
+ retry: kind.retry,
55
+ tx: opts.tx,
56
+ })
57
+ await recordEnqueue(opts.tx ?? sql, job.id, transportJobId, job.queueName ?? kind.queue)
58
+ }
59
+
60
+ export async function startWorker(options: WorkerOptions): Promise<DurableWorker> {
61
+ const registry = options.registry ?? globalRegistry
62
+ const owner = options.owner ?? makeOwnerId()
63
+ const log = options.log ?? (() => undefined)
64
+ const { sql, transport } = options
65
+
66
+ const kinds = registry.list().filter((k) => !options.kinds || options.kinds.includes(k.kind))
67
+ const byQueue = new Map<string, ResolvedKind[]>()
68
+ for (const kind of kinds) byQueue.set(kind.queue, [...(byQueue.get(kind.queue) ?? []), kind])
69
+
70
+ const bound: BoundWorker[] = []
71
+
72
+ for (const [queue, queueKinds] of byQueue) {
73
+ const concurrency = options.concurrency ?? Math.max(...queueKinds.map((k) => k.concurrency))
74
+ // The broker must tolerate a delivery being in flight for longer than a whole slice, or it
75
+ // redelivers work that is still running — which the lease then refuses, wasting the slice.
76
+ const activeTimeoutMs = Math.max(...queueKinds.map((k) => k.lease.sliceBudgetMs)) * 2
77
+
78
+ bound.push(
79
+ await transport.bind(
80
+ queue,
81
+ async (delivery, ctx) => {
82
+ const job = await loadJob(sql, delivery)
83
+ if (!job) {
84
+ log('durable_work.delivery_orphaned', { jobId: delivery.jobId, queue })
85
+ return
86
+ }
87
+ const kind = registry.get(job.kind)
88
+ if (!kind) {
89
+ // Nothing in this process can run it. Leave it alone rather than failing it: a
90
+ // rolling deploy legitimately has processes that do not yet know a new kind, and
91
+ // the reconciler parks it if nobody ever claims it.
92
+ log('durable_work.no_handler', { jobId: job.id, kind: job.kind })
93
+ return
94
+ }
95
+ const scope: Scope = { tenantId: job.tenantId, organizationId: job.organizationId }
96
+ const result = await runSlice({ sql, kind, owner, log }, delivery, scope, ctx)
97
+ if (result.outcome === 'yielded' && !ctx.signal.aborted) {
98
+ // The transport's hand-back may have been refused (a lock lost at exactly the
99
+ // wrong moment). The row is already `pending` at the next seq, so re-enqueuing
100
+ // under the new identity cannot collide with anything the broker still holds.
101
+ const current = await getJob(sql, job.id, scope)
102
+ if (current && current.status === 'pending') await enqueueJob(sql, transport, kind, current).catch((error) => {
103
+ log('durable_work.reenqueue_failed', { jobId: job.id, error: errorMessageOf(error) })
104
+ })
105
+ }
106
+ },
107
+ { concurrency, activeTimeoutMs },
108
+ ),
109
+ )
110
+ }
111
+
112
+ const reconcile = () =>
113
+ reconcileOnce({
114
+ sql,
115
+ registry,
116
+ graceMs: options.reconcilerGraceMs,
117
+ log,
118
+ enqueue: async (job) => {
119
+ const kind = registry.get(job.kind)
120
+ if (kind) await enqueueJob(sql, transport, kind, job)
121
+ },
122
+ })
123
+
124
+ // The tick is a repeating delivery owned by the broker rather than a job that re-enqueues
125
+ // itself: a self-re-enqueue is lost the moment one tick fails, and nothing would notice.
126
+ let tickWorker: BoundWorker | null = null
127
+ if (options.tickMs !== 0) {
128
+ tickWorker = await transport.bind(
129
+ RECONCILE_QUEUE,
130
+ async () => {
131
+ const report = await reconcile()
132
+ if (report.scanned) log('durable_work.reconciled', report as unknown as Record<string, unknown>)
133
+ },
134
+ { concurrency: 1, activeTimeoutMs: 120_000 },
135
+ )
136
+ await transport.upsertTick({ id: RECONCILE_TICK_ID, queue: RECONCILE_QUEUE, everyMs: options.tickMs ?? 15_000 })
137
+ bound.push(tickWorker)
138
+ }
139
+
140
+ return {
141
+ owner,
142
+ reconcile,
143
+ async stop() {
144
+ // Close the transport first: it stops accepting new deliveries and aborts the signal
145
+ // every in-flight slice is watching, so they hand back at their next boundary rather
146
+ // than being cut off between two writes.
147
+ await transport.close({ timeoutMs: options.drainTimeoutMs ?? 30_000 })
148
+ await Promise.allSettled(bound.map((worker) => worker.close({ timeoutMs: options.drainTimeoutMs ?? 30_000 })))
149
+ },
150
+ }
151
+ }
152
+
153
+ async function loadJob(sql: SqlTransactor, delivery: Delivery): Promise<DurableJob | null> {
154
+ // The delivery carries only ids, so the row is read unscoped here and every statement after
155
+ // it re-scopes from the row's own tenant. A delivery cannot name a scope it should not see:
156
+ // it can only name a job id that already exists.
157
+ const result = await sql.query<Record<string, unknown>>(
158
+ `select tenant_id, organization_id from durable_work_jobs where id = $1`,
159
+ [delivery.jobId],
160
+ )
161
+ if (!result.rows.length) return null
162
+ const row = result.rows[0]!
163
+ return getJob(sql, delivery.jobId, {
164
+ tenantId: String(row.tenant_id),
165
+ organizationId: row.organization_id == null ? null : String(row.organization_id),
166
+ })
167
+ }
168
+
169
+ export { deliveryId, parseDeliveryId, NoFurtherAttempts }
package/src/index.ts ADDED
@@ -0,0 +1,100 @@
1
+ // Public API of @fullstackhouse/open-mercato-durable-work.
2
+ //
3
+ // The OM module itself lives at ./modules/durable_work and is loaded by the host through
4
+ // `{ id: 'durable_work', from: '@fullstackhouse/open-mercato-durable-work' }`. Everything
5
+ // exported here is usable without Open Mercato: `core/` speaks to Postgres through
6
+ // `SqlExecutor` and to a broker through `TransportAdapter`, which is what lets the failure
7
+ // harness run the real mechanism with no app around it.
8
+
9
+ export { metadata } from './modules/durable_work/index'
10
+ export { features } from './modules/durable_work/acl'
11
+
12
+ export type {
13
+ Delivery,
14
+ DurableJob,
15
+ DurableJobStatus,
16
+ ErrorClass,
17
+ Lease,
18
+ LeaseSettings,
19
+ BudgetSettings,
20
+ RetrySettings,
21
+ OrphanPolicy,
22
+ ParkReason,
23
+ Scope,
24
+ SliceOutcome,
25
+ SliceVerdict,
26
+ SqlExecutor,
27
+ SqlTransactor,
28
+ StartJobInput,
29
+ } from './core/types'
30
+
31
+ export {
32
+ TransientError,
33
+ TerminalError,
34
+ UnrecoverableError,
35
+ LeaseLostError,
36
+ LockKeyHeldError,
37
+ NoFurtherAttempts,
38
+ UnknownKindError,
39
+ classifyError,
40
+ } from './core/errors'
41
+
42
+ export {
43
+ DEFAULT_BUDGET,
44
+ DEFAULT_LEASE,
45
+ DEFAULT_RETRY,
46
+ KindRegistry,
47
+ nextAttemptDelayMs,
48
+ registry,
49
+ resolveKind,
50
+ } from './core/registry'
51
+ export type { KindDefinition, ResolvedKind, SliceContext } from './core/registry'
52
+
53
+ export {
54
+ CREATE_INDEXES,
55
+ CREATE_TABLE,
56
+ DROP_INDEXES,
57
+ DROP_TABLE,
58
+ NO_ORG,
59
+ SCHEMA_STATEMENTS,
60
+ TABLE,
61
+ } from './core/schema'
62
+
63
+ export * as store from './core/store'
64
+ export { runSlice } from './core/run-slice'
65
+ export type { RunSliceDeps, RunSliceResult } from './core/run-slice'
66
+ export { DomainMirrorMismatchError, runAfterTransition, runTerminalTransition } from './core/terminal'
67
+ export type { TerminalResult, Transition } from './core/terminal'
68
+ export { DurableWorkService } from './core/service'
69
+ export type { DurableWorkServiceDeps, RedriveRefusal, StartResult } from './core/service'
70
+ export { reconcileOnce } from './core/reconciler'
71
+ export type { ReconcileReport, ReconcilerDeps } from './core/reconciler'
72
+ export { RECONCILE_QUEUE, RECONCILE_TICK_ID, enqueueJob, startWorker } from './core/worker'
73
+ export type { DurableWorker, WorkerOptions } from './core/worker'
74
+ export { PORTABLE_QUEUE_NAME, deliveryId, makeOwnerId, parseDeliveryId, queueNameFor, sliceIdempotencyKey } from './core/ids'
75
+
76
+ export type {
77
+ BindOptions,
78
+ BoundWorker,
79
+ DeliveryHandler,
80
+ DeliveryState,
81
+ EnqueueOptions,
82
+ HandlerContext,
83
+ TransportAdapter,
84
+ TransportName,
85
+ } from './transport/types'
86
+ export { createTransport, readConfig } from './om/config'
87
+ export type { DurableWorkConfig } from './om/config'
88
+ export { mikroExecutor, mikroTx } from './om/sql-executor-mikro'
89
+ export { createProgressMirror } from './om/progress-mirror'
90
+ export type { ProgressMirror, ProgressServiceLike } from './om/progress-mirror'
91
+ export { DurableWorkJob } from './modules/durable_work/data/entities'
92
+
93
+ export { MemoryTransport } from './transport/memory'
94
+ export type { MemoryFaults } from './transport/memory'
95
+ export { BullMQTransport } from './transport/bullmq'
96
+ export type { BullMQTransportOptions } from './transport/bullmq'
97
+ export { PgBossTransport } from './transport/pgboss'
98
+ export type { PgBossTransportOptions } from './transport/pgboss'
99
+ export { transportConformance } from './transport/conformance'
100
+ export type { ConformanceHooks } from './transport/conformance'
@@ -0,0 +1,51 @@
1
+ // The operator surface, against a running app.
2
+ //
3
+ // What these check is the contract an operator actually relies on: that the list is scoped and
4
+ // gated, that a job they cannot re-drive is not offered as re-drivable, and that a refusal says
5
+ // which refusal it is — because "wait for the other run" and "this is already done" call for
6
+ // opposite actions.
7
+
8
+ import { expect, test } from '@playwright/test'
9
+ import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
10
+
11
+ test.describe('durable_work operator API', () => {
12
+ test('requires authentication', async ({ request }) => {
13
+ const response = await request.get('/api/durable_work/jobs')
14
+ expect(response.status()).toBe(401)
15
+ })
16
+
17
+ test('lists jobs for an authorised operator', async ({ request }) => {
18
+ const token = await getAuthToken(request, 'admin')
19
+ const response = await apiRequest(request, 'GET', '/api/durable_work/jobs', { token })
20
+
21
+ expect(response.status()).toBe(200)
22
+ const body = (await response.json()) as { items: unknown[]; total: number }
23
+ expect(Array.isArray(body.items)).toBe(true)
24
+ expect(typeof body.total).toBe('number')
25
+ })
26
+
27
+ test('answers 404 for a job that does not exist, rather than leaking that it might', async ({ request }) => {
28
+ const token = await getAuthToken(request, 'admin')
29
+ const response = await apiRequest(request, 'GET', '/api/durable_work/jobs/00000000-0000-0000-0000-000000000000', { token })
30
+ expect(response.status()).toBe(404)
31
+ })
32
+
33
+ test('refuses to re-drive a job that does not exist', async ({ request }) => {
34
+ const token = await getAuthToken(request, 'admin')
35
+ const response = await apiRequest(request, 'POST', '/api/durable_work/jobs/00000000-0000-0000-0000-000000000000/redrive', {
36
+ token,
37
+ data: {},
38
+ })
39
+ // 409 with a code, not a bare failure: the caller needs to know *which* refusal it is.
40
+ expect(response.status()).toBe(409)
41
+ expect((await response.json()) as { error: string }).toMatchObject({ error: 'not_redrivable' })
42
+ })
43
+
44
+ test('rejects a page size a caller could use to pull the whole table', async ({ request }) => {
45
+ const token = await getAuthToken(request, 'admin')
46
+ const response = await apiRequest(request, 'GET', '/api/durable_work/jobs?pageSize=100000', { token })
47
+ expect(response.status()).toBe(200)
48
+ const body = (await response.json()) as { items: unknown[] }
49
+ expect(body.items.length).toBeLessThanOrEqual(200)
50
+ })
51
+ })
@@ -0,0 +1,13 @@
1
+ import { metadata } from '../index'
2
+ import { features } from '../acl'
3
+
4
+ describe('durable_work module metadata', () => {
5
+ it('declares the module id the host registers', () => {
6
+ expect(metadata.name).toBe('durable_work')
7
+ })
8
+
9
+ it('declares view and operate features scoped to the module', () => {
10
+ expect(features.map((f) => f.id)).toEqual(['durable_work.view', 'durable_work.operate'])
11
+ expect(features.every((f) => f.module === 'durable_work')).toBe(true)
12
+ })
13
+ })
@@ -0,0 +1,52 @@
1
+ // `core/schema.ts` is the definition of the table; the MikroORM entity is a second description
2
+ // of the same thing, for the host's benefit. Two descriptions drift, and the way this
3
+ // particular drift would surface is ugly: the host's migration creates one shape while the
4
+ // statements — every one of them a compare-and-set naming columns explicitly — expect another,
5
+ // so the failure lands at runtime on a predicate rather than at migration time on a schema.
6
+
7
+ import fs from 'node:fs'
8
+ import path from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
10
+
11
+ import { CREATE_TABLE } from '../../../core/schema'
12
+
13
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
14
+
15
+ /** Column names as they appear in the DDL. */
16
+ function ddlColumns(): string[] {
17
+ const body = CREATE_TABLE.slice(CREATE_TABLE.indexOf('(') + 1, CREATE_TABLE.lastIndexOf(')'))
18
+ return body
19
+ .split('\n')
20
+ .map((line) => line.trim())
21
+ .filter((line) => line.length > 0)
22
+ .map((line) => line.split(/\s+/)[0]!)
23
+ .filter((name) => /^[a-z_]+$/.test(name))
24
+ .sort()
25
+ }
26
+
27
+ /** Column names the entity declares, read from the source rather than from decorator metadata:
28
+ * loading metadata would need a configured ORM, and this only has to compare two lists. */
29
+ function entityColumns(): string[] {
30
+ const source = fs.readFileSync(path.join(HERE, '..', 'data', 'entities.ts'), 'utf8')
31
+ const names = [...source.matchAll(/@(?:Property|PrimaryKey)\(\{[^}]*name:\s*'([a-z_]+)'/g)].map((m) => m[1]!)
32
+ // The primary key declares no `name`, so it is added explicitly.
33
+ return [...new Set([...names, 'id'])].sort()
34
+ }
35
+
36
+ describe('durable_work_jobs schema', () => {
37
+ it('declares the same columns in the DDL and in the entity', () => {
38
+ const ddl = ddlColumns()
39
+ const entity = entityColumns()
40
+ expect(ddl.length).toBeGreaterThan(30)
41
+ expect(entity).toEqual(ddl)
42
+ })
43
+
44
+ it('keeps the columns the fence depends on', () => {
45
+ // Named individually because losing any one of them silently removes a guarantee rather
46
+ // than breaking a build: no epoch is no fence, no lock key is no single-runner, no
47
+ // idempotency key turns a retried start into a second job.
48
+ for (const column of ['lease_epoch', 'lease_owner', 'lease_expires_at', 'lock_key', 'idempotency_key', 'continuation_seq', 'redrives']) {
49
+ expect(ddlColumns()).toContain(column)
50
+ }
51
+ })
52
+ })
@@ -0,0 +1,6 @@
1
+ export const features = [
2
+ { id: 'durable_work.view', title: 'View durable jobs', module: 'durable_work' },
3
+ { id: 'durable_work.operate', title: 'Re-drive and cancel durable jobs', module: 'durable_work' },
4
+ ]
5
+
6
+ export default features
@@ -0,0 +1,27 @@
1
+ import { NextResponse } from 'next/server'
2
+
3
+ import { routeContext, toDto } from '../../../lib/route-helpers'
4
+
5
+ export const metadata = {
6
+ POST: { requireAuth: true, requireFeatures: ['durable_work.operate'] },
7
+ }
8
+
9
+ /**
10
+ * Runs a stopped job again.
11
+ *
12
+ * Each refusal is a 409 with a code rather than a generic failure, because the three have
13
+ * different answers: wait for or cancel the job holding the lock key; nothing to re-drive;
14
+ * or say explicitly that re-running an unrecoverable failure is right.
15
+ */
16
+ export async function POST(req: Request, { params }: { params: { id: string } }) {
17
+ const ctx = await routeContext(req)
18
+ if (ctx instanceof NextResponse) return ctx
19
+
20
+ const body = (await req.json().catch(() => ({}))) as { force?: boolean }
21
+ const result = await ctx.service.redrive(params.id, ctx.scope, { force: body.force === true })
22
+
23
+ if ('refused' in result) {
24
+ return NextResponse.json({ error: result.refused, heldBy: result.heldBy }, { status: 409 })
25
+ }
26
+ return NextResponse.json(toDto(result))
27
+ }