@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,53 @@
1
+ import { bumpMirrorAttempts, cancelCas, completeCas, failTerminalCas, markMirrored } from "./store.js";
2
+ class DomainMirrorMismatchError extends Error {
3
+ constructor(jobId) {
4
+ super(`Domain mirror matched no rows for job ${jobId}`);
5
+ this.jobId = jobId;
6
+ this.name = "DomainMirrorMismatchError";
7
+ }
8
+ }
9
+ async function runTerminalTransition(sql, kind, lease, scope, transition) {
10
+ let casMatched = false;
11
+ try {
12
+ const result = await sql.transaction(async (tx) => {
13
+ const job = await applyCas(tx, lease, transition);
14
+ if (!job) return null;
15
+ casMatched = true;
16
+ if (transition.type === "cancel" && kind.onCancel) await kind.onCancel(job, scope, tx);
17
+ if (kind.onTransition) {
18
+ const { matched } = await kind.onTransition(job, scope, tx);
19
+ if (matched < 1) throw new DomainMirrorMismatchError(job.id);
20
+ }
21
+ await markMirrored(tx, job.id);
22
+ return { job, mirrored: true };
23
+ });
24
+ return result;
25
+ } catch (error) {
26
+ if (casMatched) await bumpMirrorAttempts(sql, lease.jobId).catch(() => void 0);
27
+ throw error;
28
+ }
29
+ }
30
+ async function applyCas(tx, lease, transition) {
31
+ switch (transition.type) {
32
+ case "complete":
33
+ return completeCas(tx, lease, transition.patch);
34
+ case "fail":
35
+ return failTerminalCas(tx, lease, { code: transition.code, class: transition.class, message: transition.message });
36
+ case "cancel":
37
+ return cancelCas(tx, lease);
38
+ }
39
+ }
40
+ async function runAfterTransition(kind, job, scope, onError) {
41
+ if (!kind.onAfterTransition) return;
42
+ try {
43
+ await kind.onAfterTransition(job, scope);
44
+ } catch (error) {
45
+ onError?.(error);
46
+ }
47
+ }
48
+ export {
49
+ DomainMirrorMismatchError,
50
+ runAfterTransition,
51
+ runTerminalTransition
52
+ };
53
+ //# sourceMappingURL=terminal.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/core/terminal.ts"],
4
+ "sourcesContent": ["// The one code path by which a job reaches `completed`, `failed` or `cancelled`.\n//\n// Everything terminal goes through here so there is a single place where \"the job row and the\n// domain row move together, or neither moves\" is true. The job row is what every surface reads\n// \u2014 the UI, the cancel route, the reconciler \u2014 so a domain row that went terminal while the\n// job row still said `running` would be repaired by the reconciler as an orphan and re-driven,\n// which is worse than the inconsistency it came from.\n\nimport { bumpMirrorAttempts, cancelCas, completeCas, failTerminalCas, markMirrored } from './store'\nimport type { ResolvedKind } from './registry'\nimport type { DurableJob, ErrorClass, Lease, Scope, SqlExecutor, SqlTransactor } from './types'\n\n/** The domain mirror ran but matched no row: the domain record is gone, or another path\n * already moved it. Treated exactly like a throw \u2014 \"mirrored\" means the domain row agrees,\n * not that the callback was called. */\nexport class DomainMirrorMismatchError extends Error {\n constructor(readonly jobId: string) {\n super(`Domain mirror matched no rows for job ${jobId}`)\n this.name = 'DomainMirrorMismatchError'\n }\n}\n\nexport type Transition =\n | { type: 'complete'; patch?: { processedCount?: number; totalCount?: number | null } }\n | { type: 'fail'; code: string; class: ErrorClass; message: string | null }\n | { type: 'cancel' }\n\nexport type TerminalResult = { job: DurableJob; mirrored: boolean }\n\n/**\n * Runs the terminal CAS and the kind's domain mirror in one transaction.\n *\n * Three distinguishable outcomes, and keeping them distinguishable is the point:\n * - `null` the CAS matched no rows. The lease was lost or taken; this delivery has no\n * say any more and should end quietly.\n * - a result committed.\n * - a throw the CAS matched but the mirror failed, so everything rolled back. The caller\n * decides whether that costs a retry (it does on the ordinary completion path,\n * and deliberately does not on the cancellation path).\n *\n * An earlier draft returned `null` for both the refused fence and the rolled-back mirror. The\n * caller then could not tell \"someone else owns this\" from \"try again\", which are opposite\n * instructions.\n */\nexport async function runTerminalTransition(\n sql: SqlTransactor,\n kind: ResolvedKind,\n lease: Lease,\n scope: Scope,\n transition: Transition,\n): Promise<TerminalResult | null> {\n let casMatched = false\n try {\n const result = await sql.transaction(async (tx) => {\n const job = await applyCas(tx, lease, transition)\n if (!job) return null\n casMatched = true\n\n if (transition.type === 'cancel' && kind.onCancel) await kind.onCancel(job, scope, tx)\n\n if (kind.onTransition) {\n const { matched } = await kind.onTransition(job, scope, tx)\n if (matched < 1) throw new DomainMirrorMismatchError(job.id)\n }\n\n // Recorded inside the same transaction as the mirror it describes: a job that says its\n // domain row agrees, when the write that made it agree rolled back, is the exact lie\n // this protocol exists to prevent.\n await markMirrored(tx, job.id)\n return { job, mirrored: true }\n })\n return result\n } catch (error) {\n // Outside the rolled-back transaction on purpose \u2014 a counter written inside it would roll\n // back with it, and the job would retry its mirror forever with nothing to show for it.\n if (casMatched) await bumpMirrorAttempts(sql, lease.jobId).catch(() => undefined)\n throw error\n }\n}\n\nasync function applyCas(tx: SqlExecutor, lease: Lease, transition: Transition): Promise<DurableJob | null> {\n switch (transition.type) {\n case 'complete':\n return completeCas(tx, lease, transition.patch)\n case 'fail':\n return failTerminalCas(tx, lease, { code: transition.code, class: transition.class, message: transition.message })\n case 'cancel':\n return cancelCas(tx, lease)\n }\n}\n\n/** Runs the kind's after-commit hook. Best-effort and at-most-once by design: it has already\n * been decided that the job is terminal, and a hook that throws must not undo that or be\n * retried into a duplicate side effect. */\nexport async function runAfterTransition(\n kind: ResolvedKind,\n job: DurableJob,\n scope: Scope,\n onError?: (error: unknown) => void,\n): Promise<void> {\n if (!kind.onAfterTransition) return\n try {\n await kind.onAfterTransition(job, scope)\n } catch (error) {\n onError?.(error)\n }\n}\n"],
5
+ "mappings": "AAQA,SAAS,oBAAoB,WAAW,aAAa,iBAAiB,oBAAoB;AAOnF,MAAM,kCAAkC,MAAM;AAAA,EACnD,YAAqB,OAAe;AAClC,UAAM,yCAAyC,KAAK,EAAE;AADnC;AAEnB,SAAK,OAAO;AAAA,EACd;AACF;AAwBA,eAAsB,sBACpB,KACA,MACA,OACA,OACA,YACgC;AAChC,MAAI,aAAa;AACjB,MAAI;AACF,UAAM,SAAS,MAAM,IAAI,YAAY,OAAO,OAAO;AACjD,YAAM,MAAM,MAAM,SAAS,IAAI,OAAO,UAAU;AAChD,UAAI,CAAC,IAAK,QAAO;AACjB,mBAAa;AAEb,UAAI,WAAW,SAAS,YAAY,KAAK,SAAU,OAAM,KAAK,SAAS,KAAK,OAAO,EAAE;AAErF,UAAI,KAAK,cAAc;AACrB,cAAM,EAAE,QAAQ,IAAI,MAAM,KAAK,aAAa,KAAK,OAAO,EAAE;AAC1D,YAAI,UAAU,EAAG,OAAM,IAAI,0BAA0B,IAAI,EAAE;AAAA,MAC7D;AAKA,YAAM,aAAa,IAAI,IAAI,EAAE;AAC7B,aAAO,EAAE,KAAK,UAAU,KAAK;AAAA,IAC/B,CAAC;AACD,WAAO;AAAA,EACT,SAAS,OAAO;AAGd,QAAI,WAAY,OAAM,mBAAmB,KAAK,MAAM,KAAK,EAAE,MAAM,MAAM,MAAS;AAChF,UAAM;AAAA,EACR;AACF;AAEA,eAAe,SAAS,IAAiB,OAAc,YAAoD;AACzG,UAAQ,WAAW,MAAM;AAAA,IACvB,KAAK;AACH,aAAO,YAAY,IAAI,OAAO,WAAW,KAAK;AAAA,IAChD,KAAK;AACH,aAAO,gBAAgB,IAAI,OAAO,EAAE,MAAM,WAAW,MAAM,OAAO,WAAW,OAAO,SAAS,WAAW,QAAQ,CAAC;AAAA,IACnH,KAAK;AACH,aAAO,UAAU,IAAI,KAAK;AAAA,EAC9B;AACF;AAKA,eAAsB,mBACpB,MACA,KACA,OACA,SACe;AACf,MAAI,CAAC,KAAK,kBAAmB;AAC7B,MAAI;AACF,UAAM,KAAK,kBAAkB,KAAK,KAAK;AAAA,EACzC,SAAS,OAAO;AACd,cAAU,KAAK;AAAA,EACjB;AACF;",
6
+ "names": []
7
+ }
@@ -0,0 +1 @@
1
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": [],
4
+ "sourcesContent": [],
5
+ "mappings": "",
6
+ "names": []
7
+ }
@@ -0,0 +1,111 @@
1
+ import { deliveryId, makeOwnerId, parseDeliveryId, queueNameFor } from "./ids.js";
2
+ import { errorMessageOf, NoFurtherAttempts } from "./errors.js";
3
+ import { reconcileOnce } from "./reconciler.js";
4
+ import { registry as globalRegistry } from "./registry.js";
5
+ import { runSlice } from "./run-slice.js";
6
+ import { getJob, recordEnqueue } from "./store.js";
7
+ const RECONCILE_TICK_ID = "durable-work-reconcile";
8
+ const RECONCILE_QUEUE = queueNameFor("reconcile");
9
+ async function enqueueJob(sql, transport, kind, job, opts = {}) {
10
+ const delivery = { jobId: job.id, seq: job.continuationSeq, redrives: job.redrives };
11
+ const delayMs = job.nextRunAt ? Math.max(0, job.nextRunAt.getTime() - Date.now()) : 0;
12
+ const { transportJobId } = await transport.enqueue(job.queueName ?? kind.queue, delivery, {
13
+ delayMs,
14
+ retry: kind.retry,
15
+ tx: opts.tx
16
+ });
17
+ await recordEnqueue(opts.tx ?? sql, job.id, transportJobId, job.queueName ?? kind.queue);
18
+ }
19
+ async function startWorker(options) {
20
+ const registry = options.registry ?? globalRegistry;
21
+ const owner = options.owner ?? makeOwnerId();
22
+ const log = options.log ?? (() => void 0);
23
+ const { sql, transport } = options;
24
+ const kinds = registry.list().filter((k) => !options.kinds || options.kinds.includes(k.kind));
25
+ const byQueue = /* @__PURE__ */ new Map();
26
+ for (const kind of kinds) byQueue.set(kind.queue, [...byQueue.get(kind.queue) ?? [], kind]);
27
+ const bound = [];
28
+ for (const [queue, queueKinds] of byQueue) {
29
+ const concurrency = options.concurrency ?? Math.max(...queueKinds.map((k) => k.concurrency));
30
+ const activeTimeoutMs = Math.max(...queueKinds.map((k) => k.lease.sliceBudgetMs)) * 2;
31
+ bound.push(
32
+ await transport.bind(
33
+ queue,
34
+ async (delivery, ctx) => {
35
+ const job = await loadJob(sql, delivery);
36
+ if (!job) {
37
+ log("durable_work.delivery_orphaned", { jobId: delivery.jobId, queue });
38
+ return;
39
+ }
40
+ const kind = registry.get(job.kind);
41
+ if (!kind) {
42
+ log("durable_work.no_handler", { jobId: job.id, kind: job.kind });
43
+ return;
44
+ }
45
+ const scope = { tenantId: job.tenantId, organizationId: job.organizationId };
46
+ const result = await runSlice({ sql, kind, owner, log }, delivery, scope, ctx);
47
+ if (result.outcome === "yielded" && !ctx.signal.aborted) {
48
+ const current = await getJob(sql, job.id, scope);
49
+ if (current && current.status === "pending") await enqueueJob(sql, transport, kind, current).catch((error) => {
50
+ log("durable_work.reenqueue_failed", { jobId: job.id, error: errorMessageOf(error) });
51
+ });
52
+ }
53
+ },
54
+ { concurrency, activeTimeoutMs }
55
+ )
56
+ );
57
+ }
58
+ const reconcile = () => reconcileOnce({
59
+ sql,
60
+ registry,
61
+ graceMs: options.reconcilerGraceMs,
62
+ log,
63
+ enqueue: async (job) => {
64
+ const kind = registry.get(job.kind);
65
+ if (kind) await enqueueJob(sql, transport, kind, job);
66
+ }
67
+ });
68
+ let tickWorker = null;
69
+ if (options.tickMs !== 0) {
70
+ tickWorker = await transport.bind(
71
+ RECONCILE_QUEUE,
72
+ async () => {
73
+ const report = await reconcile();
74
+ if (report.scanned) log("durable_work.reconciled", report);
75
+ },
76
+ { concurrency: 1, activeTimeoutMs: 12e4 }
77
+ );
78
+ await transport.upsertTick({ id: RECONCILE_TICK_ID, queue: RECONCILE_QUEUE, everyMs: options.tickMs ?? 15e3 });
79
+ bound.push(tickWorker);
80
+ }
81
+ return {
82
+ owner,
83
+ reconcile,
84
+ async stop() {
85
+ await transport.close({ timeoutMs: options.drainTimeoutMs ?? 3e4 });
86
+ await Promise.allSettled(bound.map((worker) => worker.close({ timeoutMs: options.drainTimeoutMs ?? 3e4 })));
87
+ }
88
+ };
89
+ }
90
+ async function loadJob(sql, delivery) {
91
+ const result = await sql.query(
92
+ `select tenant_id, organization_id from durable_work_jobs where id = $1`,
93
+ [delivery.jobId]
94
+ );
95
+ if (!result.rows.length) return null;
96
+ const row = result.rows[0];
97
+ return getJob(sql, delivery.jobId, {
98
+ tenantId: String(row.tenant_id),
99
+ organizationId: row.organization_id == null ? null : String(row.organization_id)
100
+ });
101
+ }
102
+ export {
103
+ NoFurtherAttempts,
104
+ RECONCILE_QUEUE,
105
+ RECONCILE_TICK_ID,
106
+ deliveryId,
107
+ enqueueJob,
108
+ parseDeliveryId,
109
+ startWorker
110
+ };
111
+ //# sourceMappingURL=worker.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/core/worker.ts"],
4
+ "sourcesContent": ["// The worker process: binds each kind's queue, runs the reconciler tick, and drains on\n// shutdown instead of being killed mid-batch.\n\nimport { deliveryId, makeOwnerId, parseDeliveryId, queueNameFor } from './ids'\nimport { errorMessageOf, NoFurtherAttempts } from './errors'\nimport { reconcileOnce, type ReconcileReport } from './reconciler'\nimport { registry as globalRegistry, type KindRegistry, type ResolvedKind } from './registry'\nimport { runSlice } from './run-slice'\nimport { getJob, recordEnqueue } from './store'\nimport type { DurableJob, Delivery, Scope, SqlTransactor } from './types'\nimport type { BoundWorker, TransportAdapter } from '../transport/types'\n\nexport const RECONCILE_TICK_ID = 'durable-work-reconcile'\nexport const RECONCILE_QUEUE = queueNameFor('reconcile')\n\nexport type WorkerOptions = {\n sql: SqlTransactor\n transport: TransportAdapter\n registry?: KindRegistry\n /** Restrict this process to a subset of kinds. Everything registered runs by default. */\n kinds?: string[]\n concurrency?: number\n /** How often the reconciler runs. */\n tickMs?: number\n reconcilerGraceMs?: number\n drainTimeoutMs?: number\n owner?: string\n log?: (event: string, fields: Record<string, unknown>) => void\n}\n\nexport type DurableWorker = {\n owner: string\n /** Runs one reconciler pass immediately. Exposed for the CLI and for tests that would\n * otherwise have to wait out a tick. */\n reconcile(): Promise<ReconcileReport>\n stop(): Promise<void>\n}\n\n/** Enqueues a delivery for a job, and records the id so a cancellation can remove it. */\nexport async function enqueueJob(\n sql: SqlTransactor,\n transport: TransportAdapter,\n kind: ResolvedKind,\n job: DurableJob,\n opts: { tx?: Parameters<typeof recordEnqueue>[0] } = {},\n): Promise<void> {\n const delivery: Delivery = { jobId: job.id, seq: job.continuationSeq, redrives: job.redrives }\n // The delay comes from the row's own `next_run_at`, computed on the database clock. Reading\n // it back as a duration here \u2014 rather than passing a timestamp to the broker \u2014 keeps the\n // two clocks from having to agree.\n const delayMs = job.nextRunAt ? Math.max(0, job.nextRunAt.getTime() - Date.now()) : 0\n const { transportJobId } = await transport.enqueue(job.queueName ?? kind.queue, delivery, {\n delayMs,\n retry: kind.retry,\n tx: opts.tx,\n })\n await recordEnqueue(opts.tx ?? sql, job.id, transportJobId, job.queueName ?? kind.queue)\n}\n\nexport async function startWorker(options: WorkerOptions): Promise<DurableWorker> {\n const registry = options.registry ?? globalRegistry\n const owner = options.owner ?? makeOwnerId()\n const log = options.log ?? (() => undefined)\n const { sql, transport } = options\n\n const kinds = registry.list().filter((k) => !options.kinds || options.kinds.includes(k.kind))\n const byQueue = new Map<string, ResolvedKind[]>()\n for (const kind of kinds) byQueue.set(kind.queue, [...(byQueue.get(kind.queue) ?? []), kind])\n\n const bound: BoundWorker[] = []\n\n for (const [queue, queueKinds] of byQueue) {\n const concurrency = options.concurrency ?? Math.max(...queueKinds.map((k) => k.concurrency))\n // The broker must tolerate a delivery being in flight for longer than a whole slice, or it\n // redelivers work that is still running \u2014 which the lease then refuses, wasting the slice.\n const activeTimeoutMs = Math.max(...queueKinds.map((k) => k.lease.sliceBudgetMs)) * 2\n\n bound.push(\n await transport.bind(\n queue,\n async (delivery, ctx) => {\n const job = await loadJob(sql, delivery)\n if (!job) {\n log('durable_work.delivery_orphaned', { jobId: delivery.jobId, queue })\n return\n }\n const kind = registry.get(job.kind)\n if (!kind) {\n // Nothing in this process can run it. Leave it alone rather than failing it: a\n // rolling deploy legitimately has processes that do not yet know a new kind, and\n // the reconciler parks it if nobody ever claims it.\n log('durable_work.no_handler', { jobId: job.id, kind: job.kind })\n return\n }\n const scope: Scope = { tenantId: job.tenantId, organizationId: job.organizationId }\n const result = await runSlice({ sql, kind, owner, log }, delivery, scope, ctx)\n if (result.outcome === 'yielded' && !ctx.signal.aborted) {\n // The transport's hand-back may have been refused (a lock lost at exactly the\n // wrong moment). The row is already `pending` at the next seq, so re-enqueuing\n // under the new identity cannot collide with anything the broker still holds.\n const current = await getJob(sql, job.id, scope)\n if (current && current.status === 'pending') await enqueueJob(sql, transport, kind, current).catch((error) => {\n log('durable_work.reenqueue_failed', { jobId: job.id, error: errorMessageOf(error) })\n })\n }\n },\n { concurrency, activeTimeoutMs },\n ),\n )\n }\n\n const reconcile = () =>\n reconcileOnce({\n sql,\n registry,\n graceMs: options.reconcilerGraceMs,\n log,\n enqueue: async (job) => {\n const kind = registry.get(job.kind)\n if (kind) await enqueueJob(sql, transport, kind, job)\n },\n })\n\n // The tick is a repeating delivery owned by the broker rather than a job that re-enqueues\n // itself: a self-re-enqueue is lost the moment one tick fails, and nothing would notice.\n let tickWorker: BoundWorker | null = null\n if (options.tickMs !== 0) {\n tickWorker = await transport.bind(\n RECONCILE_QUEUE,\n async () => {\n const report = await reconcile()\n if (report.scanned) log('durable_work.reconciled', report as unknown as Record<string, unknown>)\n },\n { concurrency: 1, activeTimeoutMs: 120_000 },\n )\n await transport.upsertTick({ id: RECONCILE_TICK_ID, queue: RECONCILE_QUEUE, everyMs: options.tickMs ?? 15_000 })\n bound.push(tickWorker)\n }\n\n return {\n owner,\n reconcile,\n async stop() {\n // Close the transport first: it stops accepting new deliveries and aborts the signal\n // every in-flight slice is watching, so they hand back at their next boundary rather\n // than being cut off between two writes.\n await transport.close({ timeoutMs: options.drainTimeoutMs ?? 30_000 })\n await Promise.allSettled(bound.map((worker) => worker.close({ timeoutMs: options.drainTimeoutMs ?? 30_000 })))\n },\n }\n}\n\nasync function loadJob(sql: SqlTransactor, delivery: Delivery): Promise<DurableJob | null> {\n // The delivery carries only ids, so the row is read unscoped here and every statement after\n // it re-scopes from the row's own tenant. A delivery cannot name a scope it should not see:\n // it can only name a job id that already exists.\n const result = await sql.query<Record<string, unknown>>(\n `select tenant_id, organization_id from durable_work_jobs where id = $1`,\n [delivery.jobId],\n )\n if (!result.rows.length) return null\n const row = result.rows[0]!\n return getJob(sql, delivery.jobId, {\n tenantId: String(row.tenant_id),\n organizationId: row.organization_id == null ? null : String(row.organization_id),\n })\n}\n\nexport { deliveryId, parseDeliveryId, NoFurtherAttempts }\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
package/dist/index.js ADDED
@@ -0,0 +1,99 @@
1
+ import { metadata } from "./modules/durable_work/index.js";
2
+ import { features } from "./modules/durable_work/acl.js";
3
+ import {
4
+ TransientError,
5
+ TerminalError,
6
+ UnrecoverableError,
7
+ LeaseLostError,
8
+ LockKeyHeldError,
9
+ NoFurtherAttempts,
10
+ UnknownKindError,
11
+ classifyError
12
+ } from "./core/errors.js";
13
+ import {
14
+ DEFAULT_BUDGET,
15
+ DEFAULT_LEASE,
16
+ DEFAULT_RETRY,
17
+ KindRegistry,
18
+ nextAttemptDelayMs,
19
+ registry,
20
+ resolveKind
21
+ } from "./core/registry.js";
22
+ import {
23
+ CREATE_INDEXES,
24
+ CREATE_TABLE,
25
+ DROP_INDEXES,
26
+ DROP_TABLE,
27
+ NO_ORG,
28
+ SCHEMA_STATEMENTS,
29
+ TABLE
30
+ } from "./core/schema.js";
31
+ import * as store from "./core/store.js";
32
+ import { runSlice } from "./core/run-slice.js";
33
+ import { DomainMirrorMismatchError, runAfterTransition, runTerminalTransition } from "./core/terminal.js";
34
+ import { DurableWorkService } from "./core/service.js";
35
+ import { reconcileOnce } from "./core/reconciler.js";
36
+ import { RECONCILE_QUEUE, RECONCILE_TICK_ID, enqueueJob, startWorker } from "./core/worker.js";
37
+ import { PORTABLE_QUEUE_NAME, deliveryId, makeOwnerId, parseDeliveryId, queueNameFor, sliceIdempotencyKey } from "./core/ids.js";
38
+ import { createTransport, readConfig } from "./om/config.js";
39
+ import { mikroExecutor, mikroTx } from "./om/sql-executor-mikro.js";
40
+ import { createProgressMirror } from "./om/progress-mirror.js";
41
+ import { DurableWorkJob } from "./modules/durable_work/data/entities.js";
42
+ import { MemoryTransport } from "./transport/memory.js";
43
+ import { BullMQTransport } from "./transport/bullmq.js";
44
+ import { PgBossTransport } from "./transport/pgboss.js";
45
+ import { transportConformance } from "./transport/conformance.js";
46
+ export {
47
+ BullMQTransport,
48
+ CREATE_INDEXES,
49
+ CREATE_TABLE,
50
+ DEFAULT_BUDGET,
51
+ DEFAULT_LEASE,
52
+ DEFAULT_RETRY,
53
+ DROP_INDEXES,
54
+ DROP_TABLE,
55
+ DomainMirrorMismatchError,
56
+ DurableWorkJob,
57
+ DurableWorkService,
58
+ KindRegistry,
59
+ LeaseLostError,
60
+ LockKeyHeldError,
61
+ MemoryTransport,
62
+ NO_ORG,
63
+ NoFurtherAttempts,
64
+ PORTABLE_QUEUE_NAME,
65
+ PgBossTransport,
66
+ RECONCILE_QUEUE,
67
+ RECONCILE_TICK_ID,
68
+ SCHEMA_STATEMENTS,
69
+ TABLE,
70
+ TerminalError,
71
+ TransientError,
72
+ UnknownKindError,
73
+ UnrecoverableError,
74
+ classifyError,
75
+ createProgressMirror,
76
+ createTransport,
77
+ deliveryId,
78
+ enqueueJob,
79
+ features,
80
+ makeOwnerId,
81
+ metadata,
82
+ mikroExecutor,
83
+ mikroTx,
84
+ nextAttemptDelayMs,
85
+ parseDeliveryId,
86
+ queueNameFor,
87
+ readConfig,
88
+ reconcileOnce,
89
+ registry,
90
+ resolveKind,
91
+ runAfterTransition,
92
+ runSlice,
93
+ runTerminalTransition,
94
+ sliceIdempotencyKey,
95
+ startWorker,
96
+ store,
97
+ transportConformance
98
+ };
99
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/index.ts"],
4
+ "sourcesContent": ["// Public API of @fullstackhouse/open-mercato-durable-work.\n//\n// The OM module itself lives at ./modules/durable_work and is loaded by the host through\n// `{ id: 'durable_work', from: '@fullstackhouse/open-mercato-durable-work' }`. Everything\n// exported here is usable without Open Mercato: `core/` speaks to Postgres through\n// `SqlExecutor` and to a broker through `TransportAdapter`, which is what lets the failure\n// harness run the real mechanism with no app around it.\n\nexport { metadata } from './modules/durable_work/index'\nexport { features } from './modules/durable_work/acl'\n\nexport type {\n Delivery,\n DurableJob,\n DurableJobStatus,\n ErrorClass,\n Lease,\n LeaseSettings,\n BudgetSettings,\n RetrySettings,\n OrphanPolicy,\n ParkReason,\n Scope,\n SliceOutcome,\n SliceVerdict,\n SqlExecutor,\n SqlTransactor,\n StartJobInput,\n} from './core/types'\n\nexport {\n TransientError,\n TerminalError,\n UnrecoverableError,\n LeaseLostError,\n LockKeyHeldError,\n NoFurtherAttempts,\n UnknownKindError,\n classifyError,\n} from './core/errors'\n\nexport {\n DEFAULT_BUDGET,\n DEFAULT_LEASE,\n DEFAULT_RETRY,\n KindRegistry,\n nextAttemptDelayMs,\n registry,\n resolveKind,\n} from './core/registry'\nexport type { KindDefinition, ResolvedKind, SliceContext } from './core/registry'\n\nexport {\n CREATE_INDEXES,\n CREATE_TABLE,\n DROP_INDEXES,\n DROP_TABLE,\n NO_ORG,\n SCHEMA_STATEMENTS,\n TABLE,\n} from './core/schema'\n\nexport * as store from './core/store'\nexport { runSlice } from './core/run-slice'\nexport type { RunSliceDeps, RunSliceResult } from './core/run-slice'\nexport { DomainMirrorMismatchError, runAfterTransition, runTerminalTransition } from './core/terminal'\nexport type { TerminalResult, Transition } from './core/terminal'\nexport { DurableWorkService } from './core/service'\nexport type { DurableWorkServiceDeps, RedriveRefusal, StartResult } from './core/service'\nexport { reconcileOnce } from './core/reconciler'\nexport type { ReconcileReport, ReconcilerDeps } from './core/reconciler'\nexport { RECONCILE_QUEUE, RECONCILE_TICK_ID, enqueueJob, startWorker } from './core/worker'\nexport type { DurableWorker, WorkerOptions } from './core/worker'\nexport { PORTABLE_QUEUE_NAME, deliveryId, makeOwnerId, parseDeliveryId, queueNameFor, sliceIdempotencyKey } from './core/ids'\n\nexport type {\n BindOptions,\n BoundWorker,\n DeliveryHandler,\n DeliveryState,\n EnqueueOptions,\n HandlerContext,\n TransportAdapter,\n TransportName,\n} from './transport/types'\nexport { createTransport, readConfig } from './om/config'\nexport type { DurableWorkConfig } from './om/config'\nexport { mikroExecutor, mikroTx } from './om/sql-executor-mikro'\nexport { createProgressMirror } from './om/progress-mirror'\nexport type { ProgressMirror, ProgressServiceLike } from './om/progress-mirror'\nexport { DurableWorkJob } from './modules/durable_work/data/entities'\n\nexport { MemoryTransport } from './transport/memory'\nexport type { MemoryFaults } from './transport/memory'\nexport { BullMQTransport } from './transport/bullmq'\nexport type { BullMQTransportOptions } from './transport/bullmq'\nexport { PgBossTransport } from './transport/pgboss'\nexport type { PgBossTransportOptions } from './transport/pgboss'\nexport { transportConformance } from './transport/conformance'\nexport type { ConformanceHooks } from './transport/conformance'\n"],
5
+ "mappings": "AAQA,SAAS,gBAAgB;AACzB,SAAS,gBAAgB;AAqBzB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAGP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,YAAY,WAAW;AACvB,SAAS,gBAAgB;AAEzB,SAAS,2BAA2B,oBAAoB,6BAA6B;AAErF,SAAS,0BAA0B;AAEnC,SAAS,qBAAqB;AAE9B,SAAS,iBAAiB,mBAAmB,YAAY,mBAAmB;AAE5E,SAAS,qBAAqB,YAAY,aAAa,iBAAiB,cAAc,2BAA2B;AAYjH,SAAS,iBAAiB,kBAAkB;AAE5C,SAAS,eAAe,eAAe;AACvC,SAAS,4BAA4B;AAErC,SAAS,sBAAsB;AAE/B,SAAS,uBAAuB;AAEhC,SAAS,uBAAuB;AAEhC,SAAS,uBAAuB;AAEhC,SAAS,4BAA4B;",
6
+ "names": []
7
+ }
@@ -0,0 +1,10 @@
1
+ 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
+ var acl_default = features;
6
+ export {
7
+ acl_default as default,
8
+ features
9
+ };
10
+ //# sourceMappingURL=acl.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/modules/durable_work/acl.ts"],
4
+ "sourcesContent": ["export const features = [\n { id: 'durable_work.view', title: 'View durable jobs', module: 'durable_work' },\n { id: 'durable_work.operate', title: 'Re-drive and cancel durable jobs', module: 'durable_work' },\n]\n\nexport default features\n"],
5
+ "mappings": "AAAO,MAAM,WAAW;AAAA,EACtB,EAAE,IAAI,qBAAqB,OAAO,qBAAqB,QAAQ,eAAe;AAAA,EAC9E,EAAE,IAAI,wBAAwB,OAAO,oCAAoC,QAAQ,eAAe;AAClG;AAEA,IAAO,cAAQ;",
6
+ "names": []
7
+ }
@@ -0,0 +1,20 @@
1
+ import { NextResponse } from "next/server";
2
+ import { routeContext, toDto } from "../../../lib/route-helpers.js";
3
+ const metadata = {
4
+ POST: { requireAuth: true, requireFeatures: ["durable_work.operate"] }
5
+ };
6
+ async function POST(req, { params }) {
7
+ const ctx = await routeContext(req);
8
+ if (ctx instanceof NextResponse) return ctx;
9
+ const body = await req.json().catch(() => ({}));
10
+ const result = await ctx.service.redrive(params.id, ctx.scope, { force: body.force === true });
11
+ if ("refused" in result) {
12
+ return NextResponse.json({ error: result.refused, heldBy: result.heldBy }, { status: 409 });
13
+ }
14
+ return NextResponse.json(toDto(result));
15
+ }
16
+ export {
17
+ POST,
18
+ metadata
19
+ };
20
+ //# sourceMappingURL=redrive.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../../../src/modules/durable_work/api/jobs/%5Bid%5D/redrive.ts"],
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\n\nimport { routeContext, toDto } from '../../../lib/route-helpers'\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['durable_work.operate'] },\n}\n\n/**\n * Runs a stopped job again.\n *\n * Each refusal is a 409 with a code rather than a generic failure, because the three have\n * different answers: wait for or cancel the job holding the lock key; nothing to re-drive;\n * or say explicitly that re-running an unrecoverable failure is right.\n */\nexport async function POST(req: Request, { params }: { params: { id: string } }) {\n const ctx = await routeContext(req)\n if (ctx instanceof NextResponse) return ctx\n\n const body = (await req.json().catch(() => ({}))) as { force?: boolean }\n const result = await ctx.service.redrive(params.id, ctx.scope, { force: body.force === true })\n\n if ('refused' in result) {\n return NextResponse.json({ error: result.refused, heldBy: result.heldBy }, { status: 409 })\n }\n return NextResponse.json(toDto(result))\n}\n"],
5
+ "mappings": "AAAA,SAAS,oBAAoB;AAE7B,SAAS,cAAc,aAAa;AAE7B,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,sBAAsB,EAAE;AACvE;AASA,eAAsB,KAAK,KAAc,EAAE,OAAO,GAA+B;AAC/E,QAAM,MAAM,MAAM,aAAa,GAAG;AAClC,MAAI,eAAe,aAAc,QAAO;AAExC,QAAM,OAAQ,MAAM,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC,EAAE;AAC/C,QAAM,SAAS,MAAM,IAAI,QAAQ,QAAQ,OAAO,IAAI,IAAI,OAAO,EAAE,OAAO,KAAK,UAAU,KAAK,CAAC;AAE7F,MAAI,aAAa,QAAQ;AACvB,WAAO,aAAa,KAAK,EAAE,OAAO,OAAO,SAAS,QAAQ,OAAO,OAAO,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC5F;AACA,SAAO,aAAa,KAAK,MAAM,MAAM,CAAC;AACxC;",
6
+ "names": []
7
+ }
@@ -0,0 +1,26 @@
1
+ import { NextResponse } from "next/server";
2
+ import { routeContext, toDto } from "../../../lib/route-helpers.js";
3
+ const metadata = {
4
+ GET: { requireAuth: true, requireFeatures: ["durable_work.view"] },
5
+ DELETE: { requireAuth: true, requireFeatures: ["durable_work.operate"] }
6
+ };
7
+ async function GET(req, { params }) {
8
+ const ctx = await routeContext(req);
9
+ if (ctx instanceof NextResponse) return ctx;
10
+ const job = await ctx.service.get(params.id, ctx.scope);
11
+ if (!job) return NextResponse.json({ error: "Not found" }, { status: 404 });
12
+ return NextResponse.json(toDto(job));
13
+ }
14
+ async function DELETE(req, { params }) {
15
+ const ctx = await routeContext(req);
16
+ if (ctx instanceof NextResponse) return ctx;
17
+ const job = await ctx.service.cancel(params.id, ctx.scope, ctx.userId);
18
+ if (!job) return NextResponse.json({ error: "Not found or already finished" }, { status: 404 });
19
+ return NextResponse.json({ ...toDto(job), state: job.status === "cancelled" ? "cancelled" : "cancelling" });
20
+ }
21
+ export {
22
+ DELETE,
23
+ GET,
24
+ metadata
25
+ };
26
+ //# sourceMappingURL=route.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../../../src/modules/durable_work/api/jobs/%5Bid%5D/route.ts"],
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\n\nimport { routeContext, toDto } from '../../../lib/route-helpers'\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['durable_work.view'] },\n DELETE: { requireAuth: true, requireFeatures: ['durable_work.operate'] },\n}\n\nexport async function GET(req: Request, { params }: { params: { id: string } }) {\n const ctx = await routeContext(req)\n if (ctx instanceof NextResponse) return ctx\n const job = await ctx.service.get(params.id, ctx.scope)\n if (!job) return NextResponse.json({ error: 'Not found' }, { status: 404 })\n return NextResponse.json(toDto(job))\n}\n\n/** Asks the job to stop. Answers with what actually happened \u2014 a running job is `cancelling`\n * until its driver observes the request, and saying `cancelled` before that would be a lie\n * an operator might act on. */\nexport async function DELETE(req: Request, { params }: { params: { id: string } }) {\n const ctx = await routeContext(req)\n if (ctx instanceof NextResponse) return ctx\n const job = await ctx.service.cancel(params.id, ctx.scope, ctx.userId)\n if (!job) return NextResponse.json({ error: 'Not found or already finished' }, { status: 404 })\n return NextResponse.json({ ...toDto(job), state: job.status === 'cancelled' ? 'cancelled' : 'cancelling' })\n}\n"],
5
+ "mappings": "AAAA,SAAS,oBAAoB;AAE7B,SAAS,cAAc,aAAa;AAE7B,MAAM,WAAW;AAAA,EACtB,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,mBAAmB,EAAE;AAAA,EACjE,QAAQ,EAAE,aAAa,MAAM,iBAAiB,CAAC,sBAAsB,EAAE;AACzE;AAEA,eAAsB,IAAI,KAAc,EAAE,OAAO,GAA+B;AAC9E,QAAM,MAAM,MAAM,aAAa,GAAG;AAClC,MAAI,eAAe,aAAc,QAAO;AACxC,QAAM,MAAM,MAAM,IAAI,QAAQ,IAAI,OAAO,IAAI,IAAI,KAAK;AACtD,MAAI,CAAC,IAAK,QAAO,aAAa,KAAK,EAAE,OAAO,YAAY,GAAG,EAAE,QAAQ,IAAI,CAAC;AAC1E,SAAO,aAAa,KAAK,MAAM,GAAG,CAAC;AACrC;AAKA,eAAsB,OAAO,KAAc,EAAE,OAAO,GAA+B;AACjF,QAAM,MAAM,MAAM,aAAa,GAAG;AAClC,MAAI,eAAe,aAAc,QAAO;AACxC,QAAM,MAAM,MAAM,IAAI,QAAQ,OAAO,OAAO,IAAI,IAAI,OAAO,IAAI,MAAM;AACrE,MAAI,CAAC,IAAK,QAAO,aAAa,KAAK,EAAE,OAAO,gCAAgC,GAAG,EAAE,QAAQ,IAAI,CAAC;AAC9F,SAAO,aAAa,KAAK,EAAE,GAAG,MAAM,GAAG,GAAG,OAAO,IAAI,WAAW,cAAc,cAAc,aAAa,CAAC;AAC5G;",
6
+ "names": []
7
+ }
@@ -0,0 +1,24 @@
1
+ import { NextResponse } from "next/server";
2
+ import { routeContext, toDto } from "../../lib/route-helpers.js";
3
+ const metadata = {
4
+ GET: { requireAuth: true, requireFeatures: ["durable_work.view"] }
5
+ };
6
+ const STATUSES = /* @__PURE__ */ new Set(["pending", "running", "completed", "failed", "cancelled"]);
7
+ async function GET(req) {
8
+ const ctx = await routeContext(req);
9
+ if (ctx instanceof NextResponse) return ctx;
10
+ const url = new URL(req.url);
11
+ const status = url.searchParams.get("status");
12
+ const { items, total } = await ctx.service.list(ctx.scope, {
13
+ kind: url.searchParams.get("kind") ?? void 0,
14
+ status: status && STATUSES.has(status) ? status : void 0,
15
+ page: Number(url.searchParams.get("page") ?? "1"),
16
+ pageSize: Number(url.searchParams.get("pageSize") ?? "20")
17
+ });
18
+ return NextResponse.json({ items: items.map(toDto), total });
19
+ }
20
+ export {
21
+ GET,
22
+ metadata
23
+ };
24
+ //# sourceMappingURL=route.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../../src/modules/durable_work/api/jobs/route.ts"],
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\n\nimport { routeContext, toDto } from '../../lib/route-helpers'\nimport type { DurableJobStatus } from '../../../../core/types'\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['durable_work.view'] },\n}\n\nconst STATUSES = new Set<DurableJobStatus>(['pending', 'running', 'completed', 'failed', 'cancelled'])\n\nexport async function GET(req: Request) {\n const ctx = await routeContext(req)\n if (ctx instanceof NextResponse) return ctx\n\n const url = new URL(req.url)\n const status = url.searchParams.get('status')\n const { items, total } = await ctx.service.list(ctx.scope, {\n kind: url.searchParams.get('kind') ?? undefined,\n status: status && STATUSES.has(status as DurableJobStatus) ? (status as DurableJobStatus) : undefined,\n page: Number(url.searchParams.get('page') ?? '1'),\n pageSize: Number(url.searchParams.get('pageSize') ?? '20'),\n })\n\n return NextResponse.json({ items: items.map(toDto), total })\n}\n"],
5
+ "mappings": "AAAA,SAAS,oBAAoB;AAE7B,SAAS,cAAc,aAAa;AAG7B,MAAM,WAAW;AAAA,EACtB,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,mBAAmB,EAAE;AACnE;AAEA,MAAM,WAAW,oBAAI,IAAsB,CAAC,WAAW,WAAW,aAAa,UAAU,WAAW,CAAC;AAErG,eAAsB,IAAI,KAAc;AACtC,QAAM,MAAM,MAAM,aAAa,GAAG;AAClC,MAAI,eAAe,aAAc,QAAO;AAExC,QAAM,MAAM,IAAI,IAAI,IAAI,GAAG;AAC3B,QAAM,SAAS,IAAI,aAAa,IAAI,QAAQ;AAC5C,QAAM,EAAE,OAAO,MAAM,IAAI,MAAM,IAAI,QAAQ,KAAK,IAAI,OAAO;AAAA,IACzD,MAAM,IAAI,aAAa,IAAI,MAAM,KAAK;AAAA,IACtC,QAAQ,UAAU,SAAS,IAAI,MAA0B,IAAK,SAA8B;AAAA,IAC5F,MAAM,OAAO,IAAI,aAAa,IAAI,MAAM,KAAK,GAAG;AAAA,IAChD,UAAU,OAAO,IAAI,aAAa,IAAI,UAAU,KAAK,IAAI;AAAA,EAC3D,CAAC;AAED,SAAO,aAAa,KAAK,EAAE,OAAO,MAAM,IAAI,KAAK,GAAG,MAAM,CAAC;AAC7D;",
6
+ "names": []
7
+ }
@@ -0,0 +1,72 @@
1
+ import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
2
+ import { registry } from "../../core/registry.js";
3
+ import { startWorker } from "../../core/worker.js";
4
+ import { readConfig } from "../../om/config.js";
5
+ const flag = (argv, name) => {
6
+ const index = argv.indexOf(`--${name}`);
7
+ return index >= 0 ? argv[index + 1] : void 0;
8
+ };
9
+ const emit = (event, fields = {}) => console.log(JSON.stringify({ event, ...fields }));
10
+ const workerCommand = {
11
+ command: "worker",
12
+ async run(argv) {
13
+ const config = readConfig();
14
+ const container = await createRequestContainer();
15
+ const sql = container.resolve("durableWorkSql");
16
+ const transport = container.resolve("durableWorkTransport");
17
+ const worker = await startWorker({
18
+ sql,
19
+ transport,
20
+ registry,
21
+ kinds: flag(argv, "kinds")?.split(","),
22
+ concurrency: flag(argv, "concurrency") ? Number(flag(argv, "concurrency")) : void 0,
23
+ tickMs: config.tickMs,
24
+ reconcilerGraceMs: config.reconcilerGraceMs,
25
+ drainTimeoutMs: config.drainTimeoutMs,
26
+ log: (event, fields) => emit(event, fields)
27
+ });
28
+ emit("durable_work.worker_started", {
29
+ owner: worker.owner,
30
+ transport: transport.name,
31
+ kinds: registry.list().map((kind) => kind.kind)
32
+ });
33
+ let stopping = false;
34
+ const stop = async (signal) => {
35
+ if (stopping) return;
36
+ stopping = true;
37
+ emit("durable_work.worker_draining", { signal, timeoutMs: config.drainTimeoutMs });
38
+ await worker.stop();
39
+ emit("durable_work.worker_stopped");
40
+ process.exit(0);
41
+ };
42
+ process.on("SIGTERM", () => void stop("SIGTERM"));
43
+ process.on("SIGINT", () => void stop("SIGINT"));
44
+ await new Promise(() => void 0);
45
+ }
46
+ };
47
+ const reconcileCommand = {
48
+ command: "reconcile",
49
+ async run() {
50
+ const container = await createRequestContainer();
51
+ const service = container.resolve("durableWorkService");
52
+ console.log(JSON.stringify(await service.reconcile(), null, 2));
53
+ }
54
+ };
55
+ const helpCommand = {
56
+ command: "help",
57
+ async run() {
58
+ console.log(
59
+ [
60
+ "mercato durable_work worker [--kinds a,b] [--concurrency n]",
61
+ " Bind every registered kind, own the reconciler tick, drain on SIGTERM.",
62
+ "mercato durable_work reconcile",
63
+ " Run one reconciler pass and print what it repaired."
64
+ ].join("\n")
65
+ );
66
+ }
67
+ };
68
+ var cli_default = [workerCommand, reconcileCommand, helpCommand];
69
+ export {
70
+ cli_default as default
71
+ };
72
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/modules/durable_work/cli.ts"],
4
+ "sourcesContent": ["// The worker process, and the operator's command line.\n//\n// `mercato durable_work worker` is how the mechanism runs in production. It is deliberately its\n// own process: a slice can run for minutes, and hosting that inside the web process means a\n// deploy either kills work mid-batch or holds the deploy open for the length of a slice.\n\nimport type { ModuleCli } from '@open-mercato/shared/modules/registry'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\n\nimport type { DurableWorkService } from '../../core/service'\nimport { registry } from '../../core/registry'\nimport { startWorker } from '../../core/worker'\nimport { readConfig } from '../../om/config'\nimport type { SqlTransactor } from '../../core/types'\nimport type { TransportAdapter } from '../../transport/types'\n\nconst flag = (argv: string[], name: string): string | undefined => {\n const index = argv.indexOf(`--${name}`)\n return index >= 0 ? argv[index + 1] : undefined\n}\n\nconst emit = (event: string, fields: Record<string, unknown> = {}) => console.log(JSON.stringify({ event, ...fields }))\n\nconst workerCommand: ModuleCli = {\n command: 'worker',\n async run(argv: string[]) {\n const config = readConfig()\n const container = await createRequestContainer()\n const sql = container.resolve('durableWorkSql') as SqlTransactor\n const transport = container.resolve('durableWorkTransport') as TransportAdapter\n\n const worker = await startWorker({\n sql,\n transport,\n registry,\n kinds: flag(argv, 'kinds')?.split(','),\n concurrency: flag(argv, 'concurrency') ? Number(flag(argv, 'concurrency')) : undefined,\n tickMs: config.tickMs,\n reconcilerGraceMs: config.reconcilerGraceMs,\n drainTimeoutMs: config.drainTimeoutMs,\n log: (event, fields) => emit(event, fields),\n })\n\n emit('durable_work.worker_started', {\n owner: worker.owner,\n transport: transport.name,\n kinds: registry.list().map((kind) => kind.kind),\n })\n\n // SIGTERM is what a deploy sends. Draining rather than exiting is the difference between a\n // slice handing its remaining work back and a slice being killed between two writes.\n let stopping = false\n const stop = async (signal: string) => {\n if (stopping) return\n stopping = true\n emit('durable_work.worker_draining', { signal, timeoutMs: config.drainTimeoutMs })\n await worker.stop()\n emit('durable_work.worker_stopped')\n process.exit(0)\n }\n process.on('SIGTERM', () => void stop('SIGTERM'))\n process.on('SIGINT', () => void stop('SIGINT'))\n\n await new Promise(() => undefined) // run until signalled\n },\n}\n\nconst reconcileCommand: ModuleCli = {\n command: 'reconcile',\n async run() {\n const container = await createRequestContainer()\n const service = container.resolve('durableWorkService') as DurableWorkService\n console.log(JSON.stringify(await service.reconcile(), null, 2))\n },\n}\n\nconst helpCommand: ModuleCli = {\n command: 'help',\n async run() {\n console.log(\n [\n 'mercato durable_work worker [--kinds a,b] [--concurrency n]',\n ' Bind every registered kind, own the reconciler tick, drain on SIGTERM.',\n 'mercato durable_work reconcile',\n ' Run one reconciler pass and print what it repaired.',\n ].join('\\n'),\n )\n },\n}\n\nexport default [workerCommand, reconcileCommand, helpCommand]\n"],
5
+ "mappings": "AAOA,SAAS,8BAA8B;AAGvC,SAAS,gBAAgB;AACzB,SAAS,mBAAmB;AAC5B,SAAS,kBAAkB;AAI3B,MAAM,OAAO,CAAC,MAAgB,SAAqC;AACjE,QAAM,QAAQ,KAAK,QAAQ,KAAK,IAAI,EAAE;AACtC,SAAO,SAAS,IAAI,KAAK,QAAQ,CAAC,IAAI;AACxC;AAEA,MAAM,OAAO,CAAC,OAAe,SAAkC,CAAC,MAAM,QAAQ,IAAI,KAAK,UAAU,EAAE,OAAO,GAAG,OAAO,CAAC,CAAC;AAEtH,MAAM,gBAA2B;AAAA,EAC/B,SAAS;AAAA,EACT,MAAM,IAAI,MAAgB;AACxB,UAAM,SAAS,WAAW;AAC1B,UAAM,YAAY,MAAM,uBAAuB;AAC/C,UAAM,MAAM,UAAU,QAAQ,gBAAgB;AAC9C,UAAM,YAAY,UAAU,QAAQ,sBAAsB;AAE1D,UAAM,SAAS,MAAM,YAAY;AAAA,MAC/B;AAAA,MACA;AAAA,MACA;AAAA,MACA,OAAO,KAAK,MAAM,OAAO,GAAG,MAAM,GAAG;AAAA,MACrC,aAAa,KAAK,MAAM,aAAa,IAAI,OAAO,KAAK,MAAM,aAAa,CAAC,IAAI;AAAA,MAC7E,QAAQ,OAAO;AAAA,MACf,mBAAmB,OAAO;AAAA,MAC1B,gBAAgB,OAAO;AAAA,MACvB,KAAK,CAAC,OAAO,WAAW,KAAK,OAAO,MAAM;AAAA,IAC5C,CAAC;AAED,SAAK,+BAA+B;AAAA,MAClC,OAAO,OAAO;AAAA,MACd,WAAW,UAAU;AAAA,MACrB,OAAO,SAAS,KAAK,EAAE,IAAI,CAAC,SAAS,KAAK,IAAI;AAAA,IAChD,CAAC;AAID,QAAI,WAAW;AACf,UAAM,OAAO,OAAO,WAAmB;AACrC,UAAI,SAAU;AACd,iBAAW;AACX,WAAK,gCAAgC,EAAE,QAAQ,WAAW,OAAO,eAAe,CAAC;AACjF,YAAM,OAAO,KAAK;AAClB,WAAK,6BAA6B;AAClC,cAAQ,KAAK,CAAC;AAAA,IAChB;AACA,YAAQ,GAAG,WAAW,MAAM,KAAK,KAAK,SAAS,CAAC;AAChD,YAAQ,GAAG,UAAU,MAAM,KAAK,KAAK,QAAQ,CAAC;AAE9C,UAAM,IAAI,QAAQ,MAAM,MAAS;AAAA,EACnC;AACF;AAEA,MAAM,mBAA8B;AAAA,EAClC,SAAS;AAAA,EACT,MAAM,MAAM;AACV,UAAM,YAAY,MAAM,uBAAuB;AAC/C,UAAM,UAAU,UAAU,QAAQ,oBAAoB;AACtD,YAAQ,IAAI,KAAK,UAAU,MAAM,QAAQ,UAAU,GAAG,MAAM,CAAC,CAAC;AAAA,EAChE;AACF;AAEA,MAAM,cAAyB;AAAA,EAC7B,SAAS;AAAA,EACT,MAAM,MAAM;AACV,YAAQ;AAAA,MACN;AAAA,QACE;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,MACF,EAAE,KAAK,IAAI;AAAA,IACb;AAAA,EACF;AACF;AAEA,IAAO,cAAQ,CAAC,eAAe,kBAAkB,WAAW;",
6
+ "names": []
7
+ }