@dvmkit/sdk 0.5.0-rc.1 → 0.6.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.
Files changed (32) hide show
  1. package/README.md +100 -0
  2. package/dist/chunk-AFV77S4C.js +258 -0
  3. package/dist/chunk-BQJIFRAZ.js +36 -0
  4. package/dist/{chunk-PB4FYZET.js → chunk-EQLA4FFY.js} +54 -4
  5. package/dist/{chunk-TZ2WKCDD.js → chunk-HCLVYZUZ.js} +2 -2
  6. package/dist/{chunk-EYTHFFFU.js → chunk-HHBDBMEO.js} +159 -34
  7. package/dist/{chunk-O5AT6S5T.js → chunk-IV6Q7NRW.js} +25 -195
  8. package/dist/{chunk-Z4KLECAC.js → chunk-N5RZ5HVB.js} +1 -1
  9. package/dist/{chunk-TUA7PYH7.js → chunk-OQA7IHI4.js} +124 -124
  10. package/dist/{chunk-6DOGTAHA.js → chunk-QR2EF2NI.js} +9 -43
  11. package/dist/{chunk-A4YFWZ7R.js → chunk-RCM3P2HE.js} +1 -1
  12. package/dist/content-retention-BTGSQRfP.d.ts +78 -0
  13. package/dist/{credit-menu-CAfqe8IY.d.ts → credit-menu-BAsrRu3z.d.ts} +98 -5
  14. package/dist/{fx-D9nsN3n4.d.ts → fx-BgDmyTdd.d.ts} +1 -1
  15. package/dist/index.d.ts +5 -5
  16. package/dist/index.js +3 -3
  17. package/dist/internal/caller.d.ts +16 -15
  18. package/dist/internal/caller.js +7 -5
  19. package/dist/internal/server.d.ts +8 -99
  20. package/dist/internal/server.js +12 -9
  21. package/dist/{job-store-Bx-VFRjg.d.ts → job-store-BVeF5_Jq.d.ts} +1 -1
  22. package/dist/{lightning-backend-BAx2Y8pP.d.ts → lightning-backend-40fIzz1W.d.ts} +1 -1
  23. package/dist/{postgres-consumed-credential-store-CUQJKRSL.js → postgres-consumed-credential-store-BUVCAPLS.js} +13 -0
  24. package/dist/{postgres-job-store-VLXLWXUQ.js → postgres-job-store-VHLQBJZC.js} +3 -1
  25. package/dist/server/index.d.ts +28 -10
  26. package/dist/server/index.js +22 -9
  27. package/dist/{step-cache-QG_h5-3G.d.ts → step-cache-CPdH1hei.d.ts} +2 -2
  28. package/dist/testing/index.d.ts +9 -2
  29. package/dist/testing/index.js +6 -3
  30. package/dist/{usd-EwRqCPF-.d.ts → usd-AWlrwlM5.d.ts} +1 -1
  31. package/package.json +3 -2
  32. package/recipes/postgres/financial-export-v1.sql +137 -0
package/README.md CHANGED
@@ -96,6 +96,12 @@ For Bitcoin credit monitoring, `GET /admin/credit/bitcoin-liability` reports exa
96
96
 
97
97
  The public [SDK guide](https://dvmkit.com/docs/sdk) covers signed requests, custom routes, rail configuration, credit reclaim, persistence, and deployment in depth. The [protocol guide](https://dvmkit.com/docs/protocol) specifies the HTTP contract.
98
98
 
99
+ ## Diagnostic retention
100
+
101
+ The SDK advertises 730 days of retention for sanitized diagnostics in every existing span dataset: `platform`, `cashu`, `lightning`, `stripe`, `x402`, `tempo`, `isolate`, `audit`, and `cron`. Dataset names, producer permissions, and query semantics remain unchanged; only operator services may write to `audit`.
102
+
103
+ This metadata describes the retention schedule. The SDK does not automatically delete diagnostics or override platform destruction or case controls. The platform must enforce the schedule and those controls.
104
+
99
105
  ## Repository development
100
106
 
101
107
  Use the Node.js release in `.nvmrc` (Node 22). Install with `npm ci`, then run the same gate CI runs with `npm run verify`. The gate checks repository metadata in the checkout, copies only tracked and unignored source into an isolated directory, installs its locked dependencies, and runs lint, build, typecheck (including the release scripts under `checkJs`), unit and database tests, API, package, consumer, packed-artifact safety, and release checks there. The clean source tree owns the complete build and test pass, so verification never depends on the checkout's `node_modules` or pre-existing `dist` and does not repeat the full suite; after every check passes, its verified `dist` replaces the checkout's build output for release packaging.
@@ -182,3 +188,97 @@ None of this claims exclusive rights in the phrase "digital vending machine" or
182
188
  ## License
183
189
 
184
190
  Apache-2.0. See [LICENSE](./LICENSE) and [NOTICE](./NOTICE).
191
+
192
+ ## Job content retention and case holds
193
+
194
+ `configureDVM` defaults `jobRetentionDays` to 730. Set a shorter or longer whole-day
195
+ window explicitly, or `0` to disable automatic age expiry. Only completed, failed,
196
+ and cancelled jobs are eligible. The terminal `lastActivityAt` is frozen: reads,
197
+ receipt delivery, late messages, and re-saving an outcome do not renew it. The
198
+ sweep removes input, params, summary, messages, state, caches, and bearer proofs;
199
+ it preserves durable status, signed receipts, payment amounts, and recovery linkage.
200
+
201
+ Authenticated job submit, request-result, status and JSON message responses expose
202
+ `content_expires_at` (epoch milliseconds or `null`) and `content_available` (boolean).
203
+ A null deadline means the job is active, expiry is disabled, or an active hold
204
+ suppresses it. A past deadline can still have available content until the sweep
205
+ runs. Missing fields on an older service or unsupported custom store mean unknown.
206
+ SSE messages retain their existing event format; read job status for expiry.
207
+
208
+ With durable service-admin configuration, use signed `Authorization: P2PK`
209
+ requests on the service-local routes:
210
+
211
+ | Method | Path | JSON body |
212
+ | --- | --- | --- |
213
+ | GET | `/admin/jobs/:jobId/content-holds` | none |
214
+ | POST | `/admin/jobs/:jobId/content-holds` | `purpose`, `reason`, `review_due_at` |
215
+ | POST | `/admin/jobs/:jobId/content-holds/:holdId/renew` | `reason`, `review_due_at` |
216
+ | POST | `/admin/jobs/:jobId/content-holds/:holdId/clear` | `reason` |
217
+
218
+ Purpose is `support` or `dispute`; reason is 1–1000 characters and review dates
219
+ are future epoch millisecond integers. Unknown body fields are rejected. Hold IDs,
220
+ actor public keys, and creation/clear times are server-derived. Authentication uses
221
+ the existing method/path/service-bound admin challenge, current and grace-period
222
+ keys, and atomic durable nonce consumption. A hold request does not grant access
223
+ to job content. Authentication failure returns 401 with a reason; an unsupported
224
+ store returns `content_holds_unavailable` (503), missing jobs return 404, and a
225
+ redacted job or inactive hold returns 409. Holds cannot restore redacted content.
226
+
227
+ Responses include `job_id`, caller content metadata, `baseline_content_expires_at`,
228
+ `holds`, and append-only `events`. Each hold contains `id`, `job_id`, `purpose`,
229
+ `reason`, original `actor`, `created_at`, `review_due_at`, `cleared_at`, and computed
230
+ `review_overdue`. Each event contains `id`, `hold_id`, `job_id`, `action`, `actor`,
231
+ `reason`, `at`, `review_due_at`. Renew and clear preserve the original actor and
232
+ creation date; their authenticated actors are in history. Any active hold suppresses
233
+ the effective deadline. Overdue review flags work; it does not release the hold.
234
+ The baseline remains visible for review. Clearing the last hold restores the
235
+ baseline and can make content immediately eligible for the next sweep.
236
+
237
+ `PostgresJobStore.init()` adds `job_content_holds` and `job_content_hold_events`
238
+ with job-scoped indexes; existing jobs need no expiry column or backfill. Placement
239
+ and sweep lock the same job row and check redaction/terminal activity/active holds
240
+ inside the transaction. Admin operations refuse jobs bound to another service;
241
+ legacy unbound rows belong to the local service database. A committed hold wins any later competing sweep; if
242
+ redaction wins first, placement fails. `MemoryJobStore` supplies the same contract
243
+ for development. Custom stores must implement `ContentHoldStore` and atomic
244
+ redaction fencing to support holds and truthful metadata.
245
+
246
+ Before explicitly destroying a service database, use your existing database
247
+ connection/proxy and the packaged `recipes/postgres/financial-export-v1.sql`:
248
+
249
+ ```sh
250
+ psql "$DATABASE_URL" -X -q -t -A -v ON_ERROR_STOP=1 \
251
+ -f node_modules/@dvmkit/sdk/recipes/postgres/financial-export-v1.sql \
252
+ > financial-evidence.json
253
+ ```
254
+
255
+ The versioned JSON export takes a consistent read-only snapshot and includes signed
256
+ receipt tuples, ledger balances/amounts/currencies, job/payment links, and recovery
257
+ outcomes. Funding lots preserve every deposit's original sats and credit basis,
258
+ including depleted lots. Draws retain both the original allocation and settled
259
+ amount. Refunds retain fulfilment costs and funding-lot linkage. Optional channel
260
+ projections preserve amounts and settlement links while excluding authorization
261
+ signatures and vouchers; Tempo bigint amounts are decimal strings.
262
+ Unsigned JSON blocks use typed financial projections: unsupported fields and
263
+ objects or arrays in scalar positions are excluded. Optional ledger tables absent from a service become empty arrays. It
264
+ excludes raw input/output, bearer tokens/proofs, payout descriptors, and operational
265
+ credentials. Signed receipts may retain their signed terminal reason and result
266
+ hash; supported version-1 job, funding, and drain receipts are returned unchanged.
267
+ Unsupported receipt versions, kinds, fields, or nested extensions fail the entire
268
+ export before any JSON is emitted. Update the recipe for that schema before
269
+ destruction; a failed export is not retained financial evidence. Verify receipt
270
+ signatures and check the file before destruction, then keep it securely with this recipe
271
+ and the service's public receipt-key attestation. The builder owns this export;
272
+ the platform retains its separate financial history. An export does not settle
273
+ caller balances or refunds: resolve liabilities through their existing lifecycle.
274
+ Case holds affect age expiry, not explicit erasure or contract/lifecycle destruction.
275
+ The active-service window does not extend any contractual maximum for copies after
276
+ explicit destruction, including a 90-day post-destruction limit where applicable.
277
+
278
+ The wire fields and routes are additive within protocol version 1. Existing explicit
279
+ retention settings retain their value. The new default also applies to existing
280
+ unredacted terminal rows; previously removed content stays unavailable. Upgrade
281
+ all replicas before relying on holds: older SDKs do not recognize the hold fence.
282
+ Do not downgrade or mix old replicas with active holds unless automatic sweeping
283
+ is disabled on those replicas. The SQL export format is versioned independently
284
+ of the package and is not a raw database backup or a content archive.
@@ -0,0 +1,258 @@
1
+ import {
2
+ StepCache
3
+ } from "./chunk-BQJIFRAZ.js";
4
+
5
+ // src/sdk/server/job.ts
6
+ function toJobRecord(job) {
7
+ return {
8
+ id: job.id,
9
+ tags: job.tags,
10
+ capability: job.capability,
11
+ input: job.input,
12
+ params: job.params,
13
+ requesterId: job.requesterId,
14
+ requesterTokenHash: job.requesterTokenHash,
15
+ requesterToken: job.requesterToken,
16
+ requestFingerprint: job.requestFingerprint,
17
+ requesterPubkey: job.requesterPubkey,
18
+ requestId: job.requestId,
19
+ authRequestPath: job.authRequestPath,
20
+ status: job.status,
21
+ summary: job.summary,
22
+ timingKnown: job.timingKnown,
23
+ endedBy: job.endedBy,
24
+ terminalAtMs: job.terminalAtMs,
25
+ callerWaitMs: job.callerWaitMs,
26
+ awaitingInputAtMs: job.awaitingInputAtMs,
27
+ messages: job.messages,
28
+ seq: job.seq,
29
+ paidMsats: job.paidMsats,
30
+ paymentMint: job.paymentMint,
31
+ paymentRail: job.paymentRail,
32
+ paymentTxHash: job.paymentTxHash,
33
+ paymentTransactionHash: job.paymentTransactionHash,
34
+ nativeAmount: job.nativeAmount,
35
+ nativeAsset: job.nativeAsset,
36
+ cashuFlow: job.cashuFlow,
37
+ costAmountMicro: job.costAmountMicro,
38
+ costCurrency: job.costCurrency,
39
+ costRevision: job.costRevision,
40
+ creditId: job.creditId,
41
+ drawId: job.drawId,
42
+ fundingReceipt: job.fundingReceipt,
43
+ fundingCredit: job.fundingCredit,
44
+ receivedProofs: job.receivedProofs,
45
+ pendingPaymentMsats: job.pendingPaymentMsats,
46
+ pendingMppChallengeIds: job.pendingMppChallengeIds,
47
+ pendingX402Nonce: job.pendingX402Nonce,
48
+ pendingX402AmountUsdcMicro: job.pendingX402AmountUsdcMicro,
49
+ pendingPaymentFiatMicro: job.pendingPaymentFiatMicro,
50
+ pendingPaymentFiatCurrency: job.pendingPaymentFiatCurrency,
51
+ askedTopUpMicro: job.askedTopUpMicro,
52
+ askedTopUpCurrency: job.askedTopUpCurrency,
53
+ topUpCapUnenforcedReason: job.topUpCapUnenforcedReason,
54
+ requiredMsats: job.requiredMsats,
55
+ receipt: job.receipt,
56
+ stepCache: job.stepCache.serialize(),
57
+ state: job.state,
58
+ createdAt: job.createdAt,
59
+ lastActivityAt: job.lastActivityAt
60
+ };
61
+ }
62
+ function fromJobRecord(record) {
63
+ return {
64
+ id: record.id,
65
+ tags: record.tags,
66
+ capability: record.capability,
67
+ input: record.input,
68
+ params: record.params,
69
+ requesterId: record.requesterId,
70
+ requesterTokenHash: record.requesterTokenHash,
71
+ status: record.status,
72
+ summary: record.summary,
73
+ timingKnown: record.timingKnown,
74
+ endedBy: record.endedBy,
75
+ terminalAtMs: record.terminalAtMs,
76
+ callerWaitMs: record.callerWaitMs,
77
+ awaitingInputAtMs: record.awaitingInputAtMs,
78
+ messages: [...record.messages],
79
+ seq: record.seq,
80
+ paidMsats: record.paidMsats,
81
+ paymentMint: record.paymentMint,
82
+ paymentRail: record.paymentRail,
83
+ paymentTxHash: record.paymentTxHash,
84
+ paymentTransactionHash: record.paymentTransactionHash,
85
+ nativeAmount: record.nativeAmount,
86
+ nativeAsset: record.nativeAsset,
87
+ cashuFlow: record.cashuFlow,
88
+ costAmountMicro: record.costAmountMicro,
89
+ costCurrency: record.costCurrency,
90
+ costRevision: record.costRevision,
91
+ creditId: record.creditId,
92
+ drawId: record.drawId,
93
+ fundingReceipt: record.fundingReceipt,
94
+ fundingCredit: record.fundingCredit,
95
+ receivedProofs: [...record.receivedProofs],
96
+ pendingPaymentMsats: record.pendingPaymentMsats,
97
+ pendingMppChallengeIds: record.pendingMppChallengeIds ? [...record.pendingMppChallengeIds] : void 0,
98
+ requesterToken: record.requesterToken,
99
+ requestFingerprint: record.requestFingerprint,
100
+ requesterPubkey: record.requesterPubkey,
101
+ requestId: record.requestId,
102
+ authRequestPath: record.authRequestPath,
103
+ pendingX402Nonce: record.pendingX402Nonce,
104
+ pendingX402AmountUsdcMicro: record.pendingX402AmountUsdcMicro,
105
+ pendingPaymentFiatMicro: record.pendingPaymentFiatMicro,
106
+ pendingPaymentFiatCurrency: record.pendingPaymentFiatCurrency,
107
+ askedTopUpMicro: record.askedTopUpMicro,
108
+ askedTopUpCurrency: record.askedTopUpCurrency,
109
+ topUpCapUnenforcedReason: record.topUpCapUnenforcedReason,
110
+ requiredMsats: record.requiredMsats,
111
+ receipt: record.receipt,
112
+ listeners: /* @__PURE__ */ new Set(),
113
+ abort: new AbortController(),
114
+ messageAppender: void 0,
115
+ messageAppenderTail: void 0,
116
+ stepCache: StepCache.deserialize(record.stepCache),
117
+ state: record.state,
118
+ pendingPrompts: /* @__PURE__ */ new Map(),
119
+ pendingPayment: null,
120
+ replayHighSeq: null,
121
+ replayProviderSkip: 0,
122
+ replayPaymentIndex: 0,
123
+ createdAt: record.createdAt,
124
+ lastActivityAt: record.lastActivityAt
125
+ };
126
+ }
127
+ function abortJob(job, reason) {
128
+ job.abort.abort(new JobCancelledError(reason));
129
+ }
130
+ var JobCancelledError = class extends Error {
131
+ constructor(message = "Job cancelled") {
132
+ super(message);
133
+ this.name = "JobCancelledError";
134
+ }
135
+ };
136
+ function providerMessage(job, type, content) {
137
+ if (job.replayProviderSkip > 0) {
138
+ job.replayProviderSkip--;
139
+ return;
140
+ }
141
+ const msg = localMessage(job, "provider", type, content);
142
+ if (type === "payment-request") {
143
+ const paymentRequest = content;
144
+ const pendingPaymentDelta = typeof paymentRequest.amount_msats === "number" ? paymentRequest.amount_msats : void 0;
145
+ const pendingMppChallengeIds = paymentRequest.tempo?.challenges?.map((challenge) => challenge.id).filter((id) => typeof id === "string");
146
+ job.messageAppender?.(msg, {
147
+ awaitingInput: job.status === "awaiting-input",
148
+ pendingPaymentDelta,
149
+ pendingMppChallengeIds,
150
+ pendingX402Nonce: typeof paymentRequest.x402?.nonce === "string" ? paymentRequest.x402.nonce : void 0,
151
+ pendingX402AmountUsdcMicro: typeof paymentRequest.x402?.required_usdc_micro === "string" ? paymentRequest.x402.required_usdc_micro : void 0,
152
+ // read off the job rather than the content — the fiat envelope
153
+ // is ledger state, not part of the wire shape, and `requestPayment` has
154
+ // already pinned it on the job by the time this fires (same ordering the
155
+ // `pendingPaymentMsats` comment above describes).
156
+ pendingPaymentFiatMicro: job.pendingPaymentFiatMicro,
157
+ pendingPaymentFiatCurrency: job.pendingPaymentFiatCurrency
158
+ });
159
+ return;
160
+ }
161
+ job.messageAppender?.(msg, type === "prompt" ? { awaitingInput: job.status === "awaiting-input" } : void 0);
162
+ }
163
+ function localMessage(job, from, type, content) {
164
+ job.seq++;
165
+ const msg = {
166
+ seq: job.seq,
167
+ from,
168
+ timestamp: Math.floor(Date.now() / 1e3),
169
+ type,
170
+ content
171
+ };
172
+ job.messages.push(msg);
173
+ for (const listener of job.listeners) {
174
+ listener(msg);
175
+ }
176
+ return msg;
177
+ }
178
+ function isTerminal(status) {
179
+ return status === "completed" || status === "failed" || status === "cancelled";
180
+ }
181
+ function isYieldMessage(msg) {
182
+ return msg.type === "prompt" || msg.type === "payment-request" || msg.type === "working" || msg.type === "complete" || msg.type === "cancel";
183
+ }
184
+
185
+ // src/sdk/server/content-retention.ts
186
+ import { randomUUID } from "crypto";
187
+ var ContentHoldError = class extends Error {
188
+ constructor(code) {
189
+ super(code);
190
+ this.code = code;
191
+ }
192
+ code;
193
+ };
194
+ function isContentHoldStore(store) {
195
+ return "getContentSnapshot" in store && "mutateContentHold" in store;
196
+ }
197
+ function contentMetadata(job, retentionDays, holds = []) {
198
+ const baseline = baselineContentExpiry(job, retentionDays);
199
+ return {
200
+ content_expires_at: holds.some((hold) => hold.cleared_at === null) ? null : baseline,
201
+ content_available: job.redactedAt === void 0
202
+ };
203
+ }
204
+ function baselineContentExpiry(job, retentionDays) {
205
+ return retentionDays > 0 && isTerminal(job.status) ? job.lastActivityAt + retentionDays * 864e5 : null;
206
+ }
207
+ function applyContentHoldMutation(job, history, mutation) {
208
+ if (job.redactedAt !== void 0) throw new ContentHoldError("content_unavailable");
209
+ const result = structuredClone(history);
210
+ let hold;
211
+ if (mutation.action === "placed") {
212
+ hold = {
213
+ id: randomUUID(),
214
+ job_id: job.id,
215
+ purpose: mutation.purpose,
216
+ reason: mutation.reason,
217
+ actor: mutation.actor,
218
+ created_at: mutation.at,
219
+ review_due_at: mutation.review_due_at,
220
+ cleared_at: null
221
+ };
222
+ result.holds.push(hold);
223
+ } else {
224
+ const existing = result.holds.find((item) => item.id === mutation.hold_id && item.cleared_at === null);
225
+ if (!existing) throw new ContentHoldError("hold_not_active");
226
+ hold = existing;
227
+ if (mutation.action === "cleared") hold.cleared_at = mutation.at;
228
+ else hold.review_due_at = mutation.review_due_at;
229
+ hold.reason = mutation.reason;
230
+ }
231
+ result.events.push({
232
+ id: randomUUID(),
233
+ hold_id: hold.id,
234
+ job_id: job.id,
235
+ action: mutation.action,
236
+ actor: mutation.actor,
237
+ reason: mutation.reason,
238
+ at: mutation.at,
239
+ review_due_at: hold.review_due_at
240
+ });
241
+ return result;
242
+ }
243
+
244
+ export {
245
+ toJobRecord,
246
+ fromJobRecord,
247
+ abortJob,
248
+ JobCancelledError,
249
+ providerMessage,
250
+ localMessage,
251
+ isTerminal,
252
+ isYieldMessage,
253
+ ContentHoldError,
254
+ isContentHoldStore,
255
+ contentMetadata,
256
+ baselineContentExpiry,
257
+ applyContentHoldMutation
258
+ };
@@ -0,0 +1,36 @@
1
+ // src/sdk/step-cache.ts
2
+ var StepCache = class _StepCache {
3
+ cache = /* @__PURE__ */ new Map();
4
+ has(id) {
5
+ return this.cache.has(id);
6
+ }
7
+ get(id) {
8
+ return this.getRecord(id).value;
9
+ }
10
+ getRecord(id) {
11
+ const record = this.cache.get(id);
12
+ if (record === void 0) throw new Error(`StepCache: no cached result for step "${id}"`);
13
+ return record;
14
+ }
15
+ set(id, value, costs) {
16
+ this.cache.set(id, {
17
+ id,
18
+ value,
19
+ ...costs !== void 0 && costs.length > 0 ? { costs: costs.map(({ amount, currency }) => ({ amount, currency })) } : {}
20
+ });
21
+ }
22
+ serialize() {
23
+ return [...this.cache.values()];
24
+ }
25
+ static deserialize(records) {
26
+ const cache = new _StepCache();
27
+ for (const { id, value, costs } of records) {
28
+ cache.set(id, value, costs);
29
+ }
30
+ return cache;
31
+ }
32
+ };
33
+
34
+ export {
35
+ StepCache
36
+ };
@@ -1,3 +1,7 @@
1
+ import {
2
+ ContentHoldError,
3
+ applyContentHoldMutation
4
+ } from "./chunk-AFV77S4C.js";
1
5
  import {
2
6
  withSdkInitLock
3
7
  } from "./chunk-COYKJRMM.js";
@@ -228,6 +232,18 @@ var PostgresJobStore = class {
228
232
  ALTER TABLE jobs ADD COLUMN IF NOT EXISTS receipt JSONB;
229
233
  ALTER TABLE jobs ADD COLUMN IF NOT EXISTS receipt_seq BIGINT;
230
234
  ALTER TABLE jobs ADD COLUMN IF NOT EXISTS redacted_at BIGINT;
235
+ CREATE TABLE IF NOT EXISTS job_content_holds (
236
+ id TEXT PRIMARY KEY, job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
237
+ data JSONB NOT NULL
238
+ );
239
+ CREATE INDEX IF NOT EXISTS job_content_holds_job_idx ON job_content_holds(job_id);
240
+ CREATE TABLE IF NOT EXISTS job_content_hold_events (
241
+ event_position BIGSERIAL UNIQUE,
242
+ id TEXT PRIMARY KEY, job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
243
+ data JSONB NOT NULL
244
+ );
245
+ CREATE INDEX IF NOT EXISTS job_content_hold_events_job_idx ON job_content_hold_events(job_id);
246
+
231
247
  -- credit-ledger linkage. Written at submission alongside the
232
248
  -- payment fields; the terminal funnel settles/releases the draw and the
233
249
  -- receipt countersigns the ReceiptCredit block from it.
@@ -536,7 +552,7 @@ var PostgresJobStore = class {
536
552
  required_msats = EXCLUDED.required_msats,
537
553
  step_cache = EXCLUDED.step_cache,
538
554
  state = EXCLUDED.state,
539
- last_activity_at = EXCLUDED.last_activity_at
555
+ last_activity_at = CASE WHEN jobs.status IN ('completed', 'failed', 'cancelled') THEN jobs.last_activity_at ELSE EXCLUDED.last_activity_at END
540
556
  -- terminal is terminal. On a multi-machine DVM a DELETE can
541
557
  -- land on a machine that isn't running the handler, which cancels the
542
558
  -- job in the store only; the handler's machine never learns of it and
@@ -737,6 +753,7 @@ var PostgresJobStore = class {
737
753
  const client = await this.pool.connect();
738
754
  try {
739
755
  await client.query("BEGIN");
756
+ await client.query("SELECT id FROM jobs WHERE id = $1 FOR UPDATE", [args.jobId]);
740
757
  const { rowCount } = await client.query(
741
758
  `UPDATE jobs SET
742
759
  input = '',
@@ -760,6 +777,7 @@ var PostgresJobStore = class {
760
777
  AND last_activity_at = $2
761
778
  AND redacted_at IS NULL
762
779
  AND status IN ('completed', 'failed', 'cancelled')
780
+ AND NOT EXISTS (SELECT 1 FROM job_content_holds h WHERE h.job_id=jobs.id AND h.data->'cleared_at' = 'null'::jsonb)
763
781
  AND ($4::boolean = false OR NOT EXISTS (
764
782
  SELECT 1 FROM job_messages m WHERE m.job_id=jobs.id
765
783
  AND m.data->>'type'='payment'
@@ -781,6 +799,38 @@ var PostgresJobStore = class {
781
799
  client.release();
782
800
  }
783
801
  }
802
+ async getContentSnapshot(jobId, dvmId) {
803
+ const { rows } = await this.pool.query(
804
+ `SELECT j.*, COALESCE((SELECT jsonb_agg(h.data ORDER BY h.data->>'created_at', h.id) FROM job_content_holds h WHERE h.job_id=j.id), '[]'::jsonb) AS content_holds,
805
+ COALESCE((SELECT jsonb_agg(e.data ORDER BY e.event_position) FROM job_content_hold_events e WHERE e.job_id=j.id), '[]'::jsonb) AS content_hold_events
806
+ FROM jobs j WHERE j.id=$1 AND ($2::text IS NULL OR j.dvm_id IS NULL OR j.dvm_id=$2)`,
807
+ [jobId, dvmId ?? null]
808
+ );
809
+ if (!rows[0]) return void 0;
810
+ return { job: rowToRecord(rows[0]), history: { holds: rows[0].content_holds, events: rows[0].content_hold_events } };
811
+ }
812
+ async mutateContentHold(jobId, mutation, dvmId) {
813
+ const client = await this.pool.connect();
814
+ try {
815
+ await client.query("BEGIN");
816
+ const { rows } = await client.query("SELECT * FROM jobs WHERE id=$1 AND ($2::text IS NULL OR dvm_id IS NULL OR dvm_id=$2) FOR UPDATE", [jobId, dvmId ?? null]);
817
+ if (!rows[0]) throw new ContentHoldError("job_not_found");
818
+ const holds = await client.query("SELECT data FROM job_content_holds WHERE job_id=$1 ORDER BY data->>'created_at', id", [jobId]);
819
+ const events = await client.query("SELECT data FROM job_content_hold_events WHERE job_id=$1 ORDER BY event_position", [jobId]);
820
+ const history = applyContentHoldMutation(rowToRecord(rows[0]), { holds: holds.rows.map((row) => row.data), events: events.rows.map((row) => row.data) }, mutation);
821
+ const event = history.events.at(-1);
822
+ const hold = history.holds.find((item) => item.id === event.hold_id);
823
+ await client.query(`INSERT INTO job_content_holds(id,job_id,data) VALUES($1,$2,$3) ON CONFLICT(id) DO UPDATE SET data=EXCLUDED.data`, [hold.id, jobId, JSON.stringify(hold)]);
824
+ await client.query("INSERT INTO job_content_hold_events(id,job_id,data) VALUES($1,$2,$3)", [event.id, jobId, JSON.stringify(event)]);
825
+ await client.query("COMMIT");
826
+ return history;
827
+ } catch (error) {
828
+ await client.query("ROLLBACK");
829
+ throw error;
830
+ } finally {
831
+ client.release();
832
+ }
833
+ }
784
834
  async claimReceiptSeq(jobId) {
785
835
  const client = await this.pool.connect();
786
836
  try {
@@ -828,7 +878,7 @@ var PostgresJobStore = class {
828
878
  try {
829
879
  await client.query("BEGIN");
830
880
  const { rows } = await client.query(
831
- `UPDATE jobs SET next_seq = next_seq + 1, last_activity_at = $2,
881
+ `UPDATE jobs SET next_seq = next_seq + 1, last_activity_at = CASE WHEN status IN ('completed','failed','cancelled') THEN last_activity_at ELSE $2 END,
832
882
  awaiting_input_at_ms = CASE WHEN $3::boolean AND status NOT IN ('completed', 'failed', 'cancelled')
833
883
  THEN COALESCE(awaiting_input_at_ms, $2) ELSE awaiting_input_at_ms END,
834
884
  status = CASE WHEN $3::boolean AND status NOT IN ('completed', 'failed', 'cancelled')
@@ -925,7 +975,7 @@ var PostgresJobStore = class {
925
975
  await client.query("BEGIN");
926
976
  if (message.type === "payment") await this.hostingCleanupFence?.assertNewIntent(client);
927
977
  const { rows } = await client.query(
928
- `UPDATE jobs SET next_seq = next_seq + 1, last_activity_at = $2
978
+ `UPDATE jobs SET next_seq = next_seq + 1, last_activity_at = CASE WHEN status IN ('completed','failed','cancelled') THEN last_activity_at ELSE $2 END
929
979
  WHERE id = $1 AND redacted_at IS NULL
930
980
  RETURNING (next_seq - 1) AS seq`,
931
981
  [jobId, Date.now()]
@@ -1024,7 +1074,7 @@ var PostgresJobStore = class {
1024
1074
  cashu_flow = COALESCE($9, cashu_flow),
1025
1075
  pending_x402_nonce = CASE WHEN $11::boolean THEN NULL ELSE pending_x402_nonce END,
1026
1076
  pending_x402_amount_usdc_micro = CASE WHEN $11::boolean THEN NULL ELSE pending_x402_amount_usdc_micro END,
1027
- last_activity_at = $12
1077
+ last_activity_at = CASE WHEN status IN ('completed','failed','cancelled') THEN last_activity_at ELSE $12 END
1028
1078
  WHERE id = $1
1029
1079
  RETURNING status, paid_msats, pending_payment_msats, credit_id, draw_id`,
1030
1080
  params
@@ -4,7 +4,7 @@ import {
4
4
  DEFAULT_CREDIT_TTL_SECONDS,
5
5
  DEFAULT_JOB_RETENTION_DAYS,
6
6
  isZodSchema
7
- } from "./chunk-A4YFWZ7R.js";
7
+ } from "./chunk-RCM3P2HE.js";
8
8
  import {
9
9
  InvalidCurrencyError,
10
10
  validateCurrency
@@ -47,7 +47,7 @@ function configureDVM(config) {
47
47
  }
48
48
  function resolveJobRetentionDays(raw) {
49
49
  const days = raw ?? DEFAULT_JOB_RETENTION_DAYS;
50
- if (!Number.isSafeInteger(days) || days < 0) {
50
+ if (!Number.isSafeInteger(days) || days < 0 || days > Math.floor((Number.MAX_SAFE_INTEGER - Date.now()) / 864e5)) {
51
51
  throw new Error(
52
52
  `configureDVM: jobRetentionDays must be a non-negative whole number of days; received ${JSON.stringify(raw)}`
53
53
  );