@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,435 @@
|
|
|
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
|
+
};
|
|
13
|
+
var RevenueReporter = class _RevenueReporter {
|
|
14
|
+
// 24h of failed retries
|
|
15
|
+
constructor(db, platformUrl, platformToken) {
|
|
16
|
+
this.db = db;
|
|
17
|
+
this.platformUrl = platformUrl;
|
|
18
|
+
this.platformToken = platformToken;
|
|
19
|
+
}
|
|
20
|
+
db;
|
|
21
|
+
platformUrl;
|
|
22
|
+
platformToken;
|
|
23
|
+
retryTimer = null;
|
|
24
|
+
static RETRY_INTERVAL_MS = 3e4;
|
|
25
|
+
static MAX_BACKOFF_MS = 3e5;
|
|
26
|
+
// 5 min
|
|
27
|
+
static ABANDON_AFTER_MS = 24 * 60 * 60 * 1e3;
|
|
28
|
+
/** Create the pending-reports table (idempotent). */
|
|
29
|
+
async init() {
|
|
30
|
+
await withSdkInitLock(this.db, () => this.createTables());
|
|
31
|
+
}
|
|
32
|
+
/** The boot DDL itself — always runs under {@link withSdkInitLock} (internal-review). */
|
|
33
|
+
async createTables() {
|
|
34
|
+
await this.db.query(`
|
|
35
|
+
CREATE TABLE IF NOT EXISTS pending_revenue_reports (
|
|
36
|
+
id TEXT PRIMARY KEY,
|
|
37
|
+
payload JSONB NOT NULL,
|
|
38
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
39
|
+
next_retry_at BIGINT NOT NULL DEFAULT 0,
|
|
40
|
+
stale BOOLEAN NOT NULL DEFAULT false,
|
|
41
|
+
stale_reason TEXT,
|
|
42
|
+
created_at BIGINT NOT NULL,
|
|
43
|
+
kind TEXT NOT NULL DEFAULT 'revenue'
|
|
44
|
+
)
|
|
45
|
+
`);
|
|
46
|
+
await this.db.query(
|
|
47
|
+
"ALTER TABLE pending_revenue_reports ADD COLUMN IF NOT EXISTS stale_reason TEXT"
|
|
48
|
+
);
|
|
49
|
+
await this.db.query(
|
|
50
|
+
"ALTER TABLE pending_revenue_reports ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'revenue'"
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
/** Persist a revenue report and attempt immediate delivery. */
|
|
54
|
+
async report(payload) {
|
|
55
|
+
return this.enqueue("revenue", payload, payload.jobId);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Join a credit deposit to the caller-owned rail transaction.
|
|
59
|
+
*
|
|
60
|
+
* This method performs no network I/O: it only writes the reporter-owned
|
|
61
|
+
* durable queue row through `tx`. The retry drain delivers the row after the
|
|
62
|
+
* rail transaction commits, including after a process restart.
|
|
63
|
+
*/
|
|
64
|
+
async enqueueDeposit(tx, payload) {
|
|
65
|
+
await this.insertPending(tx, "deposit", payload);
|
|
66
|
+
}
|
|
67
|
+
/** Join a released draw to the transaction that made the hold terminal. */
|
|
68
|
+
async enqueueCreditDrawRelease(tx, payload) {
|
|
69
|
+
await this.insertPending(tx, "release", payload);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Join a credit drain to the transaction that made it terminal (internal-review).
|
|
73
|
+
*
|
|
74
|
+
* Same discipline as {@link enqueueDeposit} and the same reason: no network
|
|
75
|
+
* I/O here, only the durable queue row, written through `tx` so the report
|
|
76
|
+
* and the status CAS commit or roll back together. A drain whose row was
|
|
77
|
+
* lost between the two would overstate outstanding liability forever —
|
|
78
|
+
* the mirror image of a lost deposit.
|
|
79
|
+
*
|
|
80
|
+
* Named for the payload rather than the verb because `drain` already means
|
|
81
|
+
* "flush the queue" in this class (see {@link drainPending}).
|
|
82
|
+
*/
|
|
83
|
+
async enqueueCreditDrain(tx, payload) {
|
|
84
|
+
await this.insertPending(tx, "drain", payload);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Report a reaper-force-failed paid job to the platform (internal-review) so the
|
|
88
|
+
* operator receives an ambient notice that a DVM died or wedged mid-job.
|
|
89
|
+
* Best-effort and fire-and-forget: unlike {@link report} there is no local
|
|
90
|
+
* durable queue — a non-2xx or transport failure is structured-logged
|
|
91
|
+
* (`paid_job_death_report_failed`) and dropped, since the reaper's own
|
|
92
|
+
* `stale_job_failed` log is the durable record and a failed *alert* must never
|
|
93
|
+
* wedge the sweep.
|
|
94
|
+
*/
|
|
95
|
+
async reportPaidJobDeath(payload) {
|
|
96
|
+
const url = `${this.platformUrl}/_internal/paid-job-death`;
|
|
97
|
+
try {
|
|
98
|
+
const res = await fetch(url, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: {
|
|
101
|
+
"Content-Type": "application/json",
|
|
102
|
+
Authorization: `Bearer ${this.platformToken}`
|
|
103
|
+
},
|
|
104
|
+
body: JSON.stringify(payload),
|
|
105
|
+
signal: AbortSignal.timeout(1e4)
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
console.warn(
|
|
109
|
+
JSON.stringify({
|
|
110
|
+
level: "paid_job_death_report_failed",
|
|
111
|
+
jobId: payload.jobId,
|
|
112
|
+
status: res.status
|
|
113
|
+
})
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.warn(
|
|
118
|
+
JSON.stringify({
|
|
119
|
+
level: "paid_job_death_report_failed",
|
|
120
|
+
jobId: payload.jobId,
|
|
121
|
+
error: err instanceof Error ? err.message : String(err)
|
|
122
|
+
})
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Report a settled draw whose revenue event was skipped (internal-review).
|
|
128
|
+
* Best-effort and fire-and-forget: the SDK's structured
|
|
129
|
+
* `revenue_skipped_no_rail` log is the durable backstop, so a failed notice
|
|
130
|
+
* must never wedge terminal handling or the orphan-draw reconciler.
|
|
131
|
+
*/
|
|
132
|
+
async reportRevenueSkippedNoRail(payload) {
|
|
133
|
+
const url = `${this.platformUrl}/_internal/revenue-skipped-no-rail`;
|
|
134
|
+
try {
|
|
135
|
+
const res = await fetch(url, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: {
|
|
138
|
+
"Content-Type": "application/json",
|
|
139
|
+
Authorization: `Bearer ${this.platformToken}`
|
|
140
|
+
},
|
|
141
|
+
body: JSON.stringify(payload),
|
|
142
|
+
signal: AbortSignal.timeout(1e4)
|
|
143
|
+
});
|
|
144
|
+
if (!res.ok) {
|
|
145
|
+
console.warn(
|
|
146
|
+
JSON.stringify({
|
|
147
|
+
level: "revenue_skipped_no_rail_report_failed",
|
|
148
|
+
jobId: payload.jobId,
|
|
149
|
+
drawId: payload.drawId,
|
|
150
|
+
reason: payload.reason,
|
|
151
|
+
status: res.status
|
|
152
|
+
})
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.warn(
|
|
157
|
+
JSON.stringify({
|
|
158
|
+
level: "revenue_skipped_no_rail_report_failed",
|
|
159
|
+
jobId: payload.jobId,
|
|
160
|
+
drawId: payload.drawId,
|
|
161
|
+
reason: payload.reason,
|
|
162
|
+
error: err instanceof Error ? err.message : String(err)
|
|
163
|
+
})
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** Report the self-relay's Base gas gauge for platform-side paging and recovery. */
|
|
168
|
+
async reportX402SelfRelayGasBalance(payload) {
|
|
169
|
+
const url = `${this.platformUrl}/_internal/x402-self-relay-gas-balance`;
|
|
170
|
+
try {
|
|
171
|
+
const res = await fetch(url, {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: {
|
|
174
|
+
"Content-Type": "application/json",
|
|
175
|
+
Authorization: `Bearer ${this.platformToken}`
|
|
176
|
+
},
|
|
177
|
+
body: JSON.stringify(payload),
|
|
178
|
+
signal: AbortSignal.timeout(1e4)
|
|
179
|
+
});
|
|
180
|
+
if (!res.ok) {
|
|
181
|
+
console.warn(
|
|
182
|
+
JSON.stringify({
|
|
183
|
+
level: "x402_self_relay_gas_balance_report_failed",
|
|
184
|
+
address: payload.address,
|
|
185
|
+
status: res.status
|
|
186
|
+
})
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
} catch (err) {
|
|
190
|
+
console.warn(
|
|
191
|
+
JSON.stringify({
|
|
192
|
+
level: "x402_self_relay_gas_balance_report_failed",
|
|
193
|
+
address: payload.address,
|
|
194
|
+
error: err instanceof Error ? err.message : String(err)
|
|
195
|
+
})
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** Report redacted self-relay RPC health for platform-side paging and recovery. */
|
|
200
|
+
async reportX402SelfRelayRpcHealth(payload) {
|
|
201
|
+
const url = `${this.platformUrl}/_internal/x402-self-relay-rpc-health`;
|
|
202
|
+
try {
|
|
203
|
+
const res = await fetch(url, {
|
|
204
|
+
method: "POST",
|
|
205
|
+
headers: {
|
|
206
|
+
"Content-Type": "application/json",
|
|
207
|
+
Authorization: `Bearer ${this.platformToken}`
|
|
208
|
+
},
|
|
209
|
+
body: JSON.stringify(payload),
|
|
210
|
+
signal: AbortSignal.timeout(1e4)
|
|
211
|
+
});
|
|
212
|
+
if (!res.ok) {
|
|
213
|
+
console.warn(
|
|
214
|
+
JSON.stringify({
|
|
215
|
+
level: "x402_self_relay_rpc_health_report_failed",
|
|
216
|
+
address: payload.address,
|
|
217
|
+
status: res.status
|
|
218
|
+
})
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
console.warn(
|
|
223
|
+
JSON.stringify({
|
|
224
|
+
level: "x402_self_relay_rpc_health_report_failed",
|
|
225
|
+
address: payload.address,
|
|
226
|
+
reason: "transport"
|
|
227
|
+
})
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** Report the Tempo operator's fee-token gauge for paging and recovery. */
|
|
232
|
+
async reportTempoSettlementBalance(payload) {
|
|
233
|
+
await this.postTempoSettlementReadiness("balance", payload);
|
|
234
|
+
}
|
|
235
|
+
/** Report a redacted operator fee rejection immediately. */
|
|
236
|
+
async reportTempoSettlementFailure(payload) {
|
|
237
|
+
await this.postTempoSettlementReadiness("failure", payload);
|
|
238
|
+
}
|
|
239
|
+
async postTempoSettlementReadiness(kind, payload) {
|
|
240
|
+
const url = `${this.platformUrl}/_internal/tempo-settlement-${kind}`;
|
|
241
|
+
try {
|
|
242
|
+
const res = await fetch(url, {
|
|
243
|
+
method: "POST",
|
|
244
|
+
headers: {
|
|
245
|
+
"Content-Type": "application/json",
|
|
246
|
+
Authorization: `Bearer ${this.platformToken}`
|
|
247
|
+
},
|
|
248
|
+
body: JSON.stringify(payload),
|
|
249
|
+
signal: AbortSignal.timeout(1e4)
|
|
250
|
+
});
|
|
251
|
+
if (!res.ok) {
|
|
252
|
+
console.warn(
|
|
253
|
+
JSON.stringify({
|
|
254
|
+
level: "tempo_settlement_readiness_report_failed",
|
|
255
|
+
kind,
|
|
256
|
+
address: payload.address,
|
|
257
|
+
status: res.status
|
|
258
|
+
})
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
} catch {
|
|
262
|
+
console.warn(
|
|
263
|
+
JSON.stringify({
|
|
264
|
+
level: "tempo_settlement_readiness_report_failed",
|
|
265
|
+
kind,
|
|
266
|
+
address: payload.address,
|
|
267
|
+
reason: "transport"
|
|
268
|
+
})
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/** Start the background retry loop. */
|
|
273
|
+
startRetryLoop() {
|
|
274
|
+
if (this.retryTimer) return;
|
|
275
|
+
this.retryTimer = setInterval(() => {
|
|
276
|
+
void this.drainPending();
|
|
277
|
+
}, _RevenueReporter.RETRY_INTERVAL_MS);
|
|
278
|
+
}
|
|
279
|
+
/** Stop the background retry loop. */
|
|
280
|
+
stop() {
|
|
281
|
+
if (this.retryTimer) {
|
|
282
|
+
clearInterval(this.retryTimer);
|
|
283
|
+
this.retryTimer = null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Operator-facing queue snapshot: distinguishes the live retry queue from
|
|
288
|
+
* terminal (stale) rows and breaks the latter down by classification reason
|
|
289
|
+
* (`token_rotated`, `client_error:<status>`, `abandoned`). Lets monitoring tell
|
|
290
|
+
* transient backlog apart from permanently-failed rows.
|
|
291
|
+
*/
|
|
292
|
+
async getQueueStats() {
|
|
293
|
+
const { rows } = await this.db.query(
|
|
294
|
+
`SELECT stale, stale_reason, COUNT(*)::int AS count
|
|
295
|
+
FROM pending_revenue_reports
|
|
296
|
+
GROUP BY stale, stale_reason`
|
|
297
|
+
);
|
|
298
|
+
let liveRetry = 0;
|
|
299
|
+
let stale = 0;
|
|
300
|
+
const byReason = {};
|
|
301
|
+
for (const row of rows) {
|
|
302
|
+
const count = Number(row.count);
|
|
303
|
+
if (row.stale) {
|
|
304
|
+
stale += count;
|
|
305
|
+
const reason = row.stale_reason ?? "unknown";
|
|
306
|
+
byReason[reason] = (byReason[reason] ?? 0) + count;
|
|
307
|
+
} else {
|
|
308
|
+
liveRetry += count;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return { liveRetry, stale, byReason };
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Deliver one bounded batch from the durable queue.
|
|
315
|
+
*
|
|
316
|
+
* Public so startup/recovery tests and operator tooling can drive the same
|
|
317
|
+
* drain the background timer uses without reaching into private state.
|
|
318
|
+
*/
|
|
319
|
+
async drainPending() {
|
|
320
|
+
try {
|
|
321
|
+
const { rows } = await this.db.query(
|
|
322
|
+
`SELECT id, payload, retry_count, created_at, kind FROM pending_revenue_reports
|
|
323
|
+
WHERE stale = false AND next_retry_at <= $1
|
|
324
|
+
ORDER BY created_at LIMIT 10`,
|
|
325
|
+
[Date.now()]
|
|
326
|
+
);
|
|
327
|
+
for (const row of rows) {
|
|
328
|
+
const label = row.payload.jobId ?? row.payload.creditId ?? row.id;
|
|
329
|
+
try {
|
|
330
|
+
await this.attemptDelivery(row.id, row.payload, row.kind, label);
|
|
331
|
+
} catch {
|
|
332
|
+
const retryCount = row.retry_count + 1;
|
|
333
|
+
if (Date.now() - Number(row.created_at) >= _RevenueReporter.ABANDON_AFTER_MS) {
|
|
334
|
+
await this.markStale(row.id, "abandoned");
|
|
335
|
+
console.warn(
|
|
336
|
+
JSON.stringify({
|
|
337
|
+
level: "revenue_reporter_abandoned",
|
|
338
|
+
id: row.id,
|
|
339
|
+
kind: row.kind,
|
|
340
|
+
retryCount,
|
|
341
|
+
dvmId: row.payload.dvmId,
|
|
342
|
+
label,
|
|
343
|
+
// A drain names its payout rail `method`; every other kind
|
|
344
|
+
// carries `rail`. An abandoned row is the one place an
|
|
345
|
+
// operator sees this payload at all, so don't log it blank.
|
|
346
|
+
rail: row.payload.rail ?? row.payload.method
|
|
347
|
+
})
|
|
348
|
+
);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const backoff = Math.min(
|
|
352
|
+
3e4 * Math.pow(2, retryCount - 1),
|
|
353
|
+
_RevenueReporter.MAX_BACKOFF_MS
|
|
354
|
+
);
|
|
355
|
+
await this.db.query(
|
|
356
|
+
`UPDATE pending_revenue_reports
|
|
357
|
+
SET retry_count = $1, next_retry_at = $2
|
|
358
|
+
WHERE id = $3`,
|
|
359
|
+
[retryCount, Date.now() + backoff, row.id]
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
} catch (err) {
|
|
364
|
+
console.error("[revenue-reporter] Retry loop error:", err);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/** Durably queue one report of either kind, then try it once immediately. */
|
|
368
|
+
async enqueue(kind, payload, label) {
|
|
369
|
+
const id = await this.insertPending(this.db, kind, payload);
|
|
370
|
+
void this.attemptDelivery(id, payload, kind, label).catch(() => {
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
/** Insert one pending report through either the pool or an open transaction. */
|
|
374
|
+
async insertPending(q, kind, payload) {
|
|
375
|
+
const id = randomUUID();
|
|
376
|
+
await q.query(
|
|
377
|
+
`INSERT INTO pending_revenue_reports (id, payload, created_at, kind)
|
|
378
|
+
VALUES ($1, $2, $3, $4)`,
|
|
379
|
+
[id, JSON.stringify(payload), Date.now(), kind]
|
|
380
|
+
);
|
|
381
|
+
return id;
|
|
382
|
+
}
|
|
383
|
+
async attemptDelivery(id, payload, kind, label) {
|
|
384
|
+
const url = `${this.platformUrl}${ENDPOINTS[kind]}`;
|
|
385
|
+
let res;
|
|
386
|
+
try {
|
|
387
|
+
res = await fetch(url, {
|
|
388
|
+
method: "POST",
|
|
389
|
+
headers: {
|
|
390
|
+
"Content-Type": "application/json",
|
|
391
|
+
Authorization: `Bearer ${this.platformToken}`
|
|
392
|
+
},
|
|
393
|
+
body: JSON.stringify(payload),
|
|
394
|
+
signal: AbortSignal.timeout(1e4)
|
|
395
|
+
});
|
|
396
|
+
} catch (err) {
|
|
397
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
398
|
+
console.warn(
|
|
399
|
+
`[revenue-reporter] delivery failed for ${id}: transport error (${reason}) \u2014 will retry`
|
|
400
|
+
);
|
|
401
|
+
throw err;
|
|
402
|
+
}
|
|
403
|
+
if (res.ok) {
|
|
404
|
+
await this.db.query("DELETE FROM pending_revenue_reports WHERE id = $1", [id]);
|
|
405
|
+
console.log(`[revenue-reporter] reported ${kind} ${label}`);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (res.status === 401) {
|
|
409
|
+
await this.markStale(id, "token_rotated");
|
|
410
|
+
console.warn(
|
|
411
|
+
`[revenue-reporter] Report ${id} got 401 \u2014 marking stale (token likely rotated)`
|
|
412
|
+
);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (res.status >= 400 && res.status < 500) {
|
|
416
|
+
await this.markStale(id, `client_error:${res.status}`);
|
|
417
|
+
console.warn(
|
|
418
|
+
`[revenue-reporter] Report ${id} got HTTP ${res.status} \u2014 marking stale (permanent client error, will not retry)`
|
|
419
|
+
);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
console.warn(`[revenue-reporter] delivery failed for ${id}: HTTP ${res.status} \u2014 will retry`);
|
|
423
|
+
throw new Error(`Platform returned ${res.status}`);
|
|
424
|
+
}
|
|
425
|
+
/** Mark a pending row terminal (stale) with a classification reason. */
|
|
426
|
+
async markStale(id, reason) {
|
|
427
|
+
await this.db.query(
|
|
428
|
+
"UPDATE pending_revenue_reports SET stale = true, stale_reason = $1 WHERE id = $2",
|
|
429
|
+
[reason, id]
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
export {
|
|
434
|
+
RevenueReporter
|
|
435
|
+
};
|