@volter/twin-inngest 0.1.0

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/src/index.ts ADDED
@@ -0,0 +1,107 @@
1
+ // @volter/twin-inngest — the Inngest durable-execution / event-driven workflow-orchestration
2
+ // twin, built on the shared @volter/world-core kernel. REST transport over three surfaces: the Event
3
+ // API (POST /e/{eventKey}), the SDK execution protocol (/api/inngest, /fn/register), and the
4
+ // dev-server/cloud v2 REST management API (/api/v2/*, GROUNDED at that exact path from a
5
+ // live-fetched first-party OpenAPI document — see inngest-twin.ts header). State lives entirely
6
+ // in the kernel action log (no side-store).
7
+ //
8
+ // THE DECLARED STEP PLAN: this twin does not run user code — a function's steps
9
+ // are DECLARED (at register time, or by a test) as an ordered `step_plan`, and the twin advances
10
+ // that plan deterministically with REAL kernel-persisted memoization/replay (step outputs are
11
+ // read back from the kernel on every re-invocation, never recomputed), instant sleep/sleepUntil
12
+ // advance (no wall clock), waitForEvent gating (holds a run at a step until a matching event
13
+ // arrives, or a deterministic poll-count timeout), and an exact retry-attempt counter. This is
14
+ // the manifest files calling into a real running app as `exec.sdk_executor_roundtrip`.
15
+ export {
16
+ handleInngestTwinRequest,
17
+ routeInngestSurface,
18
+ inngestTwinSnapshot,
19
+ INNGEST_RESOURCE_TYPES,
20
+ } from './inngest-twin.ts';
21
+ export type {
22
+ InngestRequest,
23
+ InngestResponse,
24
+ InngestResourceType,
25
+ InngestSurface,
26
+ InngestTwinSnapshot,
27
+ } from './inngest-twin.ts';
28
+ export { createInngestTwinFetch, createInngestTwinServer, type InngestTwinFetchOptions } from './inngest-server.ts';
29
+ export {
30
+ mapEvent,
31
+ mapRun,
32
+ pullInngestEvents,
33
+ pullInngestRuns,
34
+ syncInngestFromReal,
35
+ } from './inngest-connector.ts';
36
+ export type { InngestLikeClient, InngestEventHandle, InngestRunHandle, InngestRealEvent, InngestRealRun, InngestBudgetedOptions } from './inngest-connector.ts';
37
+ // The client-side rate budget — the fail-closed backstop every live call goes through. The
38
+ // MECHANISM is the kernel's shared, vendor-agnostic `RateBudget`; what lives here is this vendor's
39
+ // DECLARATION (window/ceiling/per-method weights) plus `guardInngestClient`, the choke point the
40
+ // connector entrypoints apply unconditionally. Exported so an operator can inspect spend
41
+ // (`snapshot`) and a caller can catch `InngestBudgetError` by type; there is deliberately no export
42
+ // that disables the guard.
43
+ export {
44
+ INNGEST_BUDGETED_METHODS,
45
+ INNGEST_BUDGET_CEILING,
46
+ INNGEST_BUDGET_MAX_RETRY_AFTER_S,
47
+ INNGEST_BUDGET_WINDOW_MS,
48
+ INNGEST_CALL_WEIGHTS,
49
+ INNGEST_RATE_BUDGET,
50
+ InngestBudget,
51
+ InngestBudgetError,
52
+ inngestBudgetPath,
53
+ inngestCallWeight,
54
+ inngestClientBudget,
55
+ guardInngestClient,
56
+ } from './inngest-budget.ts';
57
+ export type { InngestBudgetErrorKind, InngestBudgetOptions, InngestBudgetReservation, InngestBudgetSnapshot } from './inngest-budget.ts';
58
+ export {
59
+ INNGEST_SIGNING_KEY_TYPE,
60
+ inngestSigningKey,
61
+ mintInngestSigningKey,
62
+ storedInngestSigningKey,
63
+ signInngestRequest,
64
+ buildInngestSignatureHeader,
65
+ verifyInngestSignature,
66
+ InngestSignatureError,
67
+ } from './inngest-signing.ts';
68
+
69
+ // Registry descriptor: the pack self-describes so tooling can discover it.
70
+ import type { TwinPack } from '@volter/world-core';
71
+ import { INNGEST_RATE_BUDGET as RATE_BUDGET } from './inngest-budget.ts';
72
+ export const pack: TwinPack = {
73
+ // The SAME object inngest-budget.ts declares at module load — one source of truth, so registering
74
+ // the pack and importing the connector can never arm two different ceilings.
75
+ rateBudget: RATE_BUDGET,
76
+ vendor: 'inngest',
77
+ transport: 'rest',
78
+ archetype: 'crud',
79
+ bin: 'world-inngest',
80
+ // The subject types the twin SERVES over its own API — the R2 resource-level claim, held in
81
+ // lockstep with inngest-twin.ts's INNGEST_RESOURCE_TYPES. `signing_key` IS here: the world's
82
+ // executor<->SDK signing key is minted once from real entropy and persisted
83
+ // (inngest-signing.ts), and `GET /api/v2/keys/signing` establishes it on first touch — a real
84
+ // held subject, not a name with nothing behind it. What that route serves stays masked.
85
+ resources: ['event', 'function', 'run', 'step', 'signing_key'],
86
+ // World wiring — declared HERE, not in init.ts's central table (descriptor-first exemplar (adding-a-twin.md §3)).
87
+ endpointEnv: {
88
+ name: 'INNGEST_BASE_URL',
89
+ templates: { INNGEST_EVENT_API_BASE_URL: '${url}' },
90
+ note: 'no injector entry: the inngest SDK is base-URL-configured through INNGEST_BASE_URL / INNGEST_EVENT_API_BASE_URL.',
91
+ },
92
+ specSource: 'inngest-conformance.ts (self-referential endpoint/resource inventory) — grounded read-only against inngest.com/docs, the installed inngest@4.12.0 npm package source (npm pack), and the live-fetched v2 REST OpenAPI document (api-docs.inngest.com/api-specs/v2.json); see spec-sources.json.',
93
+ description: 'Inngest durable-execution workflow-orchestration twin — event send (Event API), function register/introspect (SDK exec protocol), and a kernel-persisted step machine (step.run memoize/replay, sleep/sleepUntil instant-advance, waitForEvent gating, exact retry-attempt counters), run status/get/list, event-based cancellation, HMAC-SHA256 executor<->SDK request signing. Twin does not execute real user code — steps are declared, not run. Kernel-backed.',
94
+ browserRouting: { apiPathPrefix: '/', loaderHost: 'http://localhost:8288' },
95
+ // INTERCEPTION RULING — hostsNone, the pack's own home for it: SDK is base-URL-configured by
96
+ // design (options.baseUrl / INNGEST_BASE_URL / INNGEST_EVENT_API_BASE_URL, verified in the
97
+ // pack README) — worlds wire the twin through those vars.
98
+ hostsNone:
99
+ "SDK is base-URL-configured by design (options.baseUrl / INNGEST_BASE_URL / INNGEST_EVENT_API_BASE_URL, verified in the pack README) — worlds wire the twin through those vars",
100
+ // Adoption, moved off the central maps unchanged (descriptor-first back-migration, adding-a-twin.md §3,
101
+ // 2026-08-31).
102
+ adoption: {
103
+ // Inngest's official Python SDK - same distribution name as the npm one.
104
+ pypi: ['inngest'],
105
+ sdks: ['inngest'], envStems: ['INNGEST'],
106
+ },
107
+ };
@@ -0,0 +1,450 @@
1
+ // Inngest's CLIENT-SIDE RATE BUDGET — the pack's DECLARATION (the numbers) plus `guardInngestClient`,
2
+ // the choke point every live Inngest call goes through. The MECHANISM — the durable token-keyed
3
+ // ledger, the rolling window, reserve-under-lock, the `Retry-After`/429 cooldown, fail-CLOSED on a
4
+ // corrupt ledger — lives ONCE in the vendor-agnostic kernel (`@volter/world-core` → `rateBudget.ts`).
5
+ // Read that module's header for the full rationale AND for the honest list of what the guard does
6
+ // NOT guarantee (an injected clock or ledger path still defeats it — it guards carelessness, not
7
+ // malice). This module is modeled on notion-budget.ts, the reference injected-client decorator.
8
+ //
9
+ // ── WHY INNGEST NEEDS ONE ───────────────────────────────────────────────────────────
10
+ // Inngest's REST reference (inngest.com/docs/reference/rest-api, api-docs.inngest.com) documents NO
11
+ // rate limit, no 429, and no Retry-After for /v1/events or /v1/runs. The single rate-limit statement
12
+ // in the whole API reference is an annotation on a DIFFERENT endpoint — "Fetch function run jobs …
13
+ // This endpoint is rate limited and cached for 5 seconds" — with no number, and that endpoint is not
14
+ // part of this connector's surface. The documented usage limits are payload-shape limits (5000 events
15
+ // per request, 10 MiB batches, per-plan event payload sizes), not rates. So the vendor's rate
16
+ // behaviour here is entirely undocumented, which is the case a client-side ceiling exists for.
17
+ //
18
+ // ── HOW THE CEILING WAS CHOSEN ──────────────────────────────────────────────────────────────
19
+ // NO DOCUMENTED SCALAR LIMIT exists for the endpoints this connector calls, so this budget does NOT
20
+ // claim one. It is pinned to the kernel's conservative fallback (DEFAULT_RATE_BUDGET: 60 weighted
21
+ // units / 60s at weight 2 = 30 calls/minute) — no more permissive than an undeclared vendor already
22
+ // gets.
23
+ //
24
+ // ── WHAT THIS DOES NOT DO: PACE ─────────────────────────────────────────────────────────────
25
+ // It bounds the 60s AVERAGE; it does NOT bound the instantaneous rate. The window has no
26
+ // spacing, so a tight `await` loop can legitimately fire the whole allowance in a fraction of a
27
+ // second. In that shape the vendor's own 429 can arrive BEFORE this ceiling does, and the backstop
28
+ // is then the COOLDOWN: the guard reads the back-off off the thrown error (or the response's
29
+ // exhaustion headers) and refuses every later call without touching the vendor. So the honest claim
30
+ // is "bounds the 60s average, and converts the vendor's first 429 into a hard stop" — never
31
+ // "refuses before the vendor ever 429s". Pacing is the CALLER's job; this module REFUSES, it never
32
+ // sleeps (see the kernel header: it is deliberately not a scheduler). A tighter window would not
33
+ // close the gap, it would only turn every legitimate multi-page pull into a cascade of refusals.
34
+ //
35
+ // ── HOW THE WEIGHTS WERE CHOSEN (and what is a judgement call) ──────────────────────────────
36
+ // FLAT: every call costs 2. Both modeled endpoints are single fetch-by-id reads
37
+ // (`GET /runs/{runId}`, the event lookup), neither fans out, and Inngest publishes no per-endpoint
38
+ // rate. The one endpoint Inngest DOES annotate as rate limited ("fetch function run jobs", cached for
39
+ // 5 seconds, no number given) is not in this connector's surface — so there is nothing honest to
40
+ // price up. An unmodeled method reached through the guarded client is charged the same 2, never free.
41
+ import {
42
+ declareRateBudget,
43
+ rateBudgetPath,
44
+ rateBudgetWeight,
45
+ RateBudget,
46
+ RateBudgetError,
47
+ type RateBudgetDeclaration,
48
+ type RateBudgetOptions,
49
+ type RateBudgetReservation,
50
+ type RateBudgetSnapshot,
51
+ } from '@volter/world-core';
52
+ import type { InngestLikeClient } from './inngest-connector.ts';
53
+
54
+ const VENDOR = 'inngest';
55
+
56
+ /** Rolling window, in ms. Spend older than this is pruned. */
57
+ export const INNGEST_BUDGET_WINDOW_MS = 60_000;
58
+
59
+ /** Weighted units allowed inside one window. See the header for where this number comes from. */
60
+ export const INNGEST_BUDGET_CEILING = 60;
61
+
62
+ /** Seconds. A `Retry-After` above this means the credential is throttled hard — fail loudly. */
63
+ export const INNGEST_BUDGET_MAX_RETRY_AFTER_S = 300;
64
+
65
+ /** Per-call cost, keyed by the client method the guard is about to invoke. See the header. */
66
+ export const INNGEST_CALL_WEIGHTS = {
67
+ /** Every modeled call. Inngest publishes no per-endpoint rate to differentiate. */
68
+ other: 2,
69
+ } as const;
70
+
71
+ /**
72
+ * The client methods this pack PRICES BY NAME, as dotted paths into the injected client.
73
+ *
74
+ * Two kinds of entry: (a) every method this connector actually calls, and (b) an endpoint the
75
+ * VENDOR documents in a distinct tier which a consumer can reach through the guarded client even
76
+ * though this connector never calls it (see the weights section of the header). Used to build the
77
+ * guarded surface — and, in the pack's own suite, the counting fake.
78
+ *
79
+ * NOT a closed list, and not a claim about the injected client's shape. A path the real client does
80
+ * not have is SKIPPED (this connector's members are optional; inventing one would turn "observe
81
+ * nothing" into "call something that isn't there"), and a method absent from this list is still
82
+ * PRICED at `defaultWeight` when a caller reaches for it — an unmodeled endpoint must never be
83
+ * free, and dropping one would be worse than free because it would be invisible.
84
+ */
85
+ export const INNGEST_BUDGETED_METHODS = [
86
+ 'events.get',
87
+ 'runs.get',
88
+ ] as const;
89
+
90
+ /** THE PACK'S DECLARATION — pure data, the only Inngest-specific thing in the whole budget. */
91
+ export const INNGEST_RATE_BUDGET: RateBudgetDeclaration = {
92
+ windowMs: INNGEST_BUDGET_WINDOW_MS,
93
+ ceiling: INNGEST_BUDGET_CEILING,
94
+ defaultWeight: INNGEST_CALL_WEIGHTS.other,
95
+ maxRetryAfterSeconds: INNGEST_BUDGET_MAX_RETRY_AFTER_S,
96
+ // Ordered: the kernel prices FIRST-MATCH-WINS, so the tightest tier is listed first.
97
+ rules: [],
98
+ reason:
99
+ "Inngest documents NO rate limit, no 429 and no Retry-After for the REST endpoints this connector calls " +
100
+ "(/v1/events, /v1/runs); its published usage limits are payload-shape limits (5000 events/request, 10 MiB " +
101
+ "batches) rather than rates. The only rate-limit statement in its whole API reference annotates a different " +
102
+ "endpoint (\"fetch function run jobs … rate limited and cached for 5 seconds\") and gives no number. With no " +
103
+ "documented figure to aim at, this ceiling is pinned to the kernel's conservative fallback (60 units / 60s " +
104
+ "at weight 2 = 30 calls/min) rather than invented — no more permissive than an undeclared vendor already " +
105
+ "gets. Weights are flat because both modeled endpoints are single fetch-by-id reads and no per-endpoint " +
106
+ "rate is published.",
107
+ };
108
+
109
+ // Declared at module load, so merely importing this module (which `inngest-connector.ts` does) is
110
+ // enough to arm the real ceiling. A budget constructed BEFORE this runs falls back to the kernel's
111
+ // DEFAULT_RATE_BUDGET — which is tighter in call COUNT but prices every call at 2, so for a vendor
112
+ // with an expensive endpoint the fallback is CHEAPER there, not safer. `RateBudget` reads its policy
113
+ // LIVE precisely so this declaration takes effect the moment it lands, and constructing through the
114
+ // subclass below (whose module IS this one) makes the ordering a non-issue in practice.
115
+ declareRateBudget(VENDOR, INNGEST_RATE_BUDGET);
116
+
117
+ /** Price one Inngest call by its client method key (a dotted path, e.g. `events.get`). */
118
+ export function inngestCallWeight(method: string): number {
119
+ return rateBudgetWeight(VENDOR, method);
120
+ }
121
+
122
+ /** Where Inngest's ledger lives. Token-keyed and cwd-independent by default (the vendor limits per
123
+ * credential, so a cwd-scoped ledger would hand the same credential a fresh allowance in every
124
+ * checkout, worktree and CI matrix leg); pass `root` to opt into world-scoped accounting. */
125
+ export function inngestBudgetPath(opts: { root?: string; token?: string } | string = {}): string {
126
+ const o = typeof opts === 'string' ? { root: opts } : opts;
127
+ // VENDOR spread LAST: a loosely-typed `{ vendor: 'other', … }` slipping through (TypeScript's
128
+ // excess-property check only catches object literals) must not redirect this pack's ledger to
129
+ // another vendor's file.
130
+ return rateBudgetPath({ ...o, vendor: VENDOR });
131
+ }
132
+
133
+ /** Construction options for Inngest's budget. The vendor is fixed; everything else may only TIGHTEN. */
134
+ export type InngestBudgetOptions = Omit<RateBudgetOptions, 'vendor'>;
135
+
136
+ /**
137
+ * Inngest's budget — the shared kernel guard bound to this vendor's declaration. A real subclass,
138
+ * not an alias, so `budget instanceof InngestBudget` means "a budget that accounts against this
139
+ * vendor's ledger under this vendor's ceiling": another vendor's `RateBudget` (with its own,
140
+ * possibly larger, ceiling) is NOT assignable where one of these is required.
141
+ */
142
+ export class InngestBudget extends RateBudget {
143
+ constructor(opts: InngestBudgetOptions = {}) {
144
+ super({ ...opts, vendor: VENDOR });
145
+ }
146
+ }
147
+
148
+ export type { RateBudgetErrorKind as InngestBudgetErrorKind } from '@volter/world-core';
149
+ export { RateBudgetError as InngestBudgetError } from '@volter/world-core';
150
+ export type InngestBudgetReservation = RateBudgetReservation;
151
+ export type InngestBudgetSnapshot = RateBudgetSnapshot;
152
+
153
+ /** What every budgeted connector entrypoint accepts. There is deliberately no option that turns the
154
+ * guard OFF — only ones that say WHICH ledger and clock to account against. */
155
+ export type InngestBudgetedOptions = {
156
+ /** An existing budget to share across calls. Omit and one is constructed. Cannot be null. */
157
+ budget?: InngestBudget;
158
+ /** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
159
+ budgetOptions?: InngestBudgetOptions;
160
+ };
161
+
162
+ /** Pull the budget wiring out of a caller's opts bag, so an entrypoint can forward it verbatim. */
163
+ export function inngestBudgetOf(opts: InngestBudgetedOptions): InngestBudgetedOptions {
164
+ return {
165
+ ...(opts.budget !== undefined ? { budget: opts.budget } : {}),
166
+ ...(opts.budgetOptions !== undefined ? { budgetOptions: opts.budgetOptions } : {}),
167
+ };
168
+ }
169
+
170
+ // ── the choke point ─────────────────────────────────────────────────────────────────────────
171
+
172
+ /**
173
+ * Marks a client this module has already wrapped, so guarding twice cannot charge twice.
174
+ *
175
+ * A MODULE-PRIVATE `Symbol()`, deliberately not `Symbol.for()`: a global-registry symbol is
176
+ * reachable BY NAME, so any caller could stamp `client[Symbol.for(…)] = anything` on a RAW client and
177
+ * the guard would hand it straight back UNGUARDED — a one-line bypass of the whole budget. With a
178
+ * private symbol the only way to be branded is to have been wrapped by this function. The cost is
179
+ * that two copies of this module in one dependency tree would each wrap (double-charging a call);
180
+ * that is the SAFE direction, and over-charging is the tradeoff this module takes everywhere else.
181
+ */
182
+ const GUARDED: unique symbol = Symbol('@volter/twin-inngest.budget.guarded');
183
+
184
+ type Guarded = { [GUARDED]?: unknown };
185
+
186
+ /** Is this client already behind a budget? Returns the budget it is behind, if so. */
187
+ export function inngestClientBudget(client: unknown): RateBudget | undefined {
188
+ const mark = (client as Guarded | null)?.[GUARDED];
189
+ // Belt and braces: only a REAL budget counts as "already guarded". A non-RateBudget value here
190
+ // could only come from a forged brand, and the answer to a forgery is to wrap anyway.
191
+ return mark instanceof RateBudget ? mark : undefined;
192
+ }
193
+
194
+ /** Charge a call keyed by `method`, invoke it, settle. Returns whatever the call returned. */
195
+ type Charge = (method: string, invoke: (...args: unknown[]) => unknown) => (...args: unknown[]) => unknown;
196
+
197
+ /**
198
+ * Wrap an INJECTED Inngest client so EVERY call it makes is charged against the shared budget
199
+ * BEFORE the request goes out. This pack's connector never constructs the transport itself (the
200
+ * consumer injects a client that satisfies `InngestLikeClient` structurally), so the guard is a
201
+ * DECORATOR rather than a factory — which is exactly why every connector entrypoint applies it
202
+ * UNCONDITIONALLY instead of trusting the caller to have done it.
203
+ *
204
+ * IDEMPOTENT: wrapping an already-guarded client returns it unchanged, so a caller who forgot is
205
+ * protected and a caller who wrapped deliberately is not double-charged.
206
+ *
207
+ * A method that THROWS is still inspected: an SDK typically RAISES on a 429 rather than returning
208
+ * it, and that error's back-off is exactly the signal that must become a persisted cooldown. Losing
209
+ * it would leave the ledger cheerfully spending into a throttled credential. The original error is
210
+ * always re-raised afterwards — the budget never swallows a vendor failure — EXCEPT when the
211
+ * back-off is beyond the cap, where the budget's own louder "stop calling" error takes precedence.
212
+ */
213
+ export function guardInngestClient(client: InngestLikeClient, opts: InngestBudgetedOptions = {}): InngestLikeClient {
214
+ // There is no value a caller can pass to end up with an UNGUARDED client. `null`/`undefined` (or
215
+ // omitting it) build the default budget; anything that is not a REAL `InngestBudget` is refused
216
+ // loudly rather than trusted — a duck-typed stand-in with a no-op `checkBudget` would otherwise be
217
+ // the one clean way around the guard. Validated BEFORE the already-guarded early return, so
218
+ // `guardInngestClient(alreadyGuarded, { budget: impostor })` is refused too rather than silently
219
+ // ignoring the impostor.
220
+ if (opts.budget !== undefined && opts.budget !== null && !(opts.budget instanceof InngestBudget)) {
221
+ throw new Error('guardInngestClient: `budget` must be a InngestBudget — refusing to guard a Inngest client with an unverified rate guard');
222
+ }
223
+ if (inngestClientBudget(client)) return client;
224
+ const budget = opts.budget instanceof InngestBudget
225
+ ? opts.budget
226
+ : new InngestBudget({
227
+ // The default ledger is keyed by a hash of the credential — the vendor limits per credential,
228
+ // so a cwd-scoped ledger would hand it a fresh allowance per worktree/CI leg.
229
+ //
230
+ // HONESTLY: this pack does NOT hold the credential — the consumer's injected client does — so
231
+ // `INNGEST_SIGNING_KEY` is a BEST-EFFORT stand-in for it, not the real thing. If that env var names a
232
+ // different account than the injected client, spend is booked against the wrong ledger; if it
233
+ // is unset, every unattributed Inngest credential on the machine shares one (over-tight,
234
+ // which is the safe direction). A caller who knows the credential should say so:
235
+ // `budgetOptions: { token }` overrides this, and does so deliberately last in the spread.
236
+ ...(process.env.INNGEST_SIGNING_KEY !== undefined ? { token: process.env.INNGEST_SIGNING_KEY } : {}),
237
+ ...(opts.budgetOptions ?? {}),
238
+ });
239
+
240
+ /** Settle a reservation from whatever the call produced. May THROW (a back-off past the cap). */
241
+ const settle = (weight: number, reservation: RateBudgetReservation, v: unknown): void => {
242
+ const status = responseStatus(v);
243
+ const headers = responseHeaders(v);
244
+ if (status !== undefined || headers !== undefined) budget.recordCall(weight, headers, { status, reservation });
245
+ else budget.recordCall(weight, undefined, { reservation });
246
+ };
247
+
248
+ /**
249
+ * Settle once the call RESOLVED: the vendor has answered. recordCall arms any cooldown before
250
+ * it throws (a back-off beyond the cap), so that refusal is swallowed and the answer kept — a
251
+ * write the vendor accepted is never reported failed and performed again on retry. A resolved
252
+ * answer that carries a non-2xx status still lets the louder refusal win.
253
+ */
254
+ const settleAnswered = (weight: number, reservation: RateBudgetReservation, v: unknown): void => {
255
+ try {
256
+ settle(weight, reservation, v);
257
+ } catch (e) {
258
+ const status = responseStatus(v);
259
+ if (!(e instanceof RateBudgetError) || (status !== undefined && (status < 200 || status >= 300))) throw e;
260
+ }
261
+ };
262
+
263
+ /**
264
+ * Charge, call, settle — for a MODELED method, whose interface declares it `async`. Refusing
265
+ * REJECTS rather than throwing synchronously, so `client.x().catch(…)` behaves exactly as it does
266
+ * on an unguarded client. `checkBudget` RESERVES under lock, so nothing after its line runs when
267
+ * the budget refuses: the request is never made.
268
+ */
269
+ const chargeAsync = (method: string, invoke: (...args: unknown[]) => unknown) =>
270
+ async (...args: unknown[]): Promise<unknown> => {
271
+ const weight = budget.weightFor(method);
272
+ const reservation = budget.checkBudget(weight);
273
+ try {
274
+ const res = await invoke(...args);
275
+ settleAnswered(weight, reservation, res);
276
+ return res;
277
+ } catch (e) {
278
+ settle(weight, reservation, e); // may throw its own louder refusal, which wins
279
+ throw e;
280
+ }
281
+ };
282
+
283
+ /**
284
+ * Charge, call, settle — for an UNMODELED member reached through the passthrough Proxy, where the
285
+ * shape is unknown.
286
+ *
287
+ * Deliberately NOT `async`. Some vendor SDKs (twilio-shaped ones) build requests through
288
+ * SYNCHRONOUS chained accessors — `client.a.b('sid').c.create()` — where the intermediate calls
289
+ * return a resource context, not a promise. An `async` wrapper would turn every one of those into
290
+ * a `Promise` and `.c` would come back `undefined`: the guard would BREAK the client instead of
291
+ * guarding it. So a non-thenable return is treated as an accessor — still charged (we cannot know
292
+ * before calling, and over-charging is the safe direction) and wrapped, so the eventual async leaf
293
+ * is charged too rather than escaping the budget. The cost of the sync shape is that a REFUSAL on
294
+ * this path throws synchronously instead of rejecting; that is the honest signal for an accessor,
295
+ * and the modeled surface above (every method this connector actually calls) does not have it.
296
+ */
297
+ const charge: Charge = (method, invoke) => (...args) => {
298
+ const weight = budget.weightFor(method);
299
+ const reservation = budget.checkBudget(weight);
300
+ let out: unknown;
301
+ try {
302
+ out = invoke(...args);
303
+ } catch (e) {
304
+ settle(weight, reservation, e); // may throw its own louder refusal, which wins
305
+ throw e;
306
+ }
307
+ if (!isThenable(out)) {
308
+ settle(weight, reservation, undefined);
309
+ return out !== null && (typeof out === 'object' || typeof out === 'function')
310
+ ? proxyThrough({}, out as object, method, charge)
311
+ : out;
312
+ }
313
+ return out.then(
314
+ (res) => { settleAnswered(weight, reservation, res); return res; },
315
+ (e: unknown) => { settle(weight, reservation, e); throw e; },
316
+ );
317
+ };
318
+
319
+ // Build the charged surface from INNGEST_BUDGETED_METHODS (see its docstring for why an absent path is
320
+ // SKIPPED rather than stubbed).
321
+ const guarded: Record<string, unknown> & Guarded = {};
322
+ for (const path of INNGEST_BUDGETED_METHODS) {
323
+ const segs = path.split('.');
324
+ const leaf = segs[segs.length - 1]!;
325
+ let owner: Record<string, unknown> | undefined = client as unknown as Record<string, unknown>;
326
+ for (const seg of segs.slice(0, -1)) owner = owner?.[seg] as Record<string, unknown> | undefined;
327
+ if (typeof owner?.[leaf] !== 'function') continue;
328
+ const realOwner = owner;
329
+ let node: Record<string, unknown> = guarded;
330
+ for (const seg of segs.slice(0, -1)) node = (node[seg] ??= {}) as Record<string, unknown>;
331
+ // The method is resolved at CALL time, not here, so a client whose method is swapped later is
332
+ // still charged for whatever it actually runs.
333
+ node[leaf] = chargeAsync(path, (...args) => (realOwner[leaf] as (...a: unknown[]) => unknown).apply(realOwner, args));
334
+ }
335
+ Object.defineProperty(guarded, GUARDED, { value: budget, enumerable: false });
336
+
337
+ // The surface above is what this connector calls. A real client has MORE — and a consumer who
338
+ // needs any of it must not be forced to keep the RAW client alongside, because every call through
339
+ // that would be unbudgeted. So the guarded object is a Proxy: known members come from the map
340
+ // above, anything else is taken from the real client and PRICED at `defaultWeight`.
341
+ return proxyThrough(guarded, client as object, '', charge) as InngestLikeClient;
342
+ }
343
+
344
+ /**
345
+ * Members JavaScript itself asks for. Wrapping any of these turns the object into something that
346
+ * looks thenable / mis-reports its own identity, which breaks `await`, `instanceof` and logging —
347
+ * so they always come from the target untouched, never priced.
348
+ */
349
+ const NEVER_WRAP = new Set(['then', 'catch', 'finally', 'constructor', 'prototype', 'toJSON', 'toString', 'valueOf', 'inspect']);
350
+
351
+ /** Does this look like a promise? (Only a thenable gets the settle-on-resolution treatment.) */
352
+ function isThenable(v: unknown): v is Promise<unknown> {
353
+ return v !== null && (typeof v === 'object' || typeof v === 'function') && typeof (v as { then?: unknown }).then === 'function';
354
+ }
355
+
356
+ /**
357
+ * Symbols that name an ITERATION PROTOCOL. Reaching for one of these on a vendor object is how a
358
+ * paginator is driven (`for await (const page of client.things.list())`), i.e. it is the doorway to
359
+ * an unbounded sequence of REQUESTS — exactly what this budget exists to bound — so they are charged
360
+ * and their result is kept behind the proxy. Every other symbol (`Symbol.toStringTag`,
361
+ * `nodejs.util.inspect.custom`, …) is metadata rather than a request and passes through untouched.
362
+ */
363
+ const ITERATOR_SYMBOLS = new Set<symbol>([Symbol.asyncIterator, Symbol.iterator]);
364
+
365
+ /**
366
+ * Serve `known` where it has the member; otherwise price a passthrough to `real`. Applied
367
+ * recursively, so a namespace member this pack never modeled is charged at `defaultWeight` rather
368
+ * than coming back `undefined`.
369
+ *
370
+ * CALLABLE NAMESPACES (§9 finding): a member can be BOTH a function and a namespace — twilio's
371
+ * `client.messages` is called as `client.messages(sid)` to get one message's context AND read as
372
+ * `client.messages.list()`. An earlier version returned the modeled node as a plain object whenever
373
+ * this pack modeled any child of it, which silently DROPPED the call signature and every unmodeled
374
+ * sibling — the module's own docstring calls a dropped member "worse than free, because it is
375
+ * invisible", and that is what it was doing. So when `real` is callable the proxy target is a
376
+ * function and an `apply` trap charges the call, while `known` stays the source of modeled members.
377
+ */
378
+ function proxyThrough(known: object, real: object, prefix: string, charge: Charge, owner?: object): object {
379
+ const callable = typeof real === 'function';
380
+ // The target must itself be callable for the `apply` trap to exist at all. `known` stays the
381
+ // source of modeled members, read explicitly below rather than through the target.
382
+ const target: object = callable ? function proxied() { /* every call goes through the trap */ } : known;
383
+ return new Proxy(target, {
384
+ apply(_t, _thisArg, args: unknown[]) {
385
+ // Calling the namespace is itself a request-builder hop: charge it, and keep the result
386
+ // behind the proxy (charge() re-proxies a non-thenable) so the eventual leaf is charged too.
387
+ // `owner` is the object the function was read from — dropping it would silently break every
388
+ // method that relies on `this`.
389
+ return charge(prefix || 'call', (...a) => (real as (...x: unknown[]) => unknown).apply(owner, a))(...args);
390
+ },
391
+ get(_t, prop, receiver) {
392
+ if (typeof prop === 'symbol') {
393
+ const ownSym = Reflect.get(known, prop, receiver);
394
+ if (ownSym !== undefined) return ownSym; // the GUARDED brand, and anything we model
395
+ const fromSym = (real as Record<symbol, unknown> | null)?.[prop];
396
+ if (ITERATOR_SYMBOLS.has(prop) && typeof fromSym === 'function') {
397
+ return charge(`${prefix}[${prop.description ?? 'iterator'}]`, (...args) => (fromSym as (...a: unknown[]) => unknown).apply(real, args));
398
+ }
399
+ return fromSym;
400
+ }
401
+ const name = String(prop);
402
+ if (NEVER_WRAP.has(name)) return Reflect.get(known, prop, receiver);
403
+ const key = prefix ? `${prefix}.${name}` : name;
404
+ const own = Reflect.get(known, prop, receiver);
405
+ const from = (real as Record<string, unknown> | null)?.[name];
406
+ // A member we model: the charged wrapper (a function) or a namespace we must keep descending
407
+ // into, so an unmodeled sibling is still priced rather than dropped.
408
+ if (typeof own === 'function') return own;
409
+ if (own && typeof own === 'object') {
410
+ // `from` may be an object OR a CALLABLE namespace — both keep descending, which is what
411
+ // preserves `client.messages(sid)` alongside the modeled `client.messages.list()`.
412
+ return from && (typeof from === 'object' || typeof from === 'function')
413
+ ? proxyThrough(own, from, key, charge, real)
414
+ : own;
415
+ }
416
+ if (own !== undefined) return own;
417
+ // A member only the real client has. Functions go through proxyThrough too, so one that also
418
+ // carries members (a callable namespace) keeps both its call signature and its siblings.
419
+ if (typeof from === 'function') return proxyThrough({}, from, key, charge, real);
420
+ if (from && typeof from === 'object') return proxyThrough({}, from, key, charge, real);
421
+ return from;
422
+ },
423
+ has(_t, prop) {
424
+ return Reflect.has(known, prop) || (real !== null && Reflect.has(real, prop));
425
+ },
426
+ });
427
+ }
428
+
429
+ /** The HTTP status a value carries, if it looks like one (an SDK error, or a raw response). */
430
+ function responseStatus(v: unknown): number | undefined {
431
+ const s = (v as { status?: unknown } | null)?.status;
432
+ return typeof s === 'number' && Number.isFinite(s) ? s : undefined;
433
+ }
434
+
435
+ /**
436
+ * Response/error headers as a plain lower-cased record, or `undefined` when there are none. Accepts
437
+ * a `Headers` instance, a `Map`, or a plain object — an SDK's error type changes shape across
438
+ * versions and none of the three is worth depending on.
439
+ */
440
+ function responseHeaders(v: unknown): Record<string, string> | undefined {
441
+ const h = (v as { headers?: unknown } | null)?.headers;
442
+ if (!h || typeof h !== 'object') return undefined;
443
+ const out: Record<string, string> = {};
444
+ if (typeof (h as Headers).forEach === 'function') {
445
+ (h as Headers).forEach((value: string, key: string) => { out[String(key).toLowerCase()] = String(value); });
446
+ } else {
447
+ for (const [k, value] of Object.entries(h as Record<string, unknown>)) out[k.toLowerCase()] = String(value);
448
+ }
449
+ return Object.keys(out).length > 0 ? out : undefined;
450
+ }