@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.
Files changed (41) 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-5GFED3GJ.js +955 -0
  5. package/dist/chunk-6JZIX5WW.js +1155 -0
  6. package/dist/chunk-7IH5SG2A.js +1038 -0
  7. package/dist/chunk-AT6V3SY7.js +102 -0
  8. package/dist/chunk-DCNT4PJS.js +733 -0
  9. package/dist/chunk-DMNLFNTW.js +135 -0
  10. package/dist/chunk-FROTD5XQ.js +70 -0
  11. package/dist/chunk-H25M54MI.js +149 -0
  12. package/dist/chunk-KQAJVVZT.js +712 -0
  13. package/dist/chunk-KXWROQGK.js +74 -0
  14. package/dist/chunk-L4OYF4DQ.js +67 -0
  15. package/dist/chunk-NTK5DJ6R.js +1256 -0
  16. package/dist/chunk-RPXHKMYE.js +3808 -0
  17. package/dist/chunk-S3XAHZQY.js +63 -0
  18. package/dist/chunk-YG7G4DPZ.js +25 -0
  19. package/dist/credit-ledger-EDMEZSA2.js +28 -0
  20. package/dist/index.d.ts +144 -0
  21. package/dist/index.js +303 -0
  22. package/dist/job-store-C5n6bhap.d.ts +5090 -0
  23. package/dist/memory-credit-ledger-7TTZDSRS.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/postgres-consumed-credential-store-VHBT4KEA.js +72 -0
  27. package/dist/postgres-job-store-J5F4GUWU.js +7 -0
  28. package/dist/postgres-kv-store-JFBDP5IP.js +7 -0
  29. package/dist/postgres-replay-store-UJXRT6VO.js +7 -0
  30. package/dist/pricing-4CEB34RM.js +48 -0
  31. package/dist/processed-payment-store-HAA4SFNK.js +11 -0
  32. package/dist/revenue-reporter-M35KP6V7.js +435 -0
  33. package/dist/server/index.d.ts +4108 -0
  34. package/dist/server/index.js +22538 -0
  35. package/dist/ssrf-BdHsrrIb.d.ts +325 -0
  36. package/dist/tempo-charge-store-6GJEMNUU.js +130 -0
  37. package/dist/tempo-session-store-FTEEGZXA.js +467 -0
  38. package/dist/testing/index.d.ts +135 -0
  39. package/dist/testing/index.js +151 -0
  40. package/dist/x402-35VLYFKZ.js +1272 -0
  41. package/package.json +89 -6
@@ -0,0 +1,325 @@
1
+ import { C as Currency } from './job-store-C5n6bhap.js';
2
+
3
+ /**
4
+ * Quote-time fx snapshot. Embedded in scribe's `lockedQuote` for within-job
5
+ * consistency between upfront and mid-job charges (see scribe's
6
+ * `runTranscribeJob`). Within-call lock = "rate cached at first fetch and
7
+ * reused across the duration calculation"; across-call lock degrades to
8
+ * "fxFetcher {@link FX_CACHE_TTL_MS} cache window" — see
9
+ * `public design rationale`.
10
+ *
11
+ * `rates` maps an ISO-4217 lowercase currency code to that currency's
12
+ * value-per-BTC at `as_of`. The set of currencies present matches the
13
+ * fetcher's configured set (see `createFxFetcher.currencies`).
14
+ */
15
+ interface FxRateSnapshot {
16
+ rates: Record<Currency, number>;
17
+ as_of: string;
18
+ /**
19
+ * Configured currencies whose rate here came from an *earlier* fetch, because
20
+ * the source's latest body omitted them (internal-review). The rate is the best one
21
+ * this process holds; `as_of` describes the rest of the snapshot, so a
22
+ * consumer pricing one of these is degrading and should say so — the mid-job
23
+ * legs report it as `credit_topup_stale_rate` / `dvm.ask_pin_stale_rate`.
24
+ * Absent on the normal complete-body path.
25
+ */
26
+ carried_forward?: readonly Currency[];
27
+ /**
28
+ * Configured currencies absent from `rates` altogether — the source omitted
29
+ * them and this process has never held one to carry forward. {@link fxRateFor}
30
+ * raises `fx_rate_unavailable` rather than `unsupported_currency` for these:
31
+ * the DVM advertises the currency, so this is a rate outage narrowed to one
32
+ * code, and a caller's agent must read it on the same retryable envelope as
33
+ * any other (internal-review). Absent on the normal complete-body path.
34
+ */
35
+ unavailable?: readonly Currency[];
36
+ }
37
+ /**
38
+ * Default fiat set the bundled fx fetcher resolves against BTC. Add a currency
39
+ * here and it flows through `DEFAULT_FX_RATE_SOURCE`, the
40
+ * `UnsupportedCurrencyError` supported-set string, and every fiat-form
41
+ * `requestPayment` call site — no shape change at the SDK or caller surface.
42
+ */
43
+ declare const DEFAULT_FX_CURRENCIES: readonly Currency[];
44
+ /**
45
+ * Default fx-rate source. Points at CoinGecko's free `simple/price` endpoint.
46
+ * Used for self-hosted DVMs and as the fallback when `DVMKIT_FX_SOURCE` is
47
+ * unset. Hosted DVMs hit the platform-served endpoint instead — see
48
+ * {@link PlatformFxSource} and {@link resolveFxSourceFromEnv}.
49
+ */
50
+ declare const DEFAULT_FX_RATE_SOURCE: string;
51
+ /**
52
+ * Platform-served fx source. Hosted DVMs hit `${DVMKIT_PLATFORM_URL}/_internal/fx`
53
+ * authenticated with their per-DVM bearer token. The endpoint serves the
54
+ * canonical `fx_rates` snapshot the platform refreshes daily — one upstream
55
+ * fetch covers every hosted DVM. Self-hosted DVMs (no platform wiring) fall
56
+ * back to CoinGecko-direct.
57
+ */
58
+ interface PlatformFxSource {
59
+ kind: "platform";
60
+ url: string;
61
+ token: string;
62
+ }
63
+ /** Agent-relayable copy for an {@link FxRateUnavailableError}. */
64
+ interface FxRateUnavailableCopy {
65
+ display?: string;
66
+ hint?: string;
67
+ }
68
+ /**
69
+ * The platform's *deliberate* known-stale refusal, threaded through the fetch
70
+ * boundary from the `_internal/fx` 503 body (internal-review/internal-review). Present on an
71
+ * {@link FxRateUnavailableError} only when the platform decided the snapshot is
72
+ * too old to price against (`error.code === "fx_rate_unavailable"` with a
73
+ * `detail.stale_days`) — as opposed to a transient unreachability, which carries
74
+ * no `knownStale`. `resolveServerBtcUsdRate` fails closed on this rather than
75
+ * degrading to `lastKnown` (internal-review): the platform already refused to serve the
76
+ * rate, so pricing off a frozen local copy of it would defeat the refusal.
77
+ */
78
+ interface FxKnownStale {
79
+ staleDays: number;
80
+ staleSource?: string;
81
+ thresholdDays?: number;
82
+ }
83
+ /**
84
+ * Thrown when the fx source can't be reached or returns an unparseable rate.
85
+ * `code`/`display`/`hint` mirror the structured-error envelope the SDK quote
86
+ * path renders to agents as a 503-style response — operators retry; agents
87
+ * pass `display` to humans.
88
+ *
89
+ * `copy` overrides the quote-flavoured defaults. The payment rails raise the
90
+ * same code from a different moment in the request (internal-review) — an agent that
91
+ * can't be quoted and an agent whose payment couldn't be priced retry the same
92
+ * way, so they share one code, but "nothing was charged" is only true of the
93
+ * latter and must not be said to the former.
94
+ *
95
+ * One code, two envelopes: the quote path renders `{ error: { code, display,
96
+ * hint } }`, the payment path the flat `{ error: "fx_rate_unavailable", display,
97
+ * hint }` that every other payment error uses (`buildPaymentErrorResponse`). So
98
+ * an agent has one retry rule but still two shapes to parse the code out of —
99
+ * that split is the response envelopes', not this error's, and unifying it is
100
+ * internal-review.
101
+ */
102
+ declare class FxRateUnavailableError extends Error {
103
+ readonly code = "fx_rate_unavailable";
104
+ readonly display: string;
105
+ readonly hint: string;
106
+ /**
107
+ * Set only when the platform explicitly refused a *known-stale* snapshot
108
+ * (internal-review). Absent means transient/unreachable — a distinction the payment
109
+ * rail's amount computation branches on: refuse on `knownStale`, degrade to
110
+ * the last-known rate without it. See {@link FxKnownStale}.
111
+ */
112
+ readonly knownStale?: FxKnownStale;
113
+ constructor(message?: string, copy?: FxRateUnavailableCopy, knownStale?: FxKnownStale);
114
+ }
115
+ /**
116
+ * Snapshot fetcher for BTC against one or more fiat currencies. Caches
117
+ * in-process for {@link FX_CACHE_TTL_MS} so concurrent quotes amortise the
118
+ * rate-limit budget, and a within-window quote → job pair settles at the same
119
+ * "locked" snapshot without an explicit cross-call lock. `supportedCurrencies()`
120
+ * returns the configured set synchronously so callers can preflight an
121
+ * unsupported currency without forcing a network hit.
122
+ */
123
+ interface FxFetcher {
124
+ fetch(): Promise<FxRateSnapshot>;
125
+ supportedCurrencies(): readonly Currency[];
126
+ /**
127
+ * The last snapshot this fetcher successfully retrieved, paired with the Unix
128
+ * ms it was cached, or `null` if none has landed yet. Ignores the freshness
129
+ * TTL — the cache is never cleared, so this survives an upstream outage and is
130
+ * the offline degradation source for the payment rail (internal-review): when
131
+ * {@link fetch} throws, callers serve this rather than fail a priced request.
132
+ * Optional so lightweight test fakes need not implement it.
133
+ */
134
+ lastKnown?(): {
135
+ snapshot: FxRateSnapshot;
136
+ cachedAt: number;
137
+ } | null;
138
+ }
139
+ /**
140
+ * Options for `createFxFetcher`. `source` accepts either:
141
+ * - a URL string targeting a CoinGecko-shaped endpoint
142
+ * (`{ bitcoin: { <currency>: number, ... } }`), or
143
+ * - a {@link PlatformFxSource} object pointing at the dvmkit platform's
144
+ * `_internal/fx` endpoint (the canonical `fx_rates` snapshot, served with
145
+ * bearer auth).
146
+ *
147
+ * `undefined` falls back to {@link DEFAULT_FX_RATE_SOURCE} (CoinGecko-direct).
148
+ */
149
+ interface CreateFxFetcherOpts {
150
+ source?: string | PlatformFxSource;
151
+ /** Currencies the fetcher advertises support for. Defaults to {@link DEFAULT_FX_CURRENCIES}. */
152
+ currencies?: readonly Currency[];
153
+ fetchFn?: typeof fetch;
154
+ now?: () => number;
155
+ cacheTtlMs?: number;
156
+ retries?: number;
157
+ /**
158
+ * Base backoff between retry attempts, doubling each time. Defaults to
159
+ * {@link FX_RETRY_BASE_DELAY_MS}; pass `0` in tests so the retry loop stays
160
+ * instant.
161
+ */
162
+ retryDelayMs?: number;
163
+ }
164
+ /** Build the default fx fetcher backed by `DVMKIT_FX_SOURCE` (or CoinGecko). */
165
+ declare function createFxFetcher(opts?: CreateFxFetcherOpts): FxFetcher;
166
+ /**
167
+ * Look up `currency`'s rate in `snapshot`.
168
+ *
169
+ * Two ways it can be missing, and they are not the same failure (internal-review):
170
+ *
171
+ * - The snapshot lists it in {@link FxRateSnapshot.unavailable} — the DVM
172
+ * advertises the currency, but its rate source omitted it and this process
173
+ * holds no earlier one. That is a rate outage narrowed to one code, so it
174
+ * raises `fx_rate_unavailable`, retryable, the same envelope every other rate
175
+ * failure reaches the caller's agent on (internal-review). Answering
176
+ * `unsupported_currency` here would reach the quote path as a `400
177
+ * invalid_quote_result` — telling an agent the DVM's own quote is malformed,
178
+ * and not to retry, over a transient omission upstream.
179
+ * - Anything else — a currency neither configured nor fetched, typically an
180
+ * operator's `DVMKIT_FX_SOURCE` override that doesn't carry it. Raises
181
+ * `UnsupportedCurrencyError` naming the keys actually present, so the message
182
+ * stays truthful about what this snapshot can price.
183
+ */
184
+ declare function fxRateFor(snapshot: FxRateSnapshot, currency: Currency): number;
185
+ /**
186
+ * Resolve the fx source from a DVM process's env. Used by first-party DVMs to
187
+ * pick between platform-served and CoinGecko-direct fetchers based on whether
188
+ * the platform wired this DVM up at deploy-time. Returns `undefined` when no
189
+ * env override is set — the default CoinGecko URL kicks in inside
190
+ * {@link createFxFetcher}.
191
+ *
192
+ * - `DVMKIT_FX_SOURCE === "platform"` + `DVMKIT_PLATFORM_URL` +
193
+ * `DVMKIT_PLATFORM_TOKEN` → {@link PlatformFxSource} pointing at
194
+ * `${DVMKIT_PLATFORM_URL}/_internal/fx`. The platform's container deploy
195
+ * pipeline injects all three together (see `applyPlatformTokenInjection`).
196
+ * - `DVMKIT_FX_SOURCE` starts with `http(s)://` → URL passed through verbatim
197
+ * (CoinGecko-shaped override, mostly used by tests + self-hosted operators
198
+ * pointing at a private mirror).
199
+ * - Otherwise → `undefined` (CoinGecko-direct default).
200
+ *
201
+ * The magic string `"platform"` and the URL fallback are intentionally
202
+ * non-overlapping shapes — operators who want a CoinGecko-style URL pass a
203
+ * URL, and platform-injected DVMs get the string flag plus the two address
204
+ * env vars.
205
+ */
206
+ declare function resolveFxSourceFromEnv(env: Record<string, string | undefined>): string | PlatformFxSource | undefined;
207
+
208
+ /**
209
+ * Why `assertSafeUrl` refused this URL. Carried on `SSRFError` so callers can
210
+ * branch without string-matching `err.message` — DNS failures (NXDOMAIN, empty
211
+ * resolution), unparseable URLs, and a non-https scheme each have distinct
212
+ * recovery paths and shouldn't collapse together.
213
+ */
214
+ type SSRFReason = "invalid_url" | "insecure_scheme" | "private_address" | "dns_failed";
215
+ /**
216
+ * Thrown when an input URL points at internal infrastructure we refuse to
217
+ * fetch from inside the cluster. Carries a `reason` discriminator so the
218
+ * caller can map cleanly onto its public taxonomy instead of inspecting the
219
+ * message string.
220
+ */
221
+ declare class SSRFError extends Error {
222
+ readonly code: "ssrf_blocked";
223
+ readonly reason: SSRFReason;
224
+ constructor(reason: SSRFReason, message: string);
225
+ }
226
+ /** Pluggable DNS resolver. Defaults to `node:dns/promises` `lookup(host, { all: true })`. */
227
+ type SSRFResolver = (host: string) => Promise<{
228
+ address: string;
229
+ family: number;
230
+ }[]>;
231
+ /**
232
+ * Configurable hooks for `assertSafeUrl`. Defaults preserve the dvmkit
233
+ * baseline denylist; extra entries compose with (never replace) the defaults.
234
+ */
235
+ interface SSRFGuardOpts {
236
+ /** Custom DNS resolver — typically a test stub. */
237
+ resolver?: SSRFResolver;
238
+ /** Additional exact hostnames to deny on top of the defaults (lowercased on input). */
239
+ extraHostnamesExact?: Iterable<string>;
240
+ /**
241
+ * Additional hostname suffixes to deny on top of the defaults. Each entry
242
+ * should include the leading dot (e.g. `.example.invalid`) — the same shape
243
+ * the defaults use.
244
+ */
245
+ extraHostnameSuffixes?: Iterable<string>;
246
+ /** Additional IPv4 predicate. Returns true for an unsafe address. */
247
+ extraUnsafeIPv4?: (ipv4: string) => boolean;
248
+ /** Additional IPv6 predicate. Returns true for an unsafe address. */
249
+ extraUnsafeIPv6?: (ipv6: string) => boolean;
250
+ /**
251
+ * Underlying fetch implementation `createPinnedFetch` wraps. Defaults to
252
+ * `globalThis.fetch`. Pass an instrumented wrapper (e.g. scribe's
253
+ * `ctx.fetch` from `createInstrumentedFetch`) to keep `dvm.fetch` spans
254
+ * around outbound HEAD / GET — the dispatcher pin for every safe redirect
255
+ * hop is preserved because `init.dispatcher` flows through any thin fetch
256
+ * wrapper unchanged.
257
+ */
258
+ baseFetch?: typeof fetch;
259
+ /**
260
+ * Called after a redirect response is accepted and before the target is
261
+ * resolved or connected. Lets an outer transport refuse to replay an
262
+ * original request once its source has demonstrably responded.
263
+ */
264
+ onRedirectFollowed?: () => void;
265
+ }
266
+ /**
267
+ * Reject URLs whose host points at internal infrastructure — private /
268
+ * link-local IP ranges, the AWS / GCP metadata IP, Fly's `*.internal`
269
+ * service-discovery names, and a small denylist of obviously-internal
270
+ * hostnames. Defence-in-depth — full mitigation belongs at the egress firewall
271
+ * and at fetch-time IP pinning (`createPinnedFetch`, internal-review).
272
+ *
273
+ * Throws `SSRFError` with a `reason` discriminator so callers can branch onto
274
+ * their own typed error taxonomy.
275
+ */
276
+ declare function assertSafeUrl(url: string, opts?: SSRFGuardOpts): Promise<void>;
277
+ /**
278
+ * A `fetch`-compatible function pinned to a single pre-validated IP address.
279
+ *
280
+ * Returned by {@link createPinnedFetch}. Closes the DNS-rebinding window in
281
+ * which `assertSafeUrl` validates an address but the subsequent `fetch()`
282
+ * re-resolves DNS and lands on a different (private) address.
283
+ *
284
+ * Callers MUST `await close()` when done with the pin so undici's connection
285
+ * pool is released.
286
+ */
287
+ interface PinnedFetch {
288
+ /** Fetch whose initial request and every followed redirect are pinned to their pre-validated address sets. */
289
+ fetch: typeof fetch;
290
+ /** Primary (first) validated address of the initial URL. */
291
+ address: string;
292
+ /** IP family (4 or 6) of {@link address} (the primary). */
293
+ family: number;
294
+ /** Release the undici connection pool backing this pinned fetch. */
295
+ close(): Promise<void>;
296
+ }
297
+ /**
298
+ * Resolve the URL's host once, validate every answer against the same
299
+ * denylist `assertSafeUrl` applies, then return a `fetch` whose `connect.lookup`
300
+ * replays that frozen, pre-validated address set (see {@link buildPinnedLookup}).
301
+ * Redirects are followed manually: each target URL is independently required
302
+ * to be HTTPS, resolved once, denylist-checked, and pinned before it can be
303
+ * requested. That prevents an automatic redirect from reusing the source
304
+ * host's dispatcher for a different hostname, while keeping a redirect from
305
+ * opening a DNS-rebinding window.
306
+ *
307
+ * Throws `SSRFError` with the same `reason` discriminator as `assertSafeUrl`,
308
+ * so error-mapping paths (`mapSsrfError` in cast/scribe) work unchanged.
309
+ *
310
+ * For IP-literal URLs no dispatcher is needed, but redirects still pass
311
+ * through the same validation loop.
312
+ *
313
+ * Replaying the full validated set (not just `addresses[0]`) preserves Node's
314
+ * happy-eyeballs fallback across the answers — a host whose first address is an
315
+ * unroutable family still connects via the others — while keeping the rebind
316
+ * window closed, since every replayed address was already denylist-checked.
317
+ *
318
+ * Pass `opts.baseFetch` to wrap an instrumented fetch (e.g. scribe's
319
+ * `ctx.fetch` from `createInstrumentedFetch`) — `dvm.fetch` spans flow
320
+ * through because `init.dispatcher` is forwarded to the underlying
321
+ * `globalThis.fetch` unchanged.
322
+ */
323
+ declare function createPinnedFetch(url: string, opts?: SSRFGuardOpts): Promise<PinnedFetch>;
324
+
325
+ export { type CreateFxFetcherOpts as C, DEFAULT_FX_CURRENCIES as D, type FxFetcher as F, type PinnedFetch as P, SSRFError as S, DEFAULT_FX_RATE_SOURCE as a, type FxRateSnapshot as b, FxRateUnavailableError as c, type PlatformFxSource as d, type SSRFGuardOpts as e, type SSRFReason as f, type SSRFResolver as g, assertSafeUrl as h, createFxFetcher as i, createPinnedFetch as j, fxRateFor as k, resolveFxSourceFromEnv as r };
@@ -0,0 +1,130 @@
1
+ import {
2
+ parseStoreValue,
3
+ stringifyStoreValue
4
+ } from "./chunk-YG7G4DPZ.js";
5
+ import {
6
+ withSdkInitLock
7
+ } from "./chunk-S3XAHZQY.js";
8
+
9
+ // src/sdk/server/tempo-charge-store.ts
10
+ var DEFAULT_TABLE_NAME = "mpp_tempo_charge_replays";
11
+ var DEFAULT_RETENTION_MS = 60 * 60 * 1e3;
12
+ var DEFAULT_GC_SAMPLE_RATE = 0.01;
13
+ var VALID_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
14
+ var PostgresTempoChargeStore = class {
15
+ pool;
16
+ tableName;
17
+ retentionMs;
18
+ gcSampleRate;
19
+ now;
20
+ constructor(pool, opts) {
21
+ const tableName = opts?.tableName ?? DEFAULT_TABLE_NAME;
22
+ if (!VALID_IDENTIFIER.test(tableName)) {
23
+ throw new Error(
24
+ `PostgresTempoChargeStore: tableName must match ${VALID_IDENTIFIER.source}, got "${tableName}"`
25
+ );
26
+ }
27
+ this.pool = pool;
28
+ this.tableName = tableName;
29
+ this.retentionMs = opts?.retentionMs ?? DEFAULT_RETENTION_MS;
30
+ this.gcSampleRate = opts?.gcSampleRate ?? DEFAULT_GC_SAMPLE_RATE;
31
+ this.now = opts?.now ?? (() => Date.now());
32
+ this.get = this.get.bind(this);
33
+ this.put = this.put.bind(this);
34
+ this.delete = this.delete.bind(this);
35
+ this.update = this.update.bind(this);
36
+ }
37
+ /** Run the CREATE TABLE migration. Idempotent — safe to call repeatedly. */
38
+ async init() {
39
+ await withSdkInitLock(this.pool, () => this.createTables());
40
+ }
41
+ /** Read one stored value, or `null` when absent — mppx's miss sentinel. */
42
+ async get(key) {
43
+ const { rows } = await this.pool.query(
44
+ `SELECT value_json FROM ${this.tableName} WHERE store_key = $1`,
45
+ [key]
46
+ );
47
+ return rows[0] ? parseStoreValue(rows[0].value_json) : null;
48
+ }
49
+ /** Replace one stored value. */
50
+ async put(key, value) {
51
+ await this.update(key, () => ({ op: "set", value, result: void 0 }));
52
+ }
53
+ /** Delete one stored value — mppx's compensating release on a failed broadcast. */
54
+ async delete(key) {
55
+ await this.update(key, () => ({ op: "delete", result: void 0 }));
56
+ }
57
+ /**
58
+ * Atomic read-modify-write, linearized across every machine sharing the pool.
59
+ *
60
+ * The advisory lock is taken before the row lock because a key's *first*
61
+ * write is the one that matters here: `FOR UPDATE` locks nothing when no row
62
+ * exists, so two machines marking one hash for the first time would both read
63
+ * `null` and both believe they won.
64
+ *
65
+ * Replay keys are unique per credential, so they never contend. The contended
66
+ * key is the fee-sponsor budget — one per `(chain, sponsor)`, read-modified
67
+ * several times per sponsored charge — and serializing it across machines is
68
+ * the point of sharing the store at all. The critical section is a SELECT and
69
+ * an upsert, so a queue of waiters clears in tens of milliseconds; a pooled
70
+ * connection is held for exactly that long and never across a chain call.
71
+ */
72
+ async update(key, fn) {
73
+ const client = await this.pool.connect();
74
+ try {
75
+ await client.query("BEGIN");
76
+ await client.query(`SELECT pg_advisory_xact_lock(hashtextextended($1, 2036))`, [key]);
77
+ const { rows } = await client.query(
78
+ `SELECT value_json FROM ${this.tableName} WHERE store_key = $1 FOR UPDATE`,
79
+ [key]
80
+ );
81
+ const current = rows[0] ? parseStoreValue(rows[0].value_json) : null;
82
+ const change = fn(current);
83
+ const now = this.now();
84
+ if (change.op === "set") {
85
+ await client.query(
86
+ `INSERT INTO ${this.tableName} (store_key, value_json, expires_at)
87
+ VALUES ($1, $2, $3)
88
+ ON CONFLICT (store_key) DO UPDATE
89
+ SET value_json = EXCLUDED.value_json, expires_at = EXCLUDED.expires_at`,
90
+ [
91
+ key,
92
+ stringifyStoreValue(change.value, "Tempo charge store value"),
93
+ now + this.retentionMs
94
+ ]
95
+ );
96
+ } else if (change.op === "delete") {
97
+ await client.query(`DELETE FROM ${this.tableName} WHERE store_key = $1`, [key]);
98
+ }
99
+ await client.query("COMMIT");
100
+ if (change.op !== "noop") this.maybeSweep(now);
101
+ return change.result;
102
+ } catch (error) {
103
+ await client.query("ROLLBACK").catch(() => void 0);
104
+ throw error;
105
+ } finally {
106
+ client.release();
107
+ }
108
+ }
109
+ /** The boot DDL itself — always runs under {@link withSdkInitLock} (internal-review). */
110
+ async createTables() {
111
+ await this.pool.query(`
112
+ CREATE TABLE IF NOT EXISTS ${this.tableName} (
113
+ store_key TEXT PRIMARY KEY,
114
+ value_json TEXT NOT NULL,
115
+ expires_at BIGINT NOT NULL
116
+ );
117
+ CREATE INDEX IF NOT EXISTS ${this.tableName}_expires_at_idx
118
+ ON ${this.tableName} (expires_at);
119
+ `);
120
+ }
121
+ maybeSweep(now) {
122
+ if (this.gcSampleRate <= 0 || Math.random() >= this.gcSampleRate) return;
123
+ this.pool.query(`DELETE FROM ${this.tableName} WHERE expires_at < $1`, [now]).catch((err) => {
124
+ console.error("PostgresTempoChargeStore GC sweep failed:", err);
125
+ });
126
+ }
127
+ };
128
+ export {
129
+ PostgresTempoChargeStore
130
+ };