@dvmkit/sdk 0.0.0 → 0.1.0-rc.2

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 (42) hide show
  1. package/NOTICE +2 -0
  2. package/README.md +38 -2
  3. package/dist/chunk-27V2ILSR.js +291 -0
  4. package/dist/chunk-365P52XQ.js +4121 -0
  5. package/dist/chunk-5GFED3GJ.js +955 -0
  6. package/dist/chunk-6JZIX5WW.js +1155 -0
  7. package/dist/chunk-7IH5SG2A.js +1038 -0
  8. package/dist/chunk-AT6V3SY7.js +102 -0
  9. package/dist/chunk-DCNT4PJS.js +733 -0
  10. package/dist/chunk-DMNLFNTW.js +135 -0
  11. package/dist/chunk-FROTD5XQ.js +70 -0
  12. package/dist/chunk-H25M54MI.js +149 -0
  13. package/dist/chunk-KQAJVVZT.js +712 -0
  14. package/dist/chunk-KXWROQGK.js +74 -0
  15. package/dist/chunk-L4OYF4DQ.js +67 -0
  16. package/dist/chunk-OJ5WFIB2.js +1266 -0
  17. package/dist/chunk-S3XAHZQY.js +63 -0
  18. package/dist/chunk-YG7G4DPZ.js +25 -0
  19. package/dist/credit-ledger-RO4FGSHG.js +28 -0
  20. package/dist/index.d.ts +144 -0
  21. package/dist/index.js +303 -0
  22. package/dist/job-store-6gR4pZRP.d.ts +5350 -0
  23. package/dist/memory-credit-ledger-I2G64DDK.js +9 -0
  24. package/dist/mpp-secret-state-WNAQQ6K4.js +127 -0
  25. package/dist/mpp-setup-MOBWGTWJ.js +30 -0
  26. package/dist/payout-reporter-4TNWRS5F.js +753 -0
  27. package/dist/postgres-consumed-credential-store-VHBT4KEA.js +72 -0
  28. package/dist/postgres-job-store-J5F4GUWU.js +7 -0
  29. package/dist/postgres-kv-store-JFBDP5IP.js +7 -0
  30. package/dist/postgres-replay-store-UJXRT6VO.js +7 -0
  31. package/dist/pricing-4CEB34RM.js +48 -0
  32. package/dist/processed-payment-store-HAA4SFNK.js +11 -0
  33. package/dist/revenue-reporter-GB4WKLDC.js +510 -0
  34. package/dist/server/index.d.ts +4168 -0
  35. package/dist/server/index.js +22716 -0
  36. package/dist/ssrf-DZi-xJyn.d.ts +325 -0
  37. package/dist/tempo-charge-store-6GJEMNUU.js +130 -0
  38. package/dist/tempo-session-store-FTEEGZXA.js +467 -0
  39. package/dist/testing/index.d.ts +135 -0
  40. package/dist/testing/index.js +151 -0
  41. package/dist/x402-35VLYFKZ.js +1272 -0
  42. package/package.json +89 -6
@@ -0,0 +1,1155 @@
1
+ import {
2
+ withSdkInitLock
3
+ } from "./chunk-S3XAHZQY.js";
4
+
5
+ // src/sdk/server/postgres-job-store.ts
6
+ import { createHash } from "crypto";
7
+
8
+ // src/lib/listen-channel.ts
9
+ var DEFAULT_BACKOFF_MS = [1e3, 4e3, 15e3, 3e4];
10
+ var ListenChannel = class {
11
+ constructor(pool, opts) {
12
+ this.pool = pool;
13
+ this.opts = opts;
14
+ this.backoffMs = opts.backoffMs ?? DEFAULT_BACKOFF_MS;
15
+ }
16
+ pool;
17
+ opts;
18
+ client = null;
19
+ stopped = false;
20
+ reconnectTimer = null;
21
+ reconnectAttempt = 0;
22
+ backoffMs;
23
+ /**
24
+ * Open the LISTEN connection. Resolves once the initial connect attempt
25
+ * has either succeeded or scheduled a retry — never rejects, so callers
26
+ * can fire-and-forget at boot.
27
+ */
28
+ start() {
29
+ this.stopped = false;
30
+ return this.connect();
31
+ }
32
+ /** Release the LISTEN client and cancel any pending reconnect. */
33
+ async stop() {
34
+ this.stopped = true;
35
+ if (this.reconnectTimer) {
36
+ clearTimeout(this.reconnectTimer);
37
+ this.reconnectTimer = null;
38
+ }
39
+ const client = this.client;
40
+ this.client = null;
41
+ if (client) {
42
+ try {
43
+ await client.query("UNLISTEN *");
44
+ } catch {
45
+ }
46
+ try {
47
+ client.release();
48
+ } catch {
49
+ }
50
+ }
51
+ }
52
+ async connect() {
53
+ if (this.stopped) return;
54
+ let client;
55
+ try {
56
+ client = await this.pool.connect();
57
+ } catch (err) {
58
+ this.opts.onReconnectFailed?.(this.reconnectAttempt, err);
59
+ this.scheduleReconnect();
60
+ return;
61
+ }
62
+ let lost = false;
63
+ const onLost = (err) => {
64
+ if (lost || this.stopped) return;
65
+ lost = true;
66
+ if (this.client === client) this.client = null;
67
+ this.opts.onLost?.(err);
68
+ try {
69
+ client.release(err ?? new Error("listen connection lost"));
70
+ } catch {
71
+ }
72
+ this.scheduleReconnect();
73
+ };
74
+ const onEnd = () => {
75
+ onLost();
76
+ };
77
+ client.on("error", onLost);
78
+ client.on("end", onEnd);
79
+ try {
80
+ await client.query(`LISTEN ${this.opts.channel}`);
81
+ } catch (err) {
82
+ if (!lost) {
83
+ lost = true;
84
+ this.opts.onReconnectFailed?.(this.reconnectAttempt, err);
85
+ try {
86
+ client.release(err instanceof Error ? err : new Error(String(err)));
87
+ } catch {
88
+ }
89
+ this.scheduleReconnect();
90
+ }
91
+ return;
92
+ }
93
+ if (lost) return;
94
+ if (this.stopped) {
95
+ lost = true;
96
+ try {
97
+ await client.query("UNLISTEN *");
98
+ } catch {
99
+ }
100
+ try {
101
+ client.release();
102
+ } catch {
103
+ }
104
+ return;
105
+ }
106
+ this.client = client;
107
+ this.reconnectAttempt = 0;
108
+ client.on("notification", (notification) => {
109
+ if (notification.channel !== this.opts.channel) return;
110
+ this.opts.onNotification(notification.payload);
111
+ });
112
+ }
113
+ scheduleReconnect() {
114
+ if (this.stopped) return;
115
+ if (this.reconnectTimer) return;
116
+ const idx = Math.min(this.reconnectAttempt, this.backoffMs.length - 1);
117
+ const delay = this.backoffMs[idx];
118
+ this.reconnectAttempt++;
119
+ this.reconnectTimer = setTimeout(() => {
120
+ this.reconnectTimer = null;
121
+ void this.connect();
122
+ }, delay);
123
+ }
124
+ };
125
+
126
+ // src/sdk/server/postgres-job-store.ts
127
+ var JOB_MESSAGES_CHANNEL = "job_messages";
128
+ var PostgresJobStore = class {
129
+ pool;
130
+ listenChannel = null;
131
+ messageSubscribers = /* @__PURE__ */ new Map();
132
+ notifySubscribers = /* @__PURE__ */ new Map();
133
+ requestIdClaimLocks = /* @__PURE__ */ new Map();
134
+ constructor(pool) {
135
+ this.pool = pool;
136
+ }
137
+ /** Run the CREATE TABLE migration. Call once at startup. */
138
+ async init() {
139
+ await withSdkInitLock(this.pool, () => this.createTables());
140
+ }
141
+ /** The boot DDL itself — always runs under {@link withSdkInitLock} (internal-review). */
142
+ async createTables() {
143
+ await this.pool.query(`
144
+ CREATE TABLE IF NOT EXISTS jobs (
145
+ id TEXT PRIMARY KEY,
146
+ tags JSONB NOT NULL DEFAULT '[]',
147
+ input TEXT NOT NULL,
148
+ params JSONB NOT NULL DEFAULT '{}',
149
+ requester_id TEXT NOT NULL,
150
+ status TEXT NOT NULL DEFAULT 'processing',
151
+ summary TEXT,
152
+ messages JSONB NOT NULL DEFAULT '[]',
153
+ seq INTEGER NOT NULL DEFAULT 0,
154
+ paid_msats BIGINT NOT NULL DEFAULT 0,
155
+ payment_mint TEXT,
156
+ received_proofs JSONB NOT NULL DEFAULT '[]',
157
+ pending_payment_msats BIGINT,
158
+ -- Keeps its mpp spelling past internal-review's rail rename (internal-review): it
159
+ -- holds mppx challenge ids \u2014 objects of the MPP envelope the Tempo rail
160
+ -- rides, not the rail key \u2014 and it is server-internal anti-replay state
161
+ -- (internal-review) that never reaches a caller.
162
+ pending_mpp_challenge_ids JSONB,
163
+ pending_x402_nonce TEXT,
164
+ pending_x402_amount_usdc_micro TEXT,
165
+ step_cache JSONB NOT NULL DEFAULT '[]',
166
+ state JSONB NOT NULL DEFAULT '{}',
167
+ created_at BIGINT NOT NULL,
168
+ last_activity_at BIGINT NOT NULL
169
+ );
170
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS pending_mpp_challenge_ids JSONB;
171
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS pending_x402_nonce TEXT;
172
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS pending_x402_amount_usdc_micro TEXT;
173
+ ALTER TABLE jobs DROP COLUMN IF EXISTS pending_payment_hash;
174
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS payment_rail TEXT;
175
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS payment_tx_hash TEXT;
176
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS payment_transaction_hash TEXT;
177
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS native_amount DOUBLE PRECISION;
178
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS native_asset TEXT;
179
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS cashu_flow TEXT;
180
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS required_msats BIGINT;
181
+ -- next_seq is intentionally absent from isolate_jobs (platform/db.ts).
182
+ -- It powers StreamableJobStore cross-machine seq allocation; isolate
183
+ -- DVMs are single-machine and use basic JobStore only.
184
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS next_seq INTEGER NOT NULL DEFAULT 1;
185
+ -- internal-review: the rail formerly written as 'mpp' is 'tempo'. A live job's
186
+ -- mid-job payment compares this against the incoming rail, and the
187
+ -- receipt reports it.
188
+ UPDATE jobs SET payment_rail = 'tempo' WHERE payment_rail = 'mpp';
189
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS capability TEXT;
190
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS requester_token_hash TEXT;
191
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS requester_token TEXT;
192
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS request_fingerprint TEXT;
193
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS requester_pubkey TEXT;
194
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS request_id TEXT;
195
+ -- internal-review: the route accepted by the live auth gate. Kept outside the
196
+ -- caller-controlled envelope so reactivation cannot derive its expected
197
+ -- route from the proof it is meant to check.
198
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS auth_request_path TEXT;
199
+ -- internal-review: the signed receipt, written once at terminal by saveReceipt,
200
+ -- plus the per-DVM terminal counter value this job holds. Neither is in
201
+ -- save's column list \u2014 a stale snapshot must not be able to clear a
202
+ -- receipt or re-allocate a sequence number.
203
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS receipt JSONB;
204
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS receipt_seq BIGINT;
205
+ -- internal-review: credit-ledger linkage. Written at submission alongside the
206
+ -- payment fields; the terminal funnel settles/releases the draw and the
207
+ -- receipt countersigns the ReceiptCredit block from it.
208
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS credit_id TEXT;
209
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS draw_id TEXT;
210
+ -- internal-review: the original signed funding artifact and the snapshot served
211
+ -- with a fund-and-draw response. INSERT-only for replay stability.
212
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS funding_receipt JSONB;
213
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS funding_credit JSONB;
214
+ -- internal-review: the outstanding mid-job ask, denominated in fiat micro at the
215
+ -- instant it was emitted. The top-up's ledger leg funds and grows the
216
+ -- draw in this figure, so it must not move with the BTC/USD rate between
217
+ -- the ask and the payment (and the paying machine is often not the one
218
+ -- that asked, so it has to be durable rather than handler-local).
219
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS pending_payment_fiat_micro BIGINT;
220
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS pending_payment_fiat_currency TEXT;
221
+ -- internal-review: the same figure accumulated over the job's whole life, which
222
+ -- is what caps mid-job draw growth. It has to be its own column rather
223
+ -- than a read of the one above: Tx C decrements the outstanding figure as
224
+ -- payments land, and a cap that shrank with it would let a duplicate
225
+ -- payment buy the same ask twice.
226
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS asked_topup_micro BIGINT;
227
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS asked_topup_currency TEXT;
228
+ -- internal-review: why the ceiling above is absent, on the jobs where it is. The
229
+ -- durable half of the -1 sentinel \u2014 an uncapped job has to be findable by
230
+ -- query, not by grepping a log line off a machine that may be long gone.
231
+ ALTER TABLE jobs ADD COLUMN IF NOT EXISTS topup_cap_unenforced_reason TEXT;
232
+ DO $migration$
233
+ BEGIN
234
+ IF EXISTS (
235
+ SELECT 1 FROM information_schema.columns
236
+ WHERE table_name = 'jobs' AND column_name = 'result'
237
+ ) THEN
238
+ ALTER TABLE jobs RENAME COLUMN result TO summary;
239
+ END IF;
240
+ END
241
+ $migration$;
242
+ UPDATE jobs SET next_seq = seq + 1 WHERE next_seq <= seq;
243
+ CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status);
244
+ -- internal-review: the idempotent-replay lookup. payment_tx_hash carries the
245
+ -- caller's X-Cashu-Request-Id on the accumulator path, so a retried paid
246
+ -- submit finds the job its payment already created.
247
+ CREATE INDEX IF NOT EXISTS idx_jobs_payment_tx_hash ON jobs (payment_tx_hash);
248
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_request_id_caller
249
+ ON jobs (requester_pubkey, request_id)
250
+ WHERE request_id IS NOT NULL;
251
+
252
+ -- Public request ids keep a permanent identity/job binding. A live
253
+ -- Postgres advisory lock serializes the paid accept path; unlike a held
254
+ -- row, that lock disappears when its owning process or connection dies,
255
+ -- allowing an exact retry to reach the rail's durable idempotency row.
256
+ CREATE TABLE IF NOT EXISTS request_id_claims (
257
+ requester_pubkey TEXT NOT NULL,
258
+ request_id TEXT NOT NULL,
259
+ request_fingerprint TEXT NOT NULL,
260
+ requester_id TEXT NOT NULL,
261
+ job_id TEXT NOT NULL UNIQUE,
262
+ created_at BIGINT NOT NULL,
263
+ updated_at BIGINT NOT NULL,
264
+ PRIMARY KEY (requester_pubkey, request_id)
265
+ );
266
+
267
+ CREATE TABLE IF NOT EXISTS job_messages (
268
+ job_id TEXT NOT NULL,
269
+ seq INTEGER NOT NULL,
270
+ data JSONB NOT NULL,
271
+ status TEXT NOT NULL DEFAULT 'verified',
272
+ PRIMARY KEY (job_id, seq)
273
+ );
274
+ ALTER TABLE job_messages ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'verified';
275
+
276
+ -- internal-review: per-DVM monotonic receipt counter. Single row by
277
+ -- construction (the CHECK pins the key), and per-DVM by construction
278
+ -- too \u2014 the SDK runs one database per DVM (see postgres-kv-store.ts).
279
+ -- Allocated with the same UPDATE \u2026 RETURNING trick as jobs.next_seq so
280
+ -- concurrent machines can't hand two jobs the same number.
281
+ CREATE TABLE IF NOT EXISTS receipt_counter (
282
+ id BOOLEAN PRIMARY KEY DEFAULT TRUE,
283
+ n BIGINT NOT NULL DEFAULT 0,
284
+ CONSTRAINT receipt_counter_singleton CHECK (id)
285
+ );
286
+ INSERT INTO receipt_counter (id, n) VALUES (TRUE, 0) ON CONFLICT (id) DO NOTHING;
287
+ `);
288
+ }
289
+ async get(id) {
290
+ const { rows } = await this.pool.query("SELECT * FROM jobs WHERE id = $1", [id]);
291
+ if (rows.length === 0) return void 0;
292
+ return rowToRecord(rows[0]);
293
+ }
294
+ /**
295
+ * internal-review. Indexed by `idx_jobs_payment_tx_hash`. `paymentTxHash` is unique
296
+ * per settlement in practice — the accumulator's
297
+ * `UNIQUE(dvm_id, request_id, proof_secret)` is what makes a second job under
298
+ * the same request id impossible — but order by `created_at` so a
299
+ * hypothetical duplicate resolves to the job the caller actually paid for.
300
+ */
301
+ async findJobByPaymentTxHash(txHash) {
302
+ const { rows } = await this.pool.query(
303
+ "SELECT * FROM jobs WHERE payment_tx_hash = $1 ORDER BY created_at ASC LIMIT 1",
304
+ [txHash]
305
+ );
306
+ if (rows.length === 0) return void 0;
307
+ return rowToRecord(rows[0]);
308
+ }
309
+ async findJobByRequestId(requestId, requesterPubkey) {
310
+ const { rows } = await this.pool.query(
311
+ `SELECT * FROM jobs
312
+ WHERE requester_pubkey = $1 AND request_id = $2
313
+ ORDER BY created_at ASC
314
+ LIMIT 1`,
315
+ [requesterPubkey, requestId]
316
+ );
317
+ if (rows.length === 0) return void 0;
318
+ return rowToRecord(rows[0]);
319
+ }
320
+ async claimRequestId(claim) {
321
+ return this.acquireRequestIdClaim(claim, true);
322
+ }
323
+ async resumeRequestId(claim) {
324
+ return this.acquireRequestIdClaim(claim, false);
325
+ }
326
+ async acquireRequestIdClaim(claim, createBinding) {
327
+ const client = await this.pool.connect();
328
+ const lockKey = requestIdAdvisoryLockKey(claim.requesterPubkey, claim.requestId);
329
+ let locked = false;
330
+ try {
331
+ const lock = await client.query(
332
+ "SELECT pg_try_advisory_lock($1::bigint) AS acquired",
333
+ [lockKey]
334
+ );
335
+ if (!lock.rows[0]?.acquired) return void 0;
336
+ locked = true;
337
+ const now = Date.now();
338
+ const inserted = createBinding ? await client.query(
339
+ `INSERT INTO request_id_claims (
340
+ requester_pubkey, request_id, request_fingerprint, requester_id,
341
+ job_id, created_at, updated_at
342
+ ) VALUES ($1, $2, $3, $4, $5, $6, $6)
343
+ ON CONFLICT (requester_pubkey, request_id) DO NOTHING
344
+ RETURNING job_id`,
345
+ [
346
+ claim.requesterPubkey,
347
+ claim.requestId,
348
+ claim.requestFingerprint,
349
+ claim.requesterId,
350
+ claim.jobId,
351
+ now
352
+ ]
353
+ ) : { rowCount: 0, rows: [] };
354
+ let result;
355
+ if (inserted.rowCount === 1) {
356
+ result = { jobId: inserted.rows[0].job_id, replayed: false };
357
+ } else {
358
+ const existing = await client.query(
359
+ `SELECT job_id, request_fingerprint, requester_id
360
+ FROM request_id_claims
361
+ WHERE requester_pubkey = $1 AND request_id = $2`,
362
+ [claim.requesterPubkey, claim.requestId]
363
+ );
364
+ const row = existing.rows.at(0);
365
+ if (row?.request_fingerprint === claim.requestFingerprint && row.requester_id === claim.requesterId) {
366
+ result = { jobId: row.job_id, replayed: true };
367
+ }
368
+ }
369
+ if (!result) return void 0;
370
+ this.requestIdClaimLocks.set(claim.claimToken, {
371
+ client,
372
+ lockKey,
373
+ requesterPubkey: claim.requesterPubkey,
374
+ requestId: claim.requestId
375
+ });
376
+ return result;
377
+ } finally {
378
+ if (!this.requestIdClaimLocks.has(claim.claimToken)) {
379
+ let discard = false;
380
+ if (locked) {
381
+ try {
382
+ await client.query("SELECT pg_advisory_unlock($1::bigint)", [lockKey]);
383
+ } catch {
384
+ discard = true;
385
+ }
386
+ }
387
+ client.release(discard);
388
+ }
389
+ }
390
+ }
391
+ async releaseRequestIdClaim(claim) {
392
+ const held = this.requestIdClaimLocks.get(claim.claimToken);
393
+ if (held?.requesterPubkey !== claim.requesterPubkey || held.requestId !== claim.requestId) {
394
+ return;
395
+ }
396
+ this.requestIdClaimLocks.delete(claim.claimToken);
397
+ try {
398
+ const unlocked = await held.client.query(
399
+ "SELECT pg_advisory_unlock($1::bigint) AS unlocked",
400
+ [held.lockKey]
401
+ );
402
+ if (!unlocked.rows[0]?.unlocked) {
403
+ throw new Error("request-id advisory lock was not owned by this connection");
404
+ }
405
+ } catch (err) {
406
+ held.client.release(true);
407
+ throw err;
408
+ }
409
+ held.client.release();
410
+ }
411
+ async save(record) {
412
+ const { rowCount } = await this.pool.query(
413
+ `INSERT INTO jobs (
414
+ id, tags, capability, input, params, requester_id, status, summary,
415
+ messages, seq, paid_msats, payment_mint, payment_rail,
416
+ payment_tx_hash, payment_transaction_hash, native_amount, native_asset, cashu_flow,
417
+ received_proofs, pending_payment_msats, pending_mpp_challenge_ids,
418
+ pending_x402_nonce, pending_x402_amount_usdc_micro, required_msats, step_cache, state, created_at,
419
+ last_activity_at, next_seq, requester_token_hash, requester_token,
420
+ request_fingerprint, requester_pubkey, request_id, credit_id, draw_id,
421
+ pending_payment_fiat_micro, pending_payment_fiat_currency, auth_request_path,
422
+ funding_receipt, funding_credit
423
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40,$41)
424
+ ON CONFLICT (id) DO UPDATE SET
425
+ status = EXCLUDED.status,
426
+ summary = EXCLUDED.summary,
427
+ messages = EXCLUDED.messages,
428
+ -- GREATEST keeps seq monotonic: a stale snapshot from a machine
429
+ -- whose in-memory job.seq is behind the DB high-water mark must
430
+ -- not regress jobs.seq. See internal-review review feedback.
431
+ seq = GREATEST(jobs.seq, EXCLUDED.seq),
432
+ payment_mint = EXCLUDED.payment_mint,
433
+ payment_rail = EXCLUDED.payment_rail,
434
+ payment_tx_hash = EXCLUDED.payment_tx_hash,
435
+ payment_transaction_hash = COALESCE(jobs.payment_transaction_hash, EXCLUDED.payment_transaction_hash),
436
+ native_amount = EXCLUDED.native_amount,
437
+ native_asset = EXCLUDED.native_asset,
438
+ cashu_flow = EXCLUDED.cashu_flow,
439
+ received_proofs = EXCLUDED.received_proofs,
440
+ pending_mpp_challenge_ids = EXCLUDED.pending_mpp_challenge_ids,
441
+ pending_x402_nonce = EXCLUDED.pending_x402_nonce,
442
+ pending_x402_amount_usdc_micro = EXCLUDED.pending_x402_amount_usdc_micro,
443
+ required_msats = EXCLUDED.required_msats,
444
+ step_cache = EXCLUDED.step_cache,
445
+ state = EXCLUDED.state,
446
+ last_activity_at = EXCLUDED.last_activity_at
447
+ -- internal-review: terminal is terminal. On a multi-machine DVM a DELETE can
448
+ -- land on a machine that isn't running the handler, which cancels the
449
+ -- job in the store only; the handler's machine never learns of it and
450
+ -- would otherwise persist completed over the cancelled row. Once a row is
451
+ -- terminal, only a re-save of the SAME terminal status lands (an
452
+ -- idempotent re-persist of the same outcome); a different status is
453
+ -- rejected wholesale, so the row can't end up as a Frankenstein of a
454
+ -- cancelled status and a completed snapshot.
455
+ WHERE jobs.status NOT IN ('completed', 'failed', 'cancelled')
456
+ OR jobs.status = EXCLUDED.status`,
457
+ [
458
+ record.id,
459
+ JSON.stringify(record.tags),
460
+ record.capability,
461
+ record.input,
462
+ JSON.stringify(record.params),
463
+ record.requesterId,
464
+ record.status,
465
+ record.summary ?? null,
466
+ JSON.stringify(record.messages),
467
+ record.seq,
468
+ record.paidMsats,
469
+ record.paymentMint ?? null,
470
+ record.paymentRail ?? null,
471
+ record.paymentTxHash ?? null,
472
+ record.paymentTransactionHash ?? null,
473
+ record.nativeAmount ?? null,
474
+ record.nativeAsset ?? null,
475
+ record.cashuFlow ?? null,
476
+ JSON.stringify(record.receivedProofs),
477
+ record.pendingPaymentMsats ?? null,
478
+ record.pendingMppChallengeIds ? JSON.stringify(record.pendingMppChallengeIds) : null,
479
+ record.pendingX402Nonce ?? null,
480
+ record.pendingX402AmountUsdcMicro ?? null,
481
+ record.requiredMsats ?? null,
482
+ JSON.stringify(record.stepCache),
483
+ JSON.stringify(record.state),
484
+ record.createdAt,
485
+ record.lastActivityAt,
486
+ Math.max(record.seq + 1, 1),
487
+ record.requesterTokenHash ?? null,
488
+ // internal-review: submission-time provenance. Like requester_token_hash,
489
+ // these are INSERT-only — the ON CONFLICT branch above never touches
490
+ // them, so a later snapshot save can't rewrite what the caller must
491
+ // reproduce to recover the job.
492
+ record.requesterToken ?? null,
493
+ record.requestFingerprint ?? null,
494
+ record.requesterPubkey ?? null,
495
+ record.requestId ?? null,
496
+ // internal-review: ledger linkage — INSERT-only for the same reason.
497
+ record.creditId ?? null,
498
+ record.drawId ?? null,
499
+ // internal-review: the pinned mid-job ask. INSERT-only, exactly like its
500
+ // companion `pending_payment_msats` — both are owned by the
501
+ // transactional paths (`appendOutgoing` sets them, `verifyAndCredit`
502
+ // clears them), so a stale snapshot must not be able to reset one
503
+ // while the other stands.
504
+ record.pendingPaymentFiatMicro ?? null,
505
+ record.pendingPaymentFiatCurrency ?? null,
506
+ // internal-review: submission provenance, INSERT-only with the fingerprint
507
+ // and caller identity above. Snapshot saves cannot rewrite the route
508
+ // whose live gate accepted this proof.
509
+ record.authRequestPath ?? null,
510
+ record.fundingReceipt ? JSON.stringify(record.fundingReceipt) : null,
511
+ record.fundingCredit ? JSON.stringify(record.fundingCredit) : null
512
+ ]
513
+ );
514
+ if (rowCount === 0) {
515
+ console.warn(
516
+ JSON.stringify({
517
+ level: "job_save_rejected_terminal",
518
+ jobId: record.id,
519
+ attemptedStatus: record.status
520
+ })
521
+ );
522
+ return;
523
+ }
524
+ if (record.status === "completed" || record.status === "failed" || record.status === "cancelled") {
525
+ await this.pool.query("DELETE FROM job_messages WHERE job_id = $1", [record.id]).catch(() => void 0);
526
+ }
527
+ }
528
+ async delete(id) {
529
+ await this.pool.query("DELETE FROM jobs WHERE id = $1", [id]);
530
+ await this.pool.query("DELETE FROM job_messages WHERE job_id = $1", [id]).catch(() => void 0);
531
+ }
532
+ async claimReceiptSeq(jobId) {
533
+ const client = await this.pool.connect();
534
+ try {
535
+ await client.query("BEGIN");
536
+ const { rows } = await client.query(
537
+ "SELECT receipt_seq FROM jobs WHERE id = $1 FOR UPDATE",
538
+ [jobId]
539
+ );
540
+ if (rows.length === 0) {
541
+ await client.query("ROLLBACK");
542
+ throw new Error(`claimReceiptSeq: job ${jobId} not found`);
543
+ }
544
+ const existing = rows[0].receipt_seq;
545
+ if (existing != null) {
546
+ await client.query("ROLLBACK");
547
+ return Number(existing);
548
+ }
549
+ const { rows: counterRows } = await client.query(
550
+ "UPDATE receipt_counter SET n = n + 1 WHERE id = TRUE RETURNING n"
551
+ );
552
+ const seq = Number(counterRows[0].n);
553
+ await client.query("UPDATE jobs SET receipt_seq = $2 WHERE id = $1", [jobId, seq]);
554
+ await client.query("COMMIT");
555
+ return seq;
556
+ } catch (err) {
557
+ try {
558
+ await client.query("ROLLBACK");
559
+ } catch {
560
+ }
561
+ throw err;
562
+ } finally {
563
+ client.release();
564
+ }
565
+ }
566
+ async saveReceipt(jobId, receipt) {
567
+ const { rows } = await this.pool.query(
568
+ "UPDATE jobs SET receipt = COALESCE(receipt, $2::jsonb) WHERE id = $1 RETURNING receipt",
569
+ [jobId, JSON.stringify(receipt)]
570
+ );
571
+ if (rows.length === 0) return void 0;
572
+ return rows[0].receipt;
573
+ }
574
+ // ── Tx A / Tx B / Tx C (internal-review) ────────────────────────────────────
575
+ async appendOutgoing(jobId, message, opts) {
576
+ const client = await this.pool.connect();
577
+ try {
578
+ await client.query("BEGIN");
579
+ const { rows } = await client.query(
580
+ "UPDATE jobs SET next_seq = next_seq + 1, last_activity_at = $2 WHERE id = $1 RETURNING (next_seq - 1) AS seq",
581
+ [jobId, Date.now()]
582
+ );
583
+ if (rows.length === 0) {
584
+ await client.query("ROLLBACK");
585
+ throw new Error(`appendOutgoing: job ${jobId} not found`);
586
+ }
587
+ const seq = rows[0].seq;
588
+ const fullMessage = { ...message, seq };
589
+ await client.query(
590
+ "INSERT INTO job_messages (job_id, seq, data, status) VALUES ($1, $2, $3, 'verified')",
591
+ [jobId, seq, JSON.stringify(fullMessage)]
592
+ );
593
+ if (opts?.pendingPaymentDelta !== void 0) {
594
+ await client.query(
595
+ `UPDATE jobs SET
596
+ pending_payment_msats = COALESCE(pending_payment_msats, 0) + $2,
597
+ pending_mpp_challenge_ids = $3,
598
+ pending_x402_nonce = $4,
599
+ pending_x402_amount_usdc_micro = $5,
600
+ -- internal-review: accumulate the fiat ask alongside the msat one when the
601
+ -- currency matches what's already outstanding, replace when it
602
+ -- doesn't. A NULL micro clears both \u2014 an ask that couldn't be
603
+ -- priced in fiat must not leave a stale figure the top-up would
604
+ -- then fund against.
605
+ pending_payment_fiat_micro = CASE
606
+ WHEN $6::bigint IS NULL THEN NULL
607
+ WHEN pending_payment_fiat_currency IS NOT DISTINCT FROM $7 THEN
608
+ COALESCE(pending_payment_fiat_micro, 0) + $6
609
+ ELSE $6
610
+ END,
611
+ pending_payment_fiat_currency = CASE WHEN $6::bigint IS NULL THEN NULL ELSE $7 END,
612
+ -- internal-review: the cumulative ask total, the ceiling on this job's
613
+ -- draw growth. Same accumulate rule as above, minus the reset \u2014
614
+ -- it is monotonic by definition. Mirrors accumulateAskedTopUp
615
+ -- in job-store.ts; edit the two together. -1 is the sticky "can't
616
+ -- bound anything" sentinel: it leaves asks missing from the sum,
617
+ -- so a ceiling built on it would clip a legitimate payment.
618
+ asked_topup_micro = CASE
619
+ WHEN asked_topup_micro = -1 THEN -1
620
+ WHEN $6::bigint IS NULL THEN -1
621
+ WHEN asked_topup_micro IS NULL THEN $6
622
+ WHEN asked_topup_currency IS NOT DISTINCT FROM $7 THEN asked_topup_micro + $6
623
+ ELSE -1
624
+ END,
625
+ -- First-write-wins, and internal-review made that load-bearing: this is
626
+ -- the job's own ask denomination, read back by ctx.requestPayment
627
+ -- so every later ask pins in it. The ELSE -1 arm above is the
628
+ -- backstop for a row an older build wrote, not a live path.
629
+ asked_topup_currency = COALESCE(asked_topup_currency, $7),
630
+ -- internal-review: the sticky reason the ceiling is gone, mirroring the
631
+ -- sentinel's own stickiness through COALESCE. NULL arms mean "no
632
+ -- new poison" \u2014 an already-poisoned row keeps its first reason.
633
+ topup_cap_unenforced_reason = COALESCE(topup_cap_unenforced_reason, CASE
634
+ WHEN asked_topup_micro = -1 THEN NULL
635
+ WHEN $6::bigint IS NULL THEN 'ask_unpriceable'
636
+ WHEN asked_topup_micro IS NULL THEN NULL
637
+ WHEN asked_topup_currency IS NOT DISTINCT FROM $7 THEN NULL
638
+ ELSE 'ask_currency_switch'
639
+ END)
640
+ WHERE id = $1`,
641
+ [
642
+ jobId,
643
+ opts.pendingPaymentDelta,
644
+ opts.pendingMppChallengeIds ? JSON.stringify(opts.pendingMppChallengeIds) : null,
645
+ opts.pendingX402Nonce ?? null,
646
+ opts.pendingX402AmountUsdcMicro ?? null,
647
+ opts.pendingPaymentFiatMicro ?? null,
648
+ opts.pendingPaymentFiatCurrency ?? null
649
+ ]
650
+ );
651
+ }
652
+ await client.query("SELECT pg_notify('job_messages', $1)", [jobId]);
653
+ await client.query("COMMIT");
654
+ return seq;
655
+ } catch (err) {
656
+ try {
657
+ await client.query("ROLLBACK");
658
+ } catch {
659
+ }
660
+ throw err;
661
+ } finally {
662
+ client.release();
663
+ }
664
+ }
665
+ async recordInbound(jobId, message) {
666
+ const client = await this.pool.connect();
667
+ try {
668
+ await client.query("BEGIN");
669
+ const { rows } = await client.query(
670
+ "UPDATE jobs SET next_seq = next_seq + 1, last_activity_at = $2 WHERE id = $1 RETURNING (next_seq - 1) AS seq",
671
+ [jobId, Date.now()]
672
+ );
673
+ if (rows.length === 0) {
674
+ await client.query("ROLLBACK");
675
+ throw new Error(`recordInbound: job ${jobId} not found`);
676
+ }
677
+ const seq = rows[0].seq;
678
+ const fullMessage = { ...message, seq };
679
+ await client.query(
680
+ "INSERT INTO job_messages (job_id, seq, data, status) VALUES ($1, $2, $3, 'pending-verification')",
681
+ [jobId, seq, JSON.stringify(fullMessage)]
682
+ );
683
+ await client.query("SELECT pg_notify('job_messages', $1)", [jobId]);
684
+ await client.query("COMMIT");
685
+ return seq;
686
+ } catch (err) {
687
+ try {
688
+ await client.query("ROLLBACK");
689
+ } catch {
690
+ }
691
+ throw err;
692
+ } finally {
693
+ client.release();
694
+ }
695
+ }
696
+ async verifyAndCredit(jobId, seq, credit) {
697
+ const client = await this.pool.connect();
698
+ try {
699
+ await client.query("BEGIN");
700
+ const { rowCount } = await client.query(
701
+ "UPDATE job_messages SET status='verified' WHERE job_id = $1 AND seq = $2 AND status = 'pending-verification'",
702
+ [jobId, seq]
703
+ );
704
+ if (!rowCount) {
705
+ const counters2 = await this.fetchCountersTx(client, jobId);
706
+ const binding2 = await this.fetchCreditBindingTx(client, jobId);
707
+ await client.query("COMMIT");
708
+ if (!counters2) throw new Error(`verifyAndCredit: job ${jobId} not found`);
709
+ return { counters: counters2, alreadyVerified: true, ...binding2 };
710
+ }
711
+ let counters;
712
+ let binding = {};
713
+ if (credit) {
714
+ const sameRail = credit.paymentRail !== void 0 && credit.accumulateNative === true;
715
+ const params = [
716
+ jobId,
717
+ credit.paidMsatsDelta,
718
+ credit.clearPending ?? false,
719
+ credit.paymentMint ?? null,
720
+ credit.paymentRail ?? null,
721
+ credit.paymentTxHash ?? null,
722
+ credit.nativeAmount ?? null,
723
+ credit.nativeAsset ?? null,
724
+ credit.cashuFlow ?? null,
725
+ sameRail,
726
+ credit.clearPendingX402Binding ?? false,
727
+ Date.now(),
728
+ credit.creditId ?? null,
729
+ credit.drawId ?? null
730
+ ];
731
+ const { rows } = await client.query(
732
+ `UPDATE jobs SET
733
+ paid_msats = paid_msats + $2,
734
+ pending_payment_msats = CASE
735
+ WHEN $3::boolean THEN NULL
736
+ ELSE GREATEST(0, COALESCE(pending_payment_msats, 0) - $2)
737
+ END,
738
+ -- internal-review: the pinned fiat ask dies with the msat ask it denominates.
739
+ -- Cleared outright rather than decremented: a partially-paid ask keeps
740
+ -- its original fiat envelope so the remainder still funds at the rate
741
+ -- the caller was quoted.
742
+ pending_payment_fiat_micro = CASE
743
+ WHEN $3::boolean OR pending_payment_msats IS NULL OR pending_payment_msats <= $2
744
+ THEN NULL ELSE pending_payment_fiat_micro END,
745
+ pending_payment_fiat_currency = CASE
746
+ WHEN $3::boolean OR pending_payment_msats IS NULL OR pending_payment_msats <= $2
747
+ THEN NULL ELSE pending_payment_fiat_currency END,
748
+ -- Set-if-null: a mid-job top-up that minted a fresh implicit credit
749
+ -- binds it here; a job that already has one keeps it, because the
750
+ -- top-up grew that draw rather than opening a second one.
751
+ credit_id = COALESCE(credit_id, $13),
752
+ draw_id = COALESCE(draw_id, $14),
753
+ payment_mint = COALESCE($4, payment_mint),
754
+ payment_rail = COALESCE($5, payment_rail),
755
+ payment_tx_hash = COALESCE($6, payment_tx_hash),
756
+ native_amount = CASE
757
+ WHEN $7::double precision IS NULL THEN native_amount
758
+ WHEN $10::boolean THEN COALESCE(native_amount, 0) + $7
759
+ ELSE $7
760
+ END,
761
+ native_asset = COALESCE($8, native_asset),
762
+ cashu_flow = COALESCE($9, cashu_flow),
763
+ pending_x402_nonce = CASE WHEN $11::boolean THEN NULL ELSE pending_x402_nonce END,
764
+ pending_x402_amount_usdc_micro = CASE WHEN $11::boolean THEN NULL ELSE pending_x402_amount_usdc_micro END,
765
+ last_activity_at = $12
766
+ WHERE id = $1
767
+ RETURNING status, paid_msats, pending_payment_msats, credit_id, draw_id`,
768
+ params
769
+ );
770
+ if (rows.length === 0) {
771
+ await client.query("ROLLBACK");
772
+ throw new Error(`verifyAndCredit: job ${jobId} not found`);
773
+ }
774
+ counters = {
775
+ status: rows[0].status,
776
+ paidMsats: Number(rows[0].paid_msats),
777
+ pendingPaymentMsats: rows[0].pending_payment_msats == null ? void 0 : Number(rows[0].pending_payment_msats)
778
+ };
779
+ binding = {
780
+ ...rows[0].credit_id !== null && { creditId: rows[0].credit_id },
781
+ ...rows[0].draw_id !== null && { drawId: rows[0].draw_id }
782
+ };
783
+ } else {
784
+ counters = await this.fetchCountersTx(client, jobId);
785
+ if (!counters) {
786
+ await client.query("ROLLBACK");
787
+ throw new Error(`verifyAndCredit: job ${jobId} not found`);
788
+ }
789
+ binding = await this.fetchCreditBindingTx(client, jobId);
790
+ }
791
+ await client.query("SELECT pg_notify('job_messages', $1)", [jobId]);
792
+ await client.query("COMMIT");
793
+ return { counters, alreadyVerified: false, ...binding };
794
+ } catch (err) {
795
+ try {
796
+ await client.query("ROLLBACK");
797
+ } catch {
798
+ }
799
+ throw err;
800
+ } finally {
801
+ client.release();
802
+ }
803
+ }
804
+ async getVerifiedInbound(jobId, seq) {
805
+ const { rows } = await this.pool.query(
806
+ `SELECT j.status, j.paid_msats, j.pending_payment_msats, j.summary, j.credit_id, j.draw_id
807
+ FROM jobs j
808
+ JOIN job_messages m ON m.job_id = j.id AND m.seq = $2
809
+ WHERE j.id = $1 AND m.status = 'verified'`,
810
+ [jobId, seq]
811
+ );
812
+ if (rows.length === 0) return void 0;
813
+ const row = rows[0];
814
+ return {
815
+ counters: rowToCounters(row),
816
+ alreadyVerified: true,
817
+ ...row.credit_id !== null && { creditId: row.credit_id },
818
+ ...row.draw_id !== null && { drawId: row.draw_id }
819
+ };
820
+ }
821
+ async markInboundFailed(jobId, seq, _reason) {
822
+ await this.pool.query(
823
+ "UPDATE job_messages SET status='failed-verify' WHERE job_id = $1 AND seq = $2 AND status = 'pending-verification'",
824
+ [jobId, seq]
825
+ );
826
+ }
827
+ async findStaleJobs(processingThresholdMs, awaitingThresholdMs, limit, capabilities) {
828
+ const staleClause = `(
829
+ ((status IN ('processing', 'working')) AND last_activity_at <= $1)
830
+ OR (status = 'awaiting-input' AND last_activity_at <= $2)
831
+ )`;
832
+ const sql = capabilities === null ? `SELECT * FROM jobs
833
+ WHERE ${staleClause}
834
+ ORDER BY last_activity_at ASC
835
+ LIMIT $3` : `SELECT * FROM jobs
836
+ WHERE ${staleClause}
837
+ AND capability = ANY($4::text[])
838
+ ORDER BY last_activity_at ASC
839
+ LIMIT $3`;
840
+ const params = capabilities === null ? [processingThresholdMs, awaitingThresholdMs, limit] : [processingThresholdMs, awaitingThresholdMs, limit, capabilities];
841
+ const { rows } = await this.pool.query(sql, params);
842
+ return rows.map((r) => rowToRecord(r));
843
+ }
844
+ async heartbeatActiveJobs(jobIds, now) {
845
+ if (jobIds.length === 0) return;
846
+ await this.pool.query(
847
+ `UPDATE jobs SET last_activity_at = $1
848
+ WHERE id = ANY($2::text[]) AND status IN ('processing', 'working')`,
849
+ [now, jobIds]
850
+ );
851
+ }
852
+ cancelStaleJob(jobId, expectedActivityBefore, reason, terminalStatus) {
853
+ return this.terminateJob(jobId, reason, terminalStatus, expectedActivityBefore);
854
+ }
855
+ cancelJob(jobId, reason) {
856
+ return this.terminateJob(jobId, reason, "cancelled", null);
857
+ }
858
+ async getCounters(jobId) {
859
+ const { rows } = await this.pool.query(
860
+ "SELECT status, paid_msats, pending_payment_msats, summary FROM jobs WHERE id = $1",
861
+ [jobId]
862
+ );
863
+ if (rows.length === 0) return void 0;
864
+ return rowToCounters(rows[0]);
865
+ }
866
+ async claimForProcessing(jobId) {
867
+ const { rowCount } = await this.pool.query(
868
+ "UPDATE jobs SET status='processing', last_activity_at=$2 WHERE id=$1 AND status='awaiting-input'",
869
+ [jobId, Date.now()]
870
+ );
871
+ return rowCount === 1;
872
+ }
873
+ async tryReactivationLock(jobId, fn) {
874
+ const client = await this.pool.connect();
875
+ try {
876
+ await client.query("BEGIN");
877
+ const { rows } = await client.query(
878
+ "SELECT pg_try_advisory_xact_lock(hashtext($1)) AS got",
879
+ [jobId]
880
+ );
881
+ if (!rows[0]?.got) {
882
+ await client.query("ROLLBACK");
883
+ return { acquired: false };
884
+ }
885
+ try {
886
+ const result = await fn();
887
+ await client.query("COMMIT");
888
+ return { acquired: true, result };
889
+ } catch (err) {
890
+ await client.query("ROLLBACK");
891
+ throw err;
892
+ }
893
+ } finally {
894
+ client.release();
895
+ }
896
+ }
897
+ // ── Streaming ─────────────────────────────────────────────────────────
898
+ async subscribeMessages(jobId, afterSeq, onMessage) {
899
+ const sub = { afterSeq, cb: onMessage };
900
+ let subs = this.messageSubscribers.get(jobId);
901
+ if (!subs) {
902
+ subs = /* @__PURE__ */ new Set();
903
+ this.messageSubscribers.set(jobId, subs);
904
+ }
905
+ subs.add(sub);
906
+ const existing = await this.getMessages(jobId, afterSeq);
907
+ for (const msg of existing) {
908
+ if (msg.seq > sub.afterSeq) {
909
+ sub.afterSeq = msg.seq;
910
+ onMessage(msg);
911
+ }
912
+ }
913
+ const subSet = subs;
914
+ return () => {
915
+ subSet.delete(sub);
916
+ if (subSet.size === 0) this.messageSubscribers.delete(jobId);
917
+ };
918
+ }
919
+ subscribeNotifications(jobId, onNotify) {
920
+ const sub = { cb: onNotify };
921
+ let subs = this.notifySubscribers.get(jobId);
922
+ if (!subs) {
923
+ subs = /* @__PURE__ */ new Set();
924
+ this.notifySubscribers.set(jobId, subs);
925
+ }
926
+ subs.add(sub);
927
+ const subSet = subs;
928
+ return Promise.resolve(() => {
929
+ subSet.delete(sub);
930
+ if (subSet.size === 0) this.notifySubscribers.delete(jobId);
931
+ });
932
+ }
933
+ async getMessages(jobId, afterSeq) {
934
+ const { rows } = await this.pool.query(
935
+ "SELECT data FROM job_messages WHERE job_id = $1 AND seq > $2 AND status = 'verified' ORDER BY seq",
936
+ [jobId, afterSeq]
937
+ );
938
+ return rows.map((r) => typeof r.data === "string" ? JSON.parse(r.data) : r.data);
939
+ }
940
+ async initStreaming() {
941
+ this.listenChannel = new ListenChannel(this.pool, {
942
+ channel: JOB_MESSAGES_CHANNEL,
943
+ onNotification: (payload) => {
944
+ this.dispatch(payload);
945
+ },
946
+ onLost: (err) => {
947
+ console.warn(
948
+ JSON.stringify({
949
+ level: "job_messages_listen_lost",
950
+ error: err ? err.message : "client closed"
951
+ })
952
+ );
953
+ },
954
+ onReconnectFailed: (attempt, err) => {
955
+ console.warn(
956
+ JSON.stringify({
957
+ level: "job_messages_listen_connect_failed",
958
+ attempt,
959
+ error: err instanceof Error ? err.message : String(err)
960
+ })
961
+ );
962
+ }
963
+ });
964
+ await this.listenChannel.start();
965
+ }
966
+ async shutdownStreaming() {
967
+ if (this.listenChannel) {
968
+ await this.listenChannel.stop();
969
+ this.listenChannel = null;
970
+ }
971
+ this.messageSubscribers.clear();
972
+ this.notifySubscribers.clear();
973
+ }
974
+ dispatch(jobId) {
975
+ if (!jobId) return;
976
+ const notifySubs = this.notifySubscribers.get(jobId);
977
+ if (notifySubs) {
978
+ for (const sub of notifySubs) sub.cb();
979
+ }
980
+ const subs = this.messageSubscribers.get(jobId);
981
+ if (!subs || subs.size === 0) return;
982
+ const minSeq = Math.min(...[...subs].map((s) => s.afterSeq));
983
+ this.getMessages(jobId, minSeq).then((msgs) => {
984
+ for (const sub of subs) {
985
+ for (const msg of msgs) {
986
+ if (msg.seq > sub.afterSeq) {
987
+ sub.afterSeq = msg.seq;
988
+ sub.cb(msg);
989
+ }
990
+ }
991
+ }
992
+ }).catch(() => void 0);
993
+ }
994
+ // ── Internal ────────────────────────────────────────────────────────
995
+ /**
996
+ * Drive a non-terminal job to `terminalStatus` and append its final `cancel`
997
+ * message in one transaction. Backs both `cancelStaleJob` (which passes an
998
+ * `expectedActivityBefore` cutoff, so the flip only lands while the row is
999
+ * still stale — a live worker that heartbeats in between keeps its job) and
1000
+ * `cancelJob` (which passes `null`: an operator/caller cancel is
1001
+ * unconditional, gated only on the row still being non-terminal).
1002
+ *
1003
+ * The `SELECT … FOR UPDATE` makes the whole thing a CAS: two machines racing
1004
+ * to terminate the same job serialise on the row lock, and the loser's
1005
+ * status filter no longer matches. `pg_notify` fires inside the tx so the
1006
+ * machine running the handler wakes as soon as the terminal status is
1007
+ * visible.
1008
+ */
1009
+ async terminateJob(jobId, reason, terminalStatus, expectedActivityBefore) {
1010
+ const staleOnly = expectedActivityBefore !== null;
1011
+ const client = await this.pool.connect();
1012
+ try {
1013
+ await client.query("BEGIN");
1014
+ const { rows } = await client.query(
1015
+ `SELECT next_seq FROM jobs
1016
+ WHERE id = $1
1017
+ AND status IN ('processing', 'working', 'awaiting-input')
1018
+ ${staleOnly ? "AND last_activity_at <= $2" : ""}
1019
+ FOR UPDATE`,
1020
+ staleOnly ? [jobId, expectedActivityBefore] : [jobId]
1021
+ );
1022
+ if (rows.length === 0) {
1023
+ await client.query("ROLLBACK");
1024
+ return false;
1025
+ }
1026
+ const nextSeq = rows[0].next_seq;
1027
+ const now = Date.now();
1028
+ const cancelMsg = {
1029
+ seq: nextSeq,
1030
+ from: "provider",
1031
+ timestamp: Math.floor(now / 1e3),
1032
+ type: "cancel",
1033
+ content: { reason }
1034
+ };
1035
+ await client.query(
1036
+ "INSERT INTO job_messages (job_id, seq, data, status) VALUES ($1, $2, $3, 'verified')",
1037
+ [jobId, nextSeq, JSON.stringify(cancelMsg)]
1038
+ );
1039
+ await client.query(
1040
+ `UPDATE jobs SET
1041
+ status = $6,
1042
+ summary = COALESCE(summary, $2),
1043
+ messages = COALESCE(messages, '[]'::jsonb) || $3::jsonb,
1044
+ seq = $4,
1045
+ next_seq = $4 + 1,
1046
+ last_activity_at = $5
1047
+ WHERE id = $1`,
1048
+ [jobId, reason, JSON.stringify([cancelMsg]), nextSeq, now, terminalStatus]
1049
+ );
1050
+ await client.query("SELECT pg_notify('job_messages', $1)", [jobId]);
1051
+ await client.query("COMMIT");
1052
+ } catch (err) {
1053
+ try {
1054
+ await client.query("ROLLBACK");
1055
+ } catch {
1056
+ }
1057
+ throw err;
1058
+ } finally {
1059
+ client.release();
1060
+ }
1061
+ await this.pool.query("DELETE FROM job_messages WHERE job_id = $1", [jobId]).catch(() => void 0);
1062
+ return true;
1063
+ }
1064
+ async fetchCountersTx(client, jobId) {
1065
+ const { rows } = await client.query(
1066
+ "SELECT status, paid_msats, pending_payment_msats, summary FROM jobs WHERE id = $1",
1067
+ [jobId]
1068
+ );
1069
+ if (rows.length === 0) return void 0;
1070
+ return rowToCounters(rows[0]);
1071
+ }
1072
+ /**
1073
+ * The credit binding on the job row, read inside Tx C (internal-review). Used on the
1074
+ * paths that don't get it from a `RETURNING` — the `alreadyVerified` skip and
1075
+ * a non-payment inbound.
1076
+ */
1077
+ async fetchCreditBindingTx(client, jobId) {
1078
+ const { rows } = await client.query(
1079
+ "SELECT credit_id, draw_id FROM jobs WHERE id = $1",
1080
+ [jobId]
1081
+ );
1082
+ if (rows.length === 0) return {};
1083
+ const row = rows[0];
1084
+ return {
1085
+ ...row.credit_id !== null && { creditId: row.credit_id },
1086
+ ...row.draw_id !== null && { drawId: row.draw_id }
1087
+ };
1088
+ }
1089
+ };
1090
+ function requestIdAdvisoryLockKey(requesterPubkey, requestId) {
1091
+ return createHash("sha256").update(requesterPubkey, "utf8").update("\0", "utf8").update(requestId, "utf8").digest().readBigInt64BE(0).toString();
1092
+ }
1093
+ function rowToCounters(row) {
1094
+ return {
1095
+ status: row.status,
1096
+ paidMsats: Number(row.paid_msats),
1097
+ pendingPaymentMsats: row.pending_payment_msats == null ? void 0 : Number(row.pending_payment_msats),
1098
+ summary: row.summary ?? void 0
1099
+ };
1100
+ }
1101
+ function rowToRecord(row) {
1102
+ return {
1103
+ id: row.id,
1104
+ tags: row.tags,
1105
+ // Pre-internal-review rows persisted before this migration shipped may carry NULL
1106
+ // here. Fall back to "" so reactivation lookups surface a clear
1107
+ // "unknown capability" error rather than crashing on a typed read.
1108
+ capability: row.capability ?? "",
1109
+ input: row.input,
1110
+ params: row.params,
1111
+ requesterId: row.requester_id,
1112
+ requesterTokenHash: row.requester_token_hash ?? void 0,
1113
+ requesterToken: row.requester_token ?? void 0,
1114
+ requestFingerprint: row.request_fingerprint ?? void 0,
1115
+ requesterPubkey: row.requester_pubkey ?? void 0,
1116
+ requestId: row.request_id ?? void 0,
1117
+ authRequestPath: row.auth_request_path ?? void 0,
1118
+ status: row.status,
1119
+ summary: row.summary,
1120
+ messages: row.messages,
1121
+ seq: row.seq,
1122
+ paidMsats: Number(row.paid_msats),
1123
+ paymentMint: row.payment_mint,
1124
+ paymentRail: row.payment_rail ?? void 0,
1125
+ paymentTxHash: row.payment_tx_hash,
1126
+ paymentTransactionHash: row.payment_transaction_hash ?? void 0,
1127
+ nativeAmount: row.native_amount,
1128
+ nativeAsset: row.native_asset,
1129
+ cashuFlow: row.cashu_flow,
1130
+ creditId: row.credit_id ?? void 0,
1131
+ drawId: row.draw_id ?? void 0,
1132
+ fundingReceipt: row.funding_receipt ?? void 0,
1133
+ fundingCredit: row.funding_credit ?? void 0,
1134
+ receivedProofs: row.received_proofs,
1135
+ pendingPaymentMsats: row.pending_payment_msats == null ? void 0 : Number(row.pending_payment_msats),
1136
+ pendingMppChallengeIds: row.pending_mpp_challenge_ids ?? void 0,
1137
+ pendingX402Nonce: row.pending_x402_nonce ?? void 0,
1138
+ pendingX402AmountUsdcMicro: row.pending_x402_amount_usdc_micro ?? void 0,
1139
+ pendingPaymentFiatMicro: row.pending_payment_fiat_micro == null ? void 0 : Number(row.pending_payment_fiat_micro),
1140
+ pendingPaymentFiatCurrency: row.pending_payment_fiat_currency ?? void 0,
1141
+ askedTopUpMicro: row.asked_topup_micro == null ? void 0 : Number(row.asked_topup_micro),
1142
+ askedTopUpCurrency: row.asked_topup_currency ?? void 0,
1143
+ topUpCapUnenforcedReason: row.topup_cap_unenforced_reason ?? void 0,
1144
+ requiredMsats: row.required_msats == null ? void 0 : Number(row.required_msats),
1145
+ receipt: row.receipt ?? void 0,
1146
+ stepCache: row.step_cache,
1147
+ state: row.state,
1148
+ createdAt: Number(row.created_at),
1149
+ lastActivityAt: Number(row.last_activity_at)
1150
+ };
1151
+ }
1152
+
1153
+ export {
1154
+ PostgresJobStore
1155
+ };