@volter/twin-upstashvector 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/LICENSE +202 -0
- package/README.md +233 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +45 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +106 -0
- package/dist/src/upstashvector-budget.d.ts +84 -0
- package/dist/src/upstashvector-budget.js +443 -0
- package/dist/src/upstashvector-capabilities.d.ts +4 -0
- package/dist/src/upstashvector-capabilities.js +1108 -0
- package/dist/src/upstashvector-conformance.d.ts +7 -0
- package/dist/src/upstashvector-conformance.js +163 -0
- package/dist/src/upstashvector-connector.d.ts +109 -0
- package/dist/src/upstashvector-connector.js +286 -0
- package/dist/src/upstashvector-filter.d.ts +80 -0
- package/dist/src/upstashvector-filter.js +564 -0
- package/dist/src/upstashvector-server.d.ts +32 -0
- package/dist/src/upstashvector-server.js +56 -0
- package/dist/src/upstashvector-store.d.ts +248 -0
- package/dist/src/upstashvector-store.js +883 -0
- package/dist/src/upstashvector-twin.d.ts +67 -0
- package/dist/src/upstashvector-twin.js +287 -0
- package/package.json +51 -0
- package/src/cli.ts +47 -0
- package/src/index.ts +193 -0
- package/src/upstashvector-budget.ts +489 -0
- package/src/upstashvector-capabilities.ts +1242 -0
- package/src/upstashvector-conformance.ts +175 -0
- package/src/upstashvector-connector.ts +328 -0
- package/src/upstashvector-filter.ts +525 -0
- package/src/upstashvector-server.ts +86 -0
- package/src/upstashvector-store.ts +944 -0
- package/src/upstashvector-twin.ts +347 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
// UPSTASH VECTOR'S CLIENT-SIDE RATE BUDGET — the pack's DECLARATION (the numbers) plus
|
|
2
|
+
// `guardUpstashVectorClient`, the choke point every live Upstash Vector call goes through. The
|
|
3
|
+
// MECHANISM — the durable token-keyed ledger, the rolling window, reserve-under-lock, the
|
|
4
|
+
// `Retry-After`/429 cooldown, fail-CLOSED on a corrupt ledger — lives ONCE in the vendor-agnostic
|
|
5
|
+
// kernel (`@volter/world-core` → `rateBudget.ts`). Read that module's header for the full rationale AND
|
|
6
|
+
// for the honest list of what the guard does NOT guarantee (an injected clock or ledger path still
|
|
7
|
+
// defeats it — it guards carelessness, not malice). This module is modeled on notion-budget.ts, the
|
|
8
|
+
// reference injected-client decorator.
|
|
9
|
+
//
|
|
10
|
+
// ── UPSTASH VECTOR PUBLISHES NO PER-SECOND LIMIT, AND THIS DECLARATION SAYS SO ─────────────────
|
|
11
|
+
// This is the OPPOSITE situation to this pack's sibling `upstash`, and the difference decides
|
|
12
|
+
// the number. Upstash Redis publishes a scalar rate (10,000 commands/sec on its billing page), which
|
|
13
|
+
// is what licenses that pack to sit ABOVE the kernel's austere fallback. Upstash **Vector** publishes
|
|
14
|
+
// no requests-per-second figure anywhere — the pricing/limits tables give per-DAY caps and per-request
|
|
15
|
+
// billing, not a rate:
|
|
16
|
+
// • Pay-As-You-Go bills **per request** ($0.4 per 100K requests, upstash.com/pricing/vector);
|
|
17
|
+
// • the Free tier caps **queries per DAY** (10,000/day — ~6.9/minute sustained), not per second;
|
|
18
|
+
// • the published per-plan limits are about SHAPE, not rate: max dimension 1,536 (Free) to 5,000
|
|
19
|
+
// (Pro), max topK 1,000, metadata 48 KB/vector, data 1 MB/vector, storage 1 GB (Free) to 1 TB.
|
|
20
|
+
// docs/contributing/architecture.md D8 and docs/contributing/adding-a-twin.md are explicit about what to do here: when the vendor
|
|
21
|
+
// publishes no scalar limit, SAY SO in `reason` and stay AT OR UNDER the fallback — never dress a
|
|
22
|
+
// guess up as a vendor fact. So this pack declares exactly the kernel's own austere allowance
|
|
23
|
+
// (60 units / 60s at the default weight of 2 = **30 requests per minute**) and does not claim a
|
|
24
|
+
// vendor number it could not read.
|
|
25
|
+
//
|
|
26
|
+
// ── WHAT THIS CEILING DOES *NOT* PROTECT (§9 round 1 corrected an inversion here) ──────────────
|
|
27
|
+
// An earlier draft of this header and of `reason` claimed the ceiling was "~4x TIGHTER than the
|
|
28
|
+
// free tier's own daily query budget spread evenly, so it cannot be the thing that trips a real
|
|
29
|
+
// account". That was BACKWARDS, and this module's own test encodes the correct direction. The
|
|
30
|
+
// arithmetic: the Free tier allows 10,000 queries per DAY = ~6.94/minute sustained; this ceiling
|
|
31
|
+
// allows 30/minute = 43,200/day. So the budget is ~4.3x LOOSER than the free daily allowance, and
|
|
32
|
+
// sustained pulling AT the ceiling would exhaust a Free index's whole daily quota in about 5.5
|
|
33
|
+
// hours.
|
|
34
|
+
//
|
|
35
|
+
// The honest claim is therefore narrower: this ceiling bounds a BURST (it is a 60-second rolling
|
|
36
|
+
// window, and it refuses fail-closed), and it is the tightest burst that D8 permits without a
|
|
37
|
+
// published vendor rate to cite. It does NOT bound daily spend, because a rolling-minute window
|
|
38
|
+
// structurally cannot — bounding a daily quota would need a day-length window, which the kernel's
|
|
39
|
+
// RateBudget does not model. A caller running a long or repeated sync against a Free index must
|
|
40
|
+
// still watch that quota themselves.
|
|
41
|
+
//
|
|
42
|
+
// ── HOW THE WEIGHTS WERE CHOSEN ───────────────────────────────────────────────────────────────
|
|
43
|
+
// `range` costs 6, three times everything else — the ONE call here whose cost is unbounded in the
|
|
44
|
+
// SIZE OF THE CUSTOMER'S INDEX rather than in what the caller asked for. A vector index is
|
|
45
|
+
// routinely millions of rows and `pullUpstashVectorRange` is the only entrypoint that LOOPS, so it
|
|
46
|
+
// is the one most likely to be pointed at a real index by accident. Tripling its price is what
|
|
47
|
+
// makes the ceiling bite on an index walk (10 pages/minute) long before it bites on the handful of
|
|
48
|
+
// `info`/`listNamespaces` calls a legitimate inspection makes. A heavier weight can only TIGHTEN
|
|
49
|
+
// the allowance, which is the direction that needs no vendor citation.
|
|
50
|
+
//
|
|
51
|
+
// ── WHAT THIS DOES NOT DO: PACE ───────────────────────────────────────────────────────────────
|
|
52
|
+
// It bounds the 60s AVERAGE; it does NOT bound the instantaneous rate. The window has no spacing,
|
|
53
|
+
// so a tight `await` loop can legitimately fire the whole allowance in a fraction of a second. In
|
|
54
|
+
// that shape a vendor 429 can arrive BEFORE this ceiling does, and the backstop is then the
|
|
55
|
+
// COOLDOWN: the guard reads the back-off off the thrown error (or the response's exhaustion
|
|
56
|
+
// headers) and refuses every later call without touching the vendor. So the honest claim is
|
|
57
|
+
// "bounds the 60s average, and converts the vendor's first 429 into a hard stop" — never "refuses
|
|
58
|
+
// before the vendor ever 429s". Pacing is the CALLER's job; this module REFUSES, it never sleeps
|
|
59
|
+
// (see the kernel header: it is deliberately not a scheduler).
|
|
60
|
+
import {
|
|
61
|
+
declareRateBudget,
|
|
62
|
+
rateBudgetPath,
|
|
63
|
+
rateBudgetWeight,
|
|
64
|
+
RateBudget,
|
|
65
|
+
RateBudgetError,
|
|
66
|
+
type RateBudgetDeclaration,
|
|
67
|
+
type RateBudgetOptions,
|
|
68
|
+
type RateBudgetReservation,
|
|
69
|
+
type RateBudgetSnapshot,
|
|
70
|
+
} from '@volter/world-core';
|
|
71
|
+
import type { UpstashVectorLikeClient } from './upstashvector-connector.ts';
|
|
72
|
+
|
|
73
|
+
const VENDOR = 'upstashvector';
|
|
74
|
+
|
|
75
|
+
/** Rolling window, in ms. Spend older than this is pruned. */
|
|
76
|
+
export const UPSTASHVECTOR_BUDGET_WINDOW_MS = 60_000;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Weighted units allowed inside one window — deliberately EQUAL to the kernel's
|
|
80
|
+
* `DEFAULT_RATE_BUDGET.ceiling`, because Upstash publishes no Vector rate limit to justify more.
|
|
81
|
+
* See the header.
|
|
82
|
+
*/
|
|
83
|
+
export const UPSTASHVECTOR_BUDGET_CEILING = 60;
|
|
84
|
+
|
|
85
|
+
/** Seconds. A `Retry-After` above this means the credential is throttled hard — fail loudly. */
|
|
86
|
+
export const UPSTASHVECTOR_BUDGET_MAX_RETRY_AFTER_S = 300;
|
|
87
|
+
|
|
88
|
+
/** Per-call cost, keyed by the client method the guard is about to invoke. See the header. */
|
|
89
|
+
export const UPSTASHVECTOR_CALL_WEIGHTS = {
|
|
90
|
+
/** `range` — an index WALK, whose cost scales with the customer's index, not the request. */
|
|
91
|
+
range: 6,
|
|
92
|
+
/** Every other modeled call (info/listNamespaces, and anything unclassified). */
|
|
93
|
+
other: 2,
|
|
94
|
+
} as const;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The client methods this pack PRICES BY NAME, as dotted paths into the injected client.
|
|
98
|
+
*
|
|
99
|
+
* NOT a closed list, and not a claim about the injected client's shape. A path the real client does
|
|
100
|
+
* not have is SKIPPED (this connector's members are optional; inventing one would turn "observe
|
|
101
|
+
* nothing" into "call something that isn't there"), and a method absent from this list is still
|
|
102
|
+
* PRICED at `defaultWeight` when a caller reaches for it — an unmodeled endpoint must never be
|
|
103
|
+
* free, and dropping one would be worse than free because it would be invisible.
|
|
104
|
+
*/
|
|
105
|
+
export const UPSTASHVECTOR_BUDGETED_METHODS = [
|
|
106
|
+
"info",
|
|
107
|
+
"listNamespaces",
|
|
108
|
+
"range",
|
|
109
|
+
"fetch",
|
|
110
|
+
"query",
|
|
111
|
+
] as const;
|
|
112
|
+
|
|
113
|
+
/** THE PACK'S DECLARATION — pure data, the only Upstash-Vector-specific thing in the whole budget. */
|
|
114
|
+
export const UPSTASHVECTOR_RATE_BUDGET: RateBudgetDeclaration = {
|
|
115
|
+
windowMs: UPSTASHVECTOR_BUDGET_WINDOW_MS,
|
|
116
|
+
ceiling: UPSTASHVECTOR_BUDGET_CEILING,
|
|
117
|
+
defaultWeight: UPSTASHVECTOR_CALL_WEIGHTS.other,
|
|
118
|
+
maxRetryAfterSeconds: UPSTASHVECTOR_BUDGET_MAX_RETRY_AFTER_S,
|
|
119
|
+
// Ordered: the kernel prices FIRST-MATCH-WINS, so the most expensive tier is listed first.
|
|
120
|
+
rules: [
|
|
121
|
+
{ match: "(^|\\.)range$", weight: UPSTASHVECTOR_CALL_WEIGHTS.range },
|
|
122
|
+
],
|
|
123
|
+
reason:
|
|
124
|
+
"Upstash publishes NO requests-per-second limit for Upstash Vector — this was checked and the " +
|
|
125
|
+
"absence is the finding, not an omission. The published figures are per-DAY caps and per-request " +
|
|
126
|
+
"billing, not a rate: Pay-As-You-Go bills per request ($0.4 per 100K, upstash.com/pricing/vector), " +
|
|
127
|
+
"the Free tier caps queries per DAY (10,000/day, ~6.9/minute sustained), and the per-plan limits " +
|
|
128
|
+
"govern SHAPE rather than rate (max dimension 1,536 Free to 5,000 Pro, max topK 1,000, metadata " +
|
|
129
|
+
"48 KB/vector, data 1 MB/vector, storage 1 GB Free to 1 TB Pro). Since no scalar rate is " +
|
|
130
|
+
"documented, this declaration stays AT the kernel's austere fallback rather than inventing " +
|
|
131
|
+
"headroom: 60 units / 60s at the default weight of 2 = 30 requests per minute. WHAT THAT DOES AND " +
|
|
132
|
+
"DOES NOT PROTECT, stated precisely (an earlier draft of this field had the comparison BACKWARDS " +
|
|
133
|
+
"and §9 caught it): the Free tier's 10,000 queries/DAY is ~6.94/minute sustained, while this " +
|
|
134
|
+
"ceiling allows 30/minute = 43,200/day — i.e. ~4.3x LOOSER than the free daily allowance, not " +
|
|
135
|
+
"tighter, and sustained pulling at the ceiling would exhaust a Free index's daily quota in about " +
|
|
136
|
+
"5.5 hours. So this bounds a BURST (a 60-second rolling window, fail-closed) and is the tightest " +
|
|
137
|
+
"burst D8 permits without a published vendor rate to cite; it does NOT bound daily spend, because " +
|
|
138
|
+
"a rolling-minute window structurally cannot. A caller running a long or repeated sync against a " +
|
|
139
|
+
"Free index must watch that daily quota themselves. (Contrast the sibling upstash pack, which IS more permissive than the " +
|
|
140
|
+
"fallback — and is so precisely because Upstash publishes a scalar 10,000 commands/sec for Redis.) " +
|
|
141
|
+
"range costs 6 because its cost scales with the CUSTOMER'S INDEX rather than the request — a " +
|
|
142
|
+
"vector index is routinely millions of rows — and it is the one entrypoint that loops; a heavier " +
|
|
143
|
+
"weight only TIGHTENS the allowance, which needs no vendor citation. Upstash does not publish the " +
|
|
144
|
+
"HTTP status or body of a Vector throttle response either, and this build had no live index to " +
|
|
145
|
+
"probe, so nothing here claims one.",
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Declared at module load, so merely importing this module (which `upstashvector-connector.ts` does) is
|
|
149
|
+
// enough to arm the real ceiling. A budget constructed BEFORE this runs falls back to the kernel's
|
|
150
|
+
// DEFAULT_RATE_BUDGET — which is tighter in call COUNT but prices every call at 2, so for a vendor
|
|
151
|
+
// with an expensive endpoint the fallback is CHEAPER there, not safer. `RateBudget` reads its policy
|
|
152
|
+
// LIVE precisely so this declaration takes effect the moment it lands, and constructing through the
|
|
153
|
+
// subclass below (whose module IS this one) makes the ordering a non-issue in practice.
|
|
154
|
+
declareRateBudget(VENDOR, UPSTASHVECTOR_RATE_BUDGET);
|
|
155
|
+
|
|
156
|
+
/** Price one Upstash Vector call by its client method name (e.g. `info`, `range`). */
|
|
157
|
+
export function upstashvectorCallWeight(method: string): number {
|
|
158
|
+
return rateBudgetWeight(VENDOR, method);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Where UpstashVector's ledger lives. Token-keyed and cwd-independent by default (the vendor limits per
|
|
162
|
+
* credential, so a cwd-scoped ledger would hand the same credential a fresh allowance in every
|
|
163
|
+
* checkout, worktree and CI matrix leg); pass `root` to opt into world-scoped accounting. */
|
|
164
|
+
export function upstashvectorBudgetPath(opts: { root?: string; token?: string } | string = {}): string {
|
|
165
|
+
const o = typeof opts === 'string' ? { root: opts } : opts;
|
|
166
|
+
// VENDOR spread LAST: a loosely-typed `{ vendor: 'other', … }` slipping through (TypeScript's
|
|
167
|
+
// excess-property check only catches object literals) must not redirect this pack's ledger to
|
|
168
|
+
// another vendor's file.
|
|
169
|
+
return rateBudgetPath({ ...o, vendor: VENDOR });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Construction options for UpstashVector's budget. The vendor is fixed; everything else may only TIGHTEN. */
|
|
173
|
+
export type UpstashVectorBudgetOptions = Omit<RateBudgetOptions, 'vendor'>;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* UpstashVector's budget — the shared kernel guard bound to this vendor's declaration. A real subclass,
|
|
177
|
+
* not an alias, so `budget instanceof UpstashVectorBudget` means "a budget that accounts against this
|
|
178
|
+
* vendor's ledger under this vendor's ceiling": another vendor's `RateBudget` (with its own,
|
|
179
|
+
* possibly larger, ceiling) is NOT assignable where one of these is required.
|
|
180
|
+
*/
|
|
181
|
+
export class UpstashVectorBudget extends RateBudget {
|
|
182
|
+
constructor(opts: UpstashVectorBudgetOptions = {}) {
|
|
183
|
+
super({ ...opts, vendor: VENDOR });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export type { RateBudgetErrorKind as UpstashVectorBudgetErrorKind } from '@volter/world-core';
|
|
188
|
+
export { RateBudgetError as UpstashVectorBudgetError } from '@volter/world-core';
|
|
189
|
+
export type UpstashVectorBudgetReservation = RateBudgetReservation;
|
|
190
|
+
export type UpstashVectorBudgetSnapshot = RateBudgetSnapshot;
|
|
191
|
+
|
|
192
|
+
/** What every budgeted connector entrypoint accepts. There is deliberately no option that turns the
|
|
193
|
+
* guard OFF — only ones that say WHICH ledger and clock to account against. */
|
|
194
|
+
export type UpstashVectorBudgetedOptions = {
|
|
195
|
+
/** An existing budget to share across calls. Omit and one is constructed. Cannot be null. */
|
|
196
|
+
budget?: UpstashVectorBudget;
|
|
197
|
+
/** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
|
|
198
|
+
budgetOptions?: UpstashVectorBudgetOptions;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/** Pull the budget wiring out of a caller's opts bag, so an entrypoint can forward it verbatim. */
|
|
202
|
+
export function upstashvectorBudgetOf(opts: UpstashVectorBudgetedOptions): UpstashVectorBudgetedOptions {
|
|
203
|
+
return {
|
|
204
|
+
...(opts.budget !== undefined ? { budget: opts.budget } : {}),
|
|
205
|
+
...(opts.budgetOptions !== undefined ? { budgetOptions: opts.budgetOptions } : {}),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── the choke point ─────────────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Marks a client this module has already wrapped, so guarding twice cannot charge twice.
|
|
213
|
+
*
|
|
214
|
+
* A MODULE-PRIVATE `Symbol()`, deliberately not `Symbol.for()`: a global-registry symbol is
|
|
215
|
+
* reachable BY NAME, so any caller could stamp `client[Symbol.for(…)] = anything` on a RAW client and
|
|
216
|
+
* the guard would hand it straight back UNGUARDED — a one-line bypass of the whole budget. With a
|
|
217
|
+
* private symbol the only way to be branded is to have been wrapped by this function. The cost is
|
|
218
|
+
* that two copies of this module in one dependency tree would each wrap (double-charging a call);
|
|
219
|
+
* that is the SAFE direction, and over-charging is the tradeoff this module takes everywhere else.
|
|
220
|
+
*/
|
|
221
|
+
const GUARDED: unique symbol = Symbol('@volter/twin-upstashvector.budget.guarded');
|
|
222
|
+
|
|
223
|
+
type Guarded = { [GUARDED]?: unknown };
|
|
224
|
+
|
|
225
|
+
/** Is this client already behind a budget? Returns the budget it is behind, if so. */
|
|
226
|
+
export function upstashvectorClientBudget(client: unknown): RateBudget | undefined {
|
|
227
|
+
const mark = (client as Guarded | null)?.[GUARDED];
|
|
228
|
+
// Belt and braces: only a REAL budget counts as "already guarded". A non-RateBudget value here
|
|
229
|
+
// could only come from a forged brand, and the answer to a forgery is to wrap anyway.
|
|
230
|
+
return mark instanceof RateBudget ? mark : undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Charge a call keyed by `method`, invoke it, settle. Returns whatever the call returned. */
|
|
234
|
+
type Charge = (method: string, invoke: (...args: unknown[]) => unknown) => (...args: unknown[]) => unknown;
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Wrap an INJECTED UpstashVector client so EVERY call it makes is charged against the shared budget
|
|
238
|
+
* BEFORE the request goes out. This pack's connector never constructs the transport itself (the
|
|
239
|
+
* consumer injects a client that satisfies `UpstashVectorLikeClient` structurally), so the guard is a
|
|
240
|
+
* DECORATOR rather than a factory — which is exactly why every connector entrypoint applies it
|
|
241
|
+
* UNCONDITIONALLY instead of trusting the caller to have done it.
|
|
242
|
+
*
|
|
243
|
+
* IDEMPOTENT: wrapping an already-guarded client returns it unchanged, so a caller who forgot is
|
|
244
|
+
* protected and a caller who wrapped deliberately is not double-charged.
|
|
245
|
+
*
|
|
246
|
+
* A method that THROWS is still inspected: an SDK typically RAISES on a 429 rather than returning
|
|
247
|
+
* it, and that error's back-off is exactly the signal that must become a persisted cooldown. Losing
|
|
248
|
+
* it would leave the ledger cheerfully spending into a throttled credential. The original error is
|
|
249
|
+
* always re-raised afterwards — the budget never swallows a vendor failure — EXCEPT when the
|
|
250
|
+
* back-off is beyond the cap, where the budget's own louder "stop calling" error takes precedence.
|
|
251
|
+
*/
|
|
252
|
+
export function guardUpstashVectorClient(client: UpstashVectorLikeClient, opts: UpstashVectorBudgetedOptions = {}): UpstashVectorLikeClient {
|
|
253
|
+
// There is no value a caller can pass to end up with an UNGUARDED client. `null`/`undefined` (or
|
|
254
|
+
// omitting it) build the default budget; anything that is not a REAL `UpstashVectorBudget` is refused
|
|
255
|
+
// loudly rather than trusted — a duck-typed stand-in with a no-op `checkBudget` would otherwise be
|
|
256
|
+
// the one clean way around the guard. Validated BEFORE the already-guarded early return, so
|
|
257
|
+
// `guardUpstashVectorClient(alreadyGuarded, { budget: impostor })` is refused too rather than silently
|
|
258
|
+
// ignoring the impostor.
|
|
259
|
+
if (opts.budget !== undefined && opts.budget !== null && !(opts.budget instanceof UpstashVectorBudget)) {
|
|
260
|
+
throw new Error('guardUpstashVectorClient: `budget` must be a UpstashVectorBudget — refusing to guard a UpstashVector client with an unverified rate guard');
|
|
261
|
+
}
|
|
262
|
+
if (upstashvectorClientBudget(client)) return client;
|
|
263
|
+
const budget = opts.budget instanceof UpstashVectorBudget
|
|
264
|
+
? opts.budget
|
|
265
|
+
: new UpstashVectorBudget({
|
|
266
|
+
// The default ledger is keyed by a hash of the credential — the vendor limits per credential,
|
|
267
|
+
// so a cwd-scoped ledger would hand it a fresh allowance per worktree/CI leg.
|
|
268
|
+
//
|
|
269
|
+
// HONESTLY: this pack does NOT hold the credential — the consumer's injected client does — so
|
|
270
|
+
// `UPSTASH_VECTOR_REST_TOKEN` is a BEST-EFFORT stand-in for it, not the real thing. If that env var names a
|
|
271
|
+
// different account than the injected client, spend is booked against the wrong ledger; if it
|
|
272
|
+
// is unset, every unattributed UpstashVector credential on the machine shares one (over-tight,
|
|
273
|
+
// which is the safe direction). A caller who knows the credential should say so:
|
|
274
|
+
// `budgetOptions: { token }` overrides this, and does so deliberately last in the spread.
|
|
275
|
+
...(process.env.UPSTASH_VECTOR_REST_TOKEN !== undefined ? { token: process.env.UPSTASH_VECTOR_REST_TOKEN } : {}),
|
|
276
|
+
...(opts.budgetOptions ?? {}),
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
/** Settle a reservation from whatever the call produced. May THROW (a back-off past the cap). */
|
|
280
|
+
const settle = (weight: number, reservation: RateBudgetReservation, v: unknown): void => {
|
|
281
|
+
const status = responseStatus(v);
|
|
282
|
+
const headers = responseHeaders(v);
|
|
283
|
+
if (status !== undefined || headers !== undefined) budget.recordCall(weight, headers, { status, reservation });
|
|
284
|
+
else budget.recordCall(weight, undefined, { reservation });
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Settle once the call RESOLVED: the vendor has answered. recordCall arms any cooldown before
|
|
289
|
+
* it throws (a back-off beyond the cap), so that refusal is swallowed and the answer kept — a
|
|
290
|
+
* write the vendor accepted is never reported failed and performed again on retry. A resolved
|
|
291
|
+
* answer that carries a non-2xx status still lets the louder refusal win.
|
|
292
|
+
*/
|
|
293
|
+
const settleAnswered = (weight: number, reservation: RateBudgetReservation, v: unknown): void => {
|
|
294
|
+
try {
|
|
295
|
+
settle(weight, reservation, v);
|
|
296
|
+
} catch (e) {
|
|
297
|
+
const status = responseStatus(v);
|
|
298
|
+
if (!(e instanceof RateBudgetError) || (status !== undefined && (status < 200 || status >= 300))) throw e;
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Charge, call, settle — for a MODELED method, whose interface declares it `async`. Refusing
|
|
304
|
+
* REJECTS rather than throwing synchronously, so `client.x().catch(…)` behaves exactly as it does
|
|
305
|
+
* on an unguarded client. `checkBudget` RESERVES under lock, so nothing after its line runs when
|
|
306
|
+
* the budget refuses: the request is never made.
|
|
307
|
+
*/
|
|
308
|
+
const chargeAsync = (method: string, invoke: (...args: unknown[]) => unknown) =>
|
|
309
|
+
async (...args: unknown[]): Promise<unknown> => {
|
|
310
|
+
const weight = budget.weightFor(method);
|
|
311
|
+
const reservation = budget.checkBudget(weight);
|
|
312
|
+
try {
|
|
313
|
+
const res = await invoke(...args);
|
|
314
|
+
settleAnswered(weight, reservation, res);
|
|
315
|
+
return res;
|
|
316
|
+
} catch (e) {
|
|
317
|
+
settle(weight, reservation, e); // may throw its own louder refusal, which wins
|
|
318
|
+
throw e;
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Charge, call, settle — for an UNMODELED member reached through the passthrough Proxy, where the
|
|
324
|
+
* shape is unknown.
|
|
325
|
+
*
|
|
326
|
+
* Deliberately NOT `async`. Some vendor SDKs (twilio-shaped ones) build requests through
|
|
327
|
+
* SYNCHRONOUS chained accessors — `client.a.b('sid').c.create()` — where the intermediate calls
|
|
328
|
+
* return a resource context, not a promise. An `async` wrapper would turn every one of those into
|
|
329
|
+
* a `Promise` and `.c` would come back `undefined`: the guard would BREAK the client instead of
|
|
330
|
+
* guarding it. So a non-thenable return is treated as an accessor — still charged (we cannot know
|
|
331
|
+
* before calling, and over-charging is the safe direction) and wrapped, so the eventual async leaf
|
|
332
|
+
* is charged too rather than escaping the budget. The cost of the sync shape is that a REFUSAL on
|
|
333
|
+
* this path throws synchronously instead of rejecting; that is the honest signal for an accessor,
|
|
334
|
+
* and the modeled surface above (every method this connector actually calls) does not have it.
|
|
335
|
+
*/
|
|
336
|
+
const charge: Charge = (method, invoke) => (...args) => {
|
|
337
|
+
const weight = budget.weightFor(method);
|
|
338
|
+
const reservation = budget.checkBudget(weight);
|
|
339
|
+
let out: unknown;
|
|
340
|
+
try {
|
|
341
|
+
out = invoke(...args);
|
|
342
|
+
} catch (e) {
|
|
343
|
+
settle(weight, reservation, e); // may throw its own louder refusal, which wins
|
|
344
|
+
throw e;
|
|
345
|
+
}
|
|
346
|
+
if (!isThenable(out)) {
|
|
347
|
+
settle(weight, reservation, undefined);
|
|
348
|
+
return out !== null && (typeof out === 'object' || typeof out === 'function')
|
|
349
|
+
? proxyThrough({}, out as object, method, charge)
|
|
350
|
+
: out;
|
|
351
|
+
}
|
|
352
|
+
return out.then(
|
|
353
|
+
(res) => { settleAnswered(weight, reservation, res); return res; },
|
|
354
|
+
(e: unknown) => { settle(weight, reservation, e); throw e; },
|
|
355
|
+
);
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
// Build the charged surface from UPSTASHVECTOR_BUDGETED_METHODS (see its docstring for why an absent path is
|
|
359
|
+
// SKIPPED rather than stubbed).
|
|
360
|
+
const guarded: Record<string, unknown> & Guarded = {};
|
|
361
|
+
for (const path of UPSTASHVECTOR_BUDGETED_METHODS) {
|
|
362
|
+
const segs = path.split('.');
|
|
363
|
+
const leaf = segs[segs.length - 1]!;
|
|
364
|
+
let owner: Record<string, unknown> | undefined = client as unknown as Record<string, unknown>;
|
|
365
|
+
for (const seg of segs.slice(0, -1)) owner = owner?.[seg] as Record<string, unknown> | undefined;
|
|
366
|
+
if (typeof owner?.[leaf] !== 'function') continue;
|
|
367
|
+
const realOwner = owner;
|
|
368
|
+
let node: Record<string, unknown> = guarded;
|
|
369
|
+
for (const seg of segs.slice(0, -1)) node = (node[seg] ??= {}) as Record<string, unknown>;
|
|
370
|
+
// The method is resolved at CALL time, not here, so a client whose method is swapped later is
|
|
371
|
+
// still charged for whatever it actually runs.
|
|
372
|
+
node[leaf] = chargeAsync(path, (...args) => (realOwner[leaf] as (...a: unknown[]) => unknown).apply(realOwner, args));
|
|
373
|
+
}
|
|
374
|
+
Object.defineProperty(guarded, GUARDED, { value: budget, enumerable: false });
|
|
375
|
+
|
|
376
|
+
// The surface above is what this connector calls. A real client has MORE — and a consumer who
|
|
377
|
+
// needs any of it must not be forced to keep the RAW client alongside, because every call through
|
|
378
|
+
// that would be unbudgeted. So the guarded object is a Proxy: known members come from the map
|
|
379
|
+
// above, anything else is taken from the real client and PRICED at `defaultWeight`.
|
|
380
|
+
return proxyThrough(guarded, client as object, '', charge) as UpstashVectorLikeClient;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Members JavaScript itself asks for. Wrapping any of these turns the object into something that
|
|
385
|
+
* looks thenable / mis-reports its own identity, which breaks `await`, `instanceof` and logging —
|
|
386
|
+
* so they always come from the target untouched, never priced.
|
|
387
|
+
*/
|
|
388
|
+
const NEVER_WRAP = new Set(['then', 'catch', 'finally', 'constructor', 'prototype', 'toJSON', 'toString', 'valueOf', 'inspect']);
|
|
389
|
+
|
|
390
|
+
/** Does this look like a promise? (Only a thenable gets the settle-on-resolution treatment.) */
|
|
391
|
+
function isThenable(v: unknown): v is Promise<unknown> {
|
|
392
|
+
return v !== null && (typeof v === 'object' || typeof v === 'function') && typeof (v as { then?: unknown }).then === 'function';
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Symbols that name an ITERATION PROTOCOL. Reaching for one of these on a vendor object is how a
|
|
397
|
+
* paginator is driven (`for await (const page of client.things.list())`), i.e. it is the doorway to
|
|
398
|
+
* an unbounded sequence of REQUESTS — exactly what this budget exists to bound — so they are charged
|
|
399
|
+
* and their result is kept behind the proxy. Every other symbol (`Symbol.toStringTag`,
|
|
400
|
+
* `nodejs.util.inspect.custom`, …) is metadata rather than a request and passes through untouched.
|
|
401
|
+
*/
|
|
402
|
+
const ITERATOR_SYMBOLS = new Set<symbol>([Symbol.asyncIterator, Symbol.iterator]);
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Serve `known` where it has the member; otherwise price a passthrough to `real`. Applied
|
|
406
|
+
* recursively, so a namespace member this pack never modeled is charged at `defaultWeight` rather
|
|
407
|
+
* than coming back `undefined`.
|
|
408
|
+
*
|
|
409
|
+
* CALLABLE NAMESPACES (§9 finding): a member can be BOTH a function and a namespace — twilio's
|
|
410
|
+
* `client.messages` is called as `client.messages(sid)` to get one message's context AND read as
|
|
411
|
+
* `client.messages.list()`. An earlier version returned the modeled node as a plain object whenever
|
|
412
|
+
* this pack modeled any child of it, which silently DROPPED the call signature and every unmodeled
|
|
413
|
+
* sibling — the module's own docstring calls a dropped member "worse than free, because it is
|
|
414
|
+
* invisible", and that is what it was doing. So when `real` is callable the proxy target is a
|
|
415
|
+
* function and an `apply` trap charges the call, while `known` stays the source of modeled members.
|
|
416
|
+
*/
|
|
417
|
+
function proxyThrough(known: object, real: object, prefix: string, charge: Charge, owner?: object): object {
|
|
418
|
+
const callable = typeof real === 'function';
|
|
419
|
+
// The target must itself be callable for the `apply` trap to exist at all. `known` stays the
|
|
420
|
+
// source of modeled members, read explicitly below rather than through the target.
|
|
421
|
+
const target: object = callable ? function proxied() { /* every call goes through the trap */ } : known;
|
|
422
|
+
return new Proxy(target, {
|
|
423
|
+
apply(_t, _thisArg, args: unknown[]) {
|
|
424
|
+
// Calling the namespace is itself a request-builder hop: charge it, and keep the result
|
|
425
|
+
// behind the proxy (charge() re-proxies a non-thenable) so the eventual leaf is charged too.
|
|
426
|
+
// `owner` is the object the function was read from — dropping it would silently break every
|
|
427
|
+
// method that relies on `this`.
|
|
428
|
+
return charge(prefix || 'call', (...a) => (real as (...x: unknown[]) => unknown).apply(owner, a))(...args);
|
|
429
|
+
},
|
|
430
|
+
get(_t, prop, receiver) {
|
|
431
|
+
if (typeof prop === 'symbol') {
|
|
432
|
+
const ownSym = Reflect.get(known, prop, receiver);
|
|
433
|
+
if (ownSym !== undefined) return ownSym; // the GUARDED brand, and anything we model
|
|
434
|
+
const fromSym = (real as Record<symbol, unknown> | null)?.[prop];
|
|
435
|
+
if (ITERATOR_SYMBOLS.has(prop) && typeof fromSym === 'function') {
|
|
436
|
+
return charge(`${prefix}[${prop.description ?? 'iterator'}]`, (...args) => (fromSym as (...a: unknown[]) => unknown).apply(real, args));
|
|
437
|
+
}
|
|
438
|
+
return fromSym;
|
|
439
|
+
}
|
|
440
|
+
const name = String(prop);
|
|
441
|
+
if (NEVER_WRAP.has(name)) return Reflect.get(known, prop, receiver);
|
|
442
|
+
const key = prefix ? `${prefix}.${name}` : name;
|
|
443
|
+
const own = Reflect.get(known, prop, receiver);
|
|
444
|
+
const from = (real as Record<string, unknown> | null)?.[name];
|
|
445
|
+
// A member we model: the charged wrapper (a function) or a namespace we must keep descending
|
|
446
|
+
// into, so an unmodeled sibling is still priced rather than dropped.
|
|
447
|
+
if (typeof own === 'function') return own;
|
|
448
|
+
if (own && typeof own === 'object') {
|
|
449
|
+
// `from` may be an object OR a CALLABLE namespace — both keep descending, which is what
|
|
450
|
+
// preserves `client.messages(sid)` alongside the modeled `client.messages.list()`.
|
|
451
|
+
return from && (typeof from === 'object' || typeof from === 'function')
|
|
452
|
+
? proxyThrough(own, from, key, charge, real)
|
|
453
|
+
: own;
|
|
454
|
+
}
|
|
455
|
+
if (own !== undefined) return own;
|
|
456
|
+
// A member only the real client has. Functions go through proxyThrough too, so one that also
|
|
457
|
+
// carries members (a callable namespace) keeps both its call signature and its siblings.
|
|
458
|
+
if (typeof from === 'function') return proxyThrough({}, from, key, charge, real);
|
|
459
|
+
if (from && typeof from === 'object') return proxyThrough({}, from, key, charge, real);
|
|
460
|
+
return from;
|
|
461
|
+
},
|
|
462
|
+
has(_t, prop) {
|
|
463
|
+
return Reflect.has(known, prop) || (real !== null && Reflect.has(real, prop));
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** The HTTP status a value carries, if it looks like one (an SDK error, or a raw response). */
|
|
469
|
+
function responseStatus(v: unknown): number | undefined {
|
|
470
|
+
const s = (v as { status?: unknown } | null)?.status;
|
|
471
|
+
return typeof s === 'number' && Number.isFinite(s) ? s : undefined;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Response/error headers as a plain lower-cased record, or `undefined` when there are none. Accepts
|
|
476
|
+
* a `Headers` instance, a `Map`, or a plain object — an SDK's error type changes shape across
|
|
477
|
+
* versions and none of the three is worth depending on.
|
|
478
|
+
*/
|
|
479
|
+
function responseHeaders(v: unknown): Record<string, string> | undefined {
|
|
480
|
+
const h = (v as { headers?: unknown } | null)?.headers;
|
|
481
|
+
if (!h || typeof h !== 'object') return undefined;
|
|
482
|
+
const out: Record<string, string> = {};
|
|
483
|
+
if (typeof (h as Headers).forEach === 'function') {
|
|
484
|
+
(h as Headers).forEach((value: string, key: string) => { out[String(key).toLowerCase()] = String(value); });
|
|
485
|
+
} else {
|
|
486
|
+
for (const [k, value] of Object.entries(h as Record<string, unknown>)) out[k.toLowerCase()] = String(value);
|
|
487
|
+
}
|
|
488
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
489
|
+
}
|