@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,72 @@
1
+ import {
2
+ withSdkInitLock
3
+ } from "./chunk-S3XAHZQY.js";
4
+
5
+ // src/sdk/server/postgres-consumed-credential-store.ts
6
+ var DEFAULT_TABLE_NAME = "consumed_credentials";
7
+ var DEFAULT_GC_SAMPLE_RATE = 0.01;
8
+ var VALID_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
9
+ var PostgresConsumedCredentialStore = class {
10
+ pool;
11
+ tableName;
12
+ gcSampleRate;
13
+ now;
14
+ constructor(pool, opts) {
15
+ const tableName = opts?.tableName ?? DEFAULT_TABLE_NAME;
16
+ if (!VALID_IDENTIFIER.test(tableName)) {
17
+ throw new Error(
18
+ `PostgresConsumedCredentialStore: tableName must match ${VALID_IDENTIFIER.source}, got "${tableName}"`
19
+ );
20
+ }
21
+ this.pool = pool;
22
+ this.tableName = tableName;
23
+ this.gcSampleRate = opts?.gcSampleRate ?? DEFAULT_GC_SAMPLE_RATE;
24
+ this.now = opts?.now ?? (() => Date.now());
25
+ }
26
+ /** Run the CREATE TABLE migration. Idempotent — safe to call repeatedly. */
27
+ async init() {
28
+ await withSdkInitLock(this.pool, () => this.createTables());
29
+ }
30
+ /** The boot DDL itself — always runs under {@link withSdkInitLock} (internal-review). */
31
+ async createTables() {
32
+ await this.pool.query(`
33
+ CREATE TABLE IF NOT EXISTS ${this.tableName} (
34
+ realm TEXT NOT NULL,
35
+ challenge_id TEXT NOT NULL,
36
+ expires_at BIGINT NOT NULL,
37
+ PRIMARY KEY (realm, challenge_id)
38
+ );
39
+ CREATE INDEX IF NOT EXISTS ${this.tableName}_expires_at_idx
40
+ ON ${this.tableName} (expires_at);
41
+ `);
42
+ }
43
+ async has(realm, challengeId) {
44
+ const { rowCount } = await this.pool.query(
45
+ `SELECT 1 FROM ${this.tableName}
46
+ WHERE realm = $1 AND challenge_id = $2 AND expires_at > $3`,
47
+ [realm, challengeId, this.now()]
48
+ );
49
+ return (rowCount ?? 0) > 0;
50
+ }
51
+ async mark(realm, challengeId, ttlSeconds) {
52
+ const now = this.now();
53
+ const expiresAt = now + ttlSeconds * 1e3;
54
+ await this.pool.query(
55
+ `INSERT INTO ${this.tableName} (realm, challenge_id, expires_at)
56
+ VALUES ($1, $2, $3)
57
+ ON CONFLICT (realm, challenge_id) DO UPDATE SET expires_at = EXCLUDED.expires_at`,
58
+ [realm, challengeId, expiresAt]
59
+ );
60
+ if (this.gcSampleRate > 0 && Math.random() < this.gcSampleRate) {
61
+ this.gcSweep(now);
62
+ }
63
+ }
64
+ gcSweep(now) {
65
+ this.pool.query(`DELETE FROM ${this.tableName} WHERE expires_at < $1`, [now]).catch((err) => {
66
+ console.error("PostgresConsumedCredentialStore GC sweep failed:", err);
67
+ });
68
+ }
69
+ };
70
+ export {
71
+ PostgresConsumedCredentialStore
72
+ };
@@ -0,0 +1,7 @@
1
+ import {
2
+ PostgresJobStore
3
+ } from "./chunk-6JZIX5WW.js";
4
+ import "./chunk-S3XAHZQY.js";
5
+ export {
6
+ PostgresJobStore
7
+ };
@@ -0,0 +1,7 @@
1
+ import {
2
+ PostgresKVStore
3
+ } from "./chunk-FROTD5XQ.js";
4
+ import "./chunk-S3XAHZQY.js";
5
+ export {
6
+ PostgresKVStore
7
+ };
@@ -0,0 +1,7 @@
1
+ import {
2
+ PostgresReplayStore
3
+ } from "./chunk-L4OYF4DQ.js";
4
+ import "./chunk-S3XAHZQY.js";
5
+ export {
6
+ PostgresReplayStore
7
+ };
@@ -0,0 +1,48 @@
1
+ import {
2
+ DEFAULT_FX_CURRENCIES,
3
+ DEFAULT_FX_RATE_SOURCE,
4
+ FX_CACHE_TTL_MS,
5
+ FX_RETRY_COUNT,
6
+ FxRateUnavailableError,
7
+ InvalidCurrencyError,
8
+ UnsupportedCurrencyError,
9
+ buildUpfront,
10
+ createFxFetcher,
11
+ fxRateFor,
12
+ resolveFxSourceFromEnv,
13
+ validateCurrency,
14
+ warmFxSnapshot
15
+ } from "./chunk-27V2ILSR.js";
16
+ import {
17
+ InvalidFxRateError,
18
+ InvalidUsdPriceError,
19
+ fiatToSatsCeil,
20
+ formatFiat,
21
+ formatUsd,
22
+ parseUsdPrice,
23
+ roundUsd,
24
+ satsToFiat
25
+ } from "./chunk-AT6V3SY7.js";
26
+ export {
27
+ DEFAULT_FX_CURRENCIES,
28
+ DEFAULT_FX_RATE_SOURCE,
29
+ FX_CACHE_TTL_MS,
30
+ FX_RETRY_COUNT,
31
+ FxRateUnavailableError,
32
+ InvalidCurrencyError,
33
+ InvalidFxRateError,
34
+ InvalidUsdPriceError,
35
+ UnsupportedCurrencyError,
36
+ buildUpfront,
37
+ createFxFetcher,
38
+ fiatToSatsCeil,
39
+ formatFiat,
40
+ formatUsd,
41
+ fxRateFor,
42
+ parseUsdPrice,
43
+ resolveFxSourceFromEnv,
44
+ roundUsd,
45
+ satsToFiat,
46
+ validateCurrency,
47
+ warmFxSnapshot
48
+ };
@@ -0,0 +1,11 @@
1
+ import {
2
+ MemoryProcessedPaymentStore,
3
+ PostgresProcessedPaymentStore,
4
+ ProcessedPaymentReplayError
5
+ } from "./chunk-DMNLFNTW.js";
6
+ import "./chunk-S3XAHZQY.js";
7
+ export {
8
+ MemoryProcessedPaymentStore,
9
+ PostgresProcessedPaymentStore,
10
+ ProcessedPaymentReplayError
11
+ };
@@ -0,0 +1,510 @@
1
+ import {
2
+ withSdkInitLock
3
+ } from "./chunk-S3XAHZQY.js";
4
+
5
+ // src/sdk/server/revenue-reporter.ts
6
+ import { randomUUID } from "crypto";
7
+ var ENDPOINTS = {
8
+ revenue: "/_internal/job-revenue",
9
+ deposit: "/_internal/credit-deposit",
10
+ release: "/_internal/credit-draw-release",
11
+ drain: "/_internal/credit-drain",
12
+ payout: "/_internal/payout",
13
+ expiry_release: "/_internal/credit-expiry-release"
14
+ };
15
+ var RevenueReporter = class _RevenueReporter {
16
+ // 24h of failed retries
17
+ constructor(db, platformUrl, platformToken) {
18
+ this.db = db;
19
+ this.platformUrl = platformUrl;
20
+ this.platformToken = platformToken;
21
+ }
22
+ db;
23
+ platformUrl;
24
+ platformToken;
25
+ retryTimer = null;
26
+ static RETRY_INTERVAL_MS = 3e4;
27
+ static MAX_BACKOFF_MS = 3e5;
28
+ // 5 min
29
+ static ABANDON_AFTER_MS = 24 * 60 * 60 * 1e3;
30
+ /** Create the pending-reports table (idempotent). */
31
+ async init() {
32
+ await withSdkInitLock(this.db, () => this.createTables());
33
+ }
34
+ /** The boot DDL itself — always runs under {@link withSdkInitLock} (internal-review). */
35
+ async createTables() {
36
+ await this.db.query(`
37
+ CREATE TABLE IF NOT EXISTS pending_revenue_reports (
38
+ id TEXT PRIMARY KEY,
39
+ payload JSONB NOT NULL,
40
+ retry_count INTEGER NOT NULL DEFAULT 0,
41
+ next_retry_at BIGINT NOT NULL DEFAULT 0,
42
+ stale BOOLEAN NOT NULL DEFAULT false,
43
+ stale_reason TEXT,
44
+ created_at BIGINT NOT NULL,
45
+ kind TEXT NOT NULL DEFAULT 'revenue'
46
+ )
47
+ `);
48
+ await this.db.query(
49
+ "ALTER TABLE pending_revenue_reports ADD COLUMN IF NOT EXISTS stale_reason TEXT"
50
+ );
51
+ await this.db.query(
52
+ "ALTER TABLE pending_revenue_reports ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'revenue'"
53
+ );
54
+ }
55
+ /** Persist a revenue report and attempt immediate delivery. */
56
+ async report(payload) {
57
+ return this.enqueue("revenue", payload, payload.jobId);
58
+ }
59
+ /**
60
+ * Join a credit deposit to the caller-owned rail transaction.
61
+ *
62
+ * This method performs no network I/O: it only writes the reporter-owned
63
+ * durable queue row through `tx`. The retry drain delivers the row after the
64
+ * rail transaction commits, including after a process restart.
65
+ */
66
+ async enqueueDeposit(tx, payload) {
67
+ await this.insertPending(tx, "deposit", payload);
68
+ }
69
+ /** Join a released draw to the transaction that made the hold terminal. */
70
+ async enqueueCreditDrawRelease(tx, payload) {
71
+ await this.insertPending(tx, "release", payload);
72
+ }
73
+ /**
74
+ * Join a credit drain to the transaction that made it terminal (internal-review).
75
+ *
76
+ * Same discipline as {@link enqueueDeposit} and the same reason: no network
77
+ * I/O here, only the durable queue row, written through `tx` so the report
78
+ * and the status CAS commit or roll back together. A drain whose row was
79
+ * lost between the two would overstate outstanding liability forever —
80
+ * the mirror image of a lost deposit.
81
+ *
82
+ * Named for the payload rather than the verb because `drain` already means
83
+ * "flush the queue" in this class (see {@link drainPending}).
84
+ */
85
+ async enqueueCreditDrain(tx, payload) {
86
+ await this.insertPending(tx, "drain", payload);
87
+ }
88
+ /**
89
+ * Join a payout to the transaction that commits the movement it reports
90
+ * (internal-review) — the batch close, the accumulator mark, the drain booking.
91
+ * No network I/O, only the durable queue row through `tx`, the discipline
92
+ * {@link enqueueCreditDrain} set and for the same reason: the money has
93
+ * already moved on a rail this ledger does not own, so the platform row is
94
+ * the only record the builder's dashboard has of it, and a row lost between
95
+ * the fact and the queue would understate "paid out" forever. Without `tx`
96
+ * the row is queued on the pool, for callers with no fact of their own to
97
+ * commit. Idempotent on `(dvmId, payoutId)` platform-side, so the retry
98
+ * loop redelivers freely.
99
+ */
100
+ async enqueuePayout(payload, tx) {
101
+ await this.insertPending(tx ?? this.db, "payout", payload);
102
+ }
103
+ /**
104
+ * Report one landed payout on its own and try to deliver it at once — the
105
+ * live Tempo settlement, which has no dvmkit transaction to join. Durable
106
+ * like {@link report}.
107
+ */
108
+ async reportPayout(payload) {
109
+ return this.enqueue("payout", payload, payload.payoutId);
110
+ }
111
+ /**
112
+ * Report one rail's pending-pool snapshot (internal-review). Best-effort and
113
+ * fire-and-forget, deliberately unlike {@link reportPayout}: a snapshot is
114
+ * replaced by the next tick, so queueing a stale one behind an outage would
115
+ * only deliver figures the platform already has newer ones for.
116
+ */
117
+ async reportPayoutPending(payload) {
118
+ const url = `${this.platformUrl}/_internal/payout-pending`;
119
+ try {
120
+ const res = await fetch(url, {
121
+ method: "POST",
122
+ headers: {
123
+ "Content-Type": "application/json",
124
+ Authorization: `Bearer ${this.platformToken}`
125
+ },
126
+ body: JSON.stringify(payload),
127
+ signal: AbortSignal.timeout(1e4)
128
+ });
129
+ if (!res.ok) {
130
+ console.warn(
131
+ JSON.stringify({
132
+ level: "payout_pending_report_failed",
133
+ rail: payload.rail,
134
+ status: res.status
135
+ })
136
+ );
137
+ }
138
+ } catch (err) {
139
+ console.warn(
140
+ JSON.stringify({
141
+ level: "payout_pending_report_failed",
142
+ rail: payload.rail,
143
+ error: err instanceof Error ? err.message : String(err)
144
+ })
145
+ );
146
+ }
147
+ }
148
+ /**
149
+ * Join a credit-expiry release — or the revival that reverses one — to the
150
+ * transaction that made it true (internal-review).
151
+ *
152
+ * Same discipline as {@link enqueueCreditDrain}: no network I/O, only the
153
+ * durable queue row written through `tx`. A release whose row was lost after
154
+ * the sweep committed would understate committed value forever; a lost
155
+ * reversal would overstate it, which is the worse direction — the builder
156
+ * would see money as theirs that a revived credit can still buy work with.
157
+ */
158
+ async enqueueCreditExpiryRelease(tx, payload) {
159
+ await this.insertPending(tx, "expiry_release", payload);
160
+ }
161
+ /**
162
+ * Report a reaper-force-failed paid job to the platform (internal-review) so the
163
+ * operator receives an ambient notice that a DVM died or wedged mid-job.
164
+ * Best-effort and fire-and-forget: unlike {@link report} there is no local
165
+ * durable queue — a non-2xx or transport failure is structured-logged
166
+ * (`paid_job_death_report_failed`) and dropped, since the reaper's own
167
+ * `stale_job_failed` log is the durable record and a failed *alert* must never
168
+ * wedge the sweep.
169
+ */
170
+ async reportPaidJobDeath(payload) {
171
+ const url = `${this.platformUrl}/_internal/paid-job-death`;
172
+ try {
173
+ const res = await fetch(url, {
174
+ method: "POST",
175
+ headers: {
176
+ "Content-Type": "application/json",
177
+ Authorization: `Bearer ${this.platformToken}`
178
+ },
179
+ body: JSON.stringify(payload),
180
+ signal: AbortSignal.timeout(1e4)
181
+ });
182
+ if (!res.ok) {
183
+ console.warn(
184
+ JSON.stringify({
185
+ level: "paid_job_death_report_failed",
186
+ jobId: payload.jobId,
187
+ status: res.status
188
+ })
189
+ );
190
+ }
191
+ } catch (err) {
192
+ console.warn(
193
+ JSON.stringify({
194
+ level: "paid_job_death_report_failed",
195
+ jobId: payload.jobId,
196
+ error: err instanceof Error ? err.message : String(err)
197
+ })
198
+ );
199
+ }
200
+ }
201
+ /**
202
+ * Report a settled draw whose revenue event was skipped (internal-review).
203
+ * Best-effort and fire-and-forget: the SDK's structured
204
+ * `revenue_skipped_no_rail` log is the durable backstop, so a failed notice
205
+ * must never wedge terminal handling or the orphan-draw reconciler.
206
+ */
207
+ async reportRevenueSkippedNoRail(payload) {
208
+ const url = `${this.platformUrl}/_internal/revenue-skipped-no-rail`;
209
+ try {
210
+ const res = await fetch(url, {
211
+ method: "POST",
212
+ headers: {
213
+ "Content-Type": "application/json",
214
+ Authorization: `Bearer ${this.platformToken}`
215
+ },
216
+ body: JSON.stringify(payload),
217
+ signal: AbortSignal.timeout(1e4)
218
+ });
219
+ if (!res.ok) {
220
+ console.warn(
221
+ JSON.stringify({
222
+ level: "revenue_skipped_no_rail_report_failed",
223
+ jobId: payload.jobId,
224
+ drawId: payload.drawId,
225
+ reason: payload.reason,
226
+ status: res.status
227
+ })
228
+ );
229
+ }
230
+ } catch (err) {
231
+ console.warn(
232
+ JSON.stringify({
233
+ level: "revenue_skipped_no_rail_report_failed",
234
+ jobId: payload.jobId,
235
+ drawId: payload.drawId,
236
+ reason: payload.reason,
237
+ error: err instanceof Error ? err.message : String(err)
238
+ })
239
+ );
240
+ }
241
+ }
242
+ /** Report the self-relay's Base gas gauge for platform-side paging and recovery. */
243
+ async reportX402SelfRelayGasBalance(payload) {
244
+ const url = `${this.platformUrl}/_internal/x402-self-relay-gas-balance`;
245
+ try {
246
+ const res = await fetch(url, {
247
+ method: "POST",
248
+ headers: {
249
+ "Content-Type": "application/json",
250
+ Authorization: `Bearer ${this.platformToken}`
251
+ },
252
+ body: JSON.stringify(payload),
253
+ signal: AbortSignal.timeout(1e4)
254
+ });
255
+ if (!res.ok) {
256
+ console.warn(
257
+ JSON.stringify({
258
+ level: "x402_self_relay_gas_balance_report_failed",
259
+ address: payload.address,
260
+ status: res.status
261
+ })
262
+ );
263
+ }
264
+ } catch (err) {
265
+ console.warn(
266
+ JSON.stringify({
267
+ level: "x402_self_relay_gas_balance_report_failed",
268
+ address: payload.address,
269
+ error: err instanceof Error ? err.message : String(err)
270
+ })
271
+ );
272
+ }
273
+ }
274
+ /** Report redacted self-relay RPC health for platform-side paging and recovery. */
275
+ async reportX402SelfRelayRpcHealth(payload) {
276
+ const url = `${this.platformUrl}/_internal/x402-self-relay-rpc-health`;
277
+ try {
278
+ const res = await fetch(url, {
279
+ method: "POST",
280
+ headers: {
281
+ "Content-Type": "application/json",
282
+ Authorization: `Bearer ${this.platformToken}`
283
+ },
284
+ body: JSON.stringify(payload),
285
+ signal: AbortSignal.timeout(1e4)
286
+ });
287
+ if (!res.ok) {
288
+ console.warn(
289
+ JSON.stringify({
290
+ level: "x402_self_relay_rpc_health_report_failed",
291
+ address: payload.address,
292
+ status: res.status
293
+ })
294
+ );
295
+ }
296
+ } catch {
297
+ console.warn(
298
+ JSON.stringify({
299
+ level: "x402_self_relay_rpc_health_report_failed",
300
+ address: payload.address,
301
+ reason: "transport"
302
+ })
303
+ );
304
+ }
305
+ }
306
+ /** Report the Tempo operator's fee-token gauge for paging and recovery. */
307
+ async reportTempoSettlementBalance(payload) {
308
+ await this.postTempoSettlementReadiness("balance", payload);
309
+ }
310
+ /** Report a redacted operator fee rejection immediately. */
311
+ async reportTempoSettlementFailure(payload) {
312
+ await this.postTempoSettlementReadiness("failure", payload);
313
+ }
314
+ async postTempoSettlementReadiness(kind, payload) {
315
+ const url = `${this.platformUrl}/_internal/tempo-settlement-${kind}`;
316
+ try {
317
+ const res = await fetch(url, {
318
+ method: "POST",
319
+ headers: {
320
+ "Content-Type": "application/json",
321
+ Authorization: `Bearer ${this.platformToken}`
322
+ },
323
+ body: JSON.stringify(payload),
324
+ signal: AbortSignal.timeout(1e4)
325
+ });
326
+ if (!res.ok) {
327
+ console.warn(
328
+ JSON.stringify({
329
+ level: "tempo_settlement_readiness_report_failed",
330
+ kind,
331
+ address: payload.address,
332
+ status: res.status
333
+ })
334
+ );
335
+ }
336
+ } catch {
337
+ console.warn(
338
+ JSON.stringify({
339
+ level: "tempo_settlement_readiness_report_failed",
340
+ kind,
341
+ address: payload.address,
342
+ reason: "transport"
343
+ })
344
+ );
345
+ }
346
+ }
347
+ /** Start the background retry loop. */
348
+ startRetryLoop() {
349
+ if (this.retryTimer) return;
350
+ this.retryTimer = setInterval(() => {
351
+ void this.drainPending();
352
+ }, _RevenueReporter.RETRY_INTERVAL_MS);
353
+ }
354
+ /** Stop the background retry loop. */
355
+ stop() {
356
+ if (this.retryTimer) {
357
+ clearInterval(this.retryTimer);
358
+ this.retryTimer = null;
359
+ }
360
+ }
361
+ /**
362
+ * Operator-facing queue snapshot: distinguishes the live retry queue from
363
+ * terminal (stale) rows and breaks the latter down by classification reason
364
+ * (`token_rotated`, `client_error:<status>`, `abandoned`). Lets monitoring tell
365
+ * transient backlog apart from permanently-failed rows.
366
+ */
367
+ async getQueueStats() {
368
+ const { rows } = await this.db.query(
369
+ `SELECT stale, stale_reason, COUNT(*)::int AS count
370
+ FROM pending_revenue_reports
371
+ GROUP BY stale, stale_reason`
372
+ );
373
+ let liveRetry = 0;
374
+ let stale = 0;
375
+ const byReason = {};
376
+ for (const row of rows) {
377
+ const count = Number(row.count);
378
+ if (row.stale) {
379
+ stale += count;
380
+ const reason = row.stale_reason ?? "unknown";
381
+ byReason[reason] = (byReason[reason] ?? 0) + count;
382
+ } else {
383
+ liveRetry += count;
384
+ }
385
+ }
386
+ return { liveRetry, stale, byReason };
387
+ }
388
+ /**
389
+ * Deliver one bounded batch from the durable queue.
390
+ *
391
+ * Public so startup/recovery tests and operator tooling can drive the same
392
+ * drain the background timer uses without reaching into private state.
393
+ */
394
+ async drainPending() {
395
+ try {
396
+ const { rows } = await this.db.query(
397
+ `SELECT id, payload, retry_count, created_at, kind FROM pending_revenue_reports
398
+ WHERE stale = false AND next_retry_at <= $1
399
+ ORDER BY created_at LIMIT 10`,
400
+ [Date.now()]
401
+ );
402
+ for (const row of rows) {
403
+ const label = row.payload.jobId ?? row.payload.creditId ?? row.payload.payoutId ?? row.id;
404
+ try {
405
+ await this.attemptDelivery(row.id, row.payload, row.kind, label);
406
+ } catch {
407
+ const retryCount = row.retry_count + 1;
408
+ if (Date.now() - Number(row.created_at) >= _RevenueReporter.ABANDON_AFTER_MS) {
409
+ await this.markStale(row.id, "abandoned");
410
+ console.warn(
411
+ JSON.stringify({
412
+ level: "revenue_reporter_abandoned",
413
+ id: row.id,
414
+ kind: row.kind,
415
+ retryCount,
416
+ dvmId: row.payload.dvmId,
417
+ label,
418
+ // A drain names its payout rail `method`; every other kind
419
+ // carries `rail`. An abandoned row is the one place an
420
+ // operator sees this payload at all, so don't log it blank.
421
+ rail: row.payload.rail ?? row.payload.method
422
+ })
423
+ );
424
+ continue;
425
+ }
426
+ const backoff = Math.min(
427
+ 3e4 * Math.pow(2, retryCount - 1),
428
+ _RevenueReporter.MAX_BACKOFF_MS
429
+ );
430
+ await this.db.query(
431
+ `UPDATE pending_revenue_reports
432
+ SET retry_count = $1, next_retry_at = $2
433
+ WHERE id = $3`,
434
+ [retryCount, Date.now() + backoff, row.id]
435
+ );
436
+ }
437
+ }
438
+ } catch (err) {
439
+ console.error("[revenue-reporter] Retry loop error:", err);
440
+ }
441
+ }
442
+ /** Durably queue one report of either kind, then try it once immediately. */
443
+ async enqueue(kind, payload, label) {
444
+ const id = await this.insertPending(this.db, kind, payload);
445
+ void this.attemptDelivery(id, payload, kind, label).catch(() => {
446
+ });
447
+ }
448
+ /** Insert one pending report through either the pool or an open transaction. */
449
+ async insertPending(q, kind, payload) {
450
+ const id = randomUUID();
451
+ await q.query(
452
+ `INSERT INTO pending_revenue_reports (id, payload, created_at, kind)
453
+ VALUES ($1, $2, $3, $4)`,
454
+ [id, JSON.stringify(payload), Date.now(), kind]
455
+ );
456
+ return id;
457
+ }
458
+ async attemptDelivery(id, payload, kind, label) {
459
+ const url = `${this.platformUrl}${ENDPOINTS[kind]}`;
460
+ let res;
461
+ try {
462
+ res = await fetch(url, {
463
+ method: "POST",
464
+ headers: {
465
+ "Content-Type": "application/json",
466
+ Authorization: `Bearer ${this.platformToken}`
467
+ },
468
+ body: JSON.stringify(payload),
469
+ signal: AbortSignal.timeout(1e4)
470
+ });
471
+ } catch (err) {
472
+ const reason = err instanceof Error ? err.message : String(err);
473
+ console.warn(
474
+ `[revenue-reporter] delivery failed for ${id}: transport error (${reason}) \u2014 will retry`
475
+ );
476
+ throw err;
477
+ }
478
+ if (res.ok) {
479
+ await this.db.query("DELETE FROM pending_revenue_reports WHERE id = $1", [id]);
480
+ console.log(`[revenue-reporter] reported ${kind} ${label}`);
481
+ return;
482
+ }
483
+ if (res.status === 401) {
484
+ await this.markStale(id, "token_rotated");
485
+ console.warn(
486
+ `[revenue-reporter] Report ${id} got 401 \u2014 marking stale (token likely rotated)`
487
+ );
488
+ return;
489
+ }
490
+ if (res.status >= 400 && res.status < 500) {
491
+ await this.markStale(id, `client_error:${res.status}`);
492
+ console.warn(
493
+ `[revenue-reporter] Report ${id} got HTTP ${res.status} \u2014 marking stale (permanent client error, will not retry)`
494
+ );
495
+ return;
496
+ }
497
+ console.warn(`[revenue-reporter] delivery failed for ${id}: HTTP ${res.status} \u2014 will retry`);
498
+ throw new Error(`Platform returned ${res.status}`);
499
+ }
500
+ /** Mark a pending row terminal (stale) with a classification reason. */
501
+ async markStale(id, reason) {
502
+ await this.db.query(
503
+ "UPDATE pending_revenue_reports SET stale = true, stale_reason = $1 WHERE id = $2",
504
+ [reason, id]
505
+ );
506
+ }
507
+ };
508
+ export {
509
+ RevenueReporter
510
+ };