@dvmkit/sdk 0.0.0 → 0.1.0-rc.1
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.
- package/NOTICE +2 -0
- package/README.md +38 -2
- package/dist/chunk-27V2ILSR.js +291 -0
- package/dist/chunk-5GFED3GJ.js +955 -0
- package/dist/chunk-6JZIX5WW.js +1155 -0
- package/dist/chunk-7IH5SG2A.js +1038 -0
- package/dist/chunk-AT6V3SY7.js +102 -0
- package/dist/chunk-DCNT4PJS.js +733 -0
- package/dist/chunk-DMNLFNTW.js +135 -0
- package/dist/chunk-FROTD5XQ.js +70 -0
- package/dist/chunk-H25M54MI.js +149 -0
- package/dist/chunk-KQAJVVZT.js +712 -0
- package/dist/chunk-KXWROQGK.js +74 -0
- package/dist/chunk-L4OYF4DQ.js +67 -0
- package/dist/chunk-NTK5DJ6R.js +1256 -0
- package/dist/chunk-RPXHKMYE.js +3808 -0
- package/dist/chunk-S3XAHZQY.js +63 -0
- package/dist/chunk-YG7G4DPZ.js +25 -0
- package/dist/credit-ledger-EDMEZSA2.js +28 -0
- package/dist/index.d.ts +144 -0
- package/dist/index.js +303 -0
- package/dist/job-store-C5n6bhap.d.ts +5090 -0
- package/dist/memory-credit-ledger-7TTZDSRS.js +9 -0
- package/dist/mpp-secret-state-WNAQQ6K4.js +127 -0
- package/dist/mpp-setup-MOBWGTWJ.js +30 -0
- package/dist/postgres-consumed-credential-store-VHBT4KEA.js +72 -0
- package/dist/postgres-job-store-J5F4GUWU.js +7 -0
- package/dist/postgres-kv-store-JFBDP5IP.js +7 -0
- package/dist/postgres-replay-store-UJXRT6VO.js +7 -0
- package/dist/pricing-4CEB34RM.js +48 -0
- package/dist/processed-payment-store-HAA4SFNK.js +11 -0
- package/dist/revenue-reporter-M35KP6V7.js +435 -0
- package/dist/server/index.d.ts +4108 -0
- package/dist/server/index.js +22538 -0
- package/dist/ssrf-BdHsrrIb.d.ts +325 -0
- package/dist/tempo-charge-store-6GJEMNUU.js +130 -0
- package/dist/tempo-session-store-FTEEGZXA.js +467 -0
- package/dist/testing/index.d.ts +135 -0
- package/dist/testing/index.js +151 -0
- package/dist/x402-35VLYFKZ.js +1272 -0
- 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
|
+
};
|