@spendgraph/harness 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/README.md ADDED
@@ -0,0 +1,162 @@
1
+ # @spendgraph/harness
2
+
3
+ Pull a stored prompt, render it, report what it cost. Zero dependencies.
4
+
5
+ Your prompt lives in spendgraph, not in your codebase. This client fetches it,
6
+ substitutes its typed fields, and records what the call cost — without sitting
7
+ in your request path.
8
+
9
+ ```sh
10
+ npm install @spendgraph/harness
11
+ ```
12
+
13
+ ## The production path
14
+
15
+ ```ts
16
+ import { Harness } from "@spendgraph/harness";
17
+
18
+ const harness = new Harness({
19
+ apiKey: process.env.SPENDGRAPH_API_KEY,
20
+ baseUrl: "https://your-spendgraph.example.com",
21
+ });
22
+
23
+ export async function handle(request) {
24
+ const prompt = await harness.pull("billing-explainer-k3f9x2");
25
+
26
+ const values = { question: "why was I charged twice?" };
27
+ const messages = prompt.render(values);
28
+
29
+ // Call the provider yourself. spendgraph is never between you and the model.
30
+ const startedAt = Date.now();
31
+ const completion = await yourModelCall(messages);
32
+
33
+ await harness.report(prompt.id, {
34
+ versionId: prompt.versionId,
35
+ model: "anthropic/claude-sonnet-5",
36
+ fields: prompt.serialize(values),
37
+ rendered: messages,
38
+ output: completion.text,
39
+ inputTokens: completion.usage.inputTokens,
40
+ outputTokens: completion.usage.outputTokens,
41
+ cacheReadTokens: completion.usage.cacheReadTokens ?? 0,
42
+ cacheWriteTokens: completion.usage.cacheWriteTokens ?? 0,
43
+ latencyMs: Date.now() - startedAt,
44
+ });
45
+
46
+ return completion.text;
47
+ }
48
+ ```
49
+
50
+ Call `pull` inside the handler, not at module scope. It refreshes as a side
51
+ effect of being called, so a service that pulls once at boot serves that wording
52
+ for the life of the process. Called per request it is a cache read, not a round
53
+ trip.
54
+
55
+ `report` never throws and never rejects — it sits beside a request that has
56
+ already been answered, and telemetry that can break the thing it measures is
57
+ worse than no telemetry. Pass `onReportError` if you want to know when one is
58
+ dropped.
59
+
60
+ ## Handles
61
+
62
+ `pull`, `run` and the rest take a prompt's uuid or its slug.
63
+
64
+ A slug is derived from the prompt's name, so **renaming the prompt mints a new
65
+ one and the old stops resolving**. The uuid never changes. Pull by slug for
66
+ something readable; pull by id where the handle has to outlive an edit to the
67
+ name.
68
+
69
+ When a handle stops resolving, the client keeps serving the wording it last saw
70
+ rather than failing your request — and backs off rather than retrying on every
71
+ call. Pass `onPullError` to be told, because otherwise that is silent:
72
+
73
+ ```ts
74
+ new Harness({
75
+ apiKey,
76
+ baseUrl,
77
+ onPullError: (err, handle) => log.warn({ err, handle }, "prompt refresh failed"),
78
+ });
79
+ ```
80
+
81
+ ## Caching
82
+
83
+ `pull` is stale-while-revalidate. A fresh entry is returned outright, a stale
84
+ one is returned *now* and refreshed behind you, and only a cold miss waits on
85
+ the network. So an edit in the dashboard reaches production within a TTL without
86
+ any request paying for the fetch.
87
+
88
+ ```ts
89
+ new Harness({ apiKey, baseUrl, cache: { ttlSeconds: 300, maxSize: 100 } });
90
+ ```
91
+
92
+ `harness.invalidate(promptId)` drops it immediately — `promote()` does this for
93
+ you, including for entries cached under the other handle.
94
+
95
+ ## The batch path
96
+
97
+ `run`, `sample` and `runAll` execute the prompt server-side and record the
98
+ rollout in one call. That adds a hop and makes spendgraph a dependency of your
99
+ uptime, which is the right trade for evaluation and the wrong one for a user
100
+ waiting on a response.
101
+
102
+ ```ts
103
+ const result = await harness.run("billing-explainer-k3f9x2", values);
104
+ const k = await harness.sample(promptId, values, { k: 5 }); // pass^k
105
+ const across = await harness.runAll(promptId, values); // one per model
106
+ ```
107
+
108
+ Cap what a client can spend:
109
+
110
+ ```ts
111
+ new Harness({ apiKey, baseUrl, maxCostMicros: 5_000_000 }); // $5
112
+ harness.spent(); // micro-dollars used
113
+ harness.remaining(); // what is left
114
+ ```
115
+
116
+ Omit `maxCostMicros` and it warns once that it is unbounded. Pass `null` to say
117
+ you meant it.
118
+
119
+ ## Typed fields
120
+
121
+ A prompt declares its placeholders — `string`, `text`, `number`, `boolean`,
122
+ `enum`, `list`, `json` — and `render` validates before anything is sent, so a
123
+ missing required field is an exception rather than a `{placeholder}` reaching
124
+ the model.
125
+
126
+ `harness codegen` turns those declarations into types:
127
+
128
+ ```sh
129
+ npx harness codegen --url https://your-spendgraph.example.com
130
+ # usage: harness codegen [--out <file>] [--project <id>] [--module <specifier>]
131
+ ```
132
+
133
+ It reads `SPENDGRAPH_API_KEY` and `SPENDGRAPH_URL` from the environment and
134
+ writes `src/harness.generated.d.ts`. Include that file and the values argument
135
+ is checked per prompt, by id and by slug. Delete it and calls fall back to a
136
+ loose `Record<string, unknown>` — the runtime validation is unchanged either
137
+ way.
138
+
139
+ ## Versions
140
+
141
+ ```ts
142
+ const { versions, currentVersionId } = await harness.versions(promptId);
143
+ await harness.promote(promptId, versionId);
144
+ ```
145
+
146
+ Promoting changes what every `pull` serves from that moment, and invalidates the
147
+ cache so the change is visible immediately rather than after a TTL.
148
+
149
+ ## Datasets
150
+
151
+ ```ts
152
+ const { cases, counts } = await harness.cases(promptId);
153
+ await harness.setCases(promptId, cases);
154
+ ```
155
+
156
+ `setCases` replaces the whole set. A dataset answers "what am I evaluating
157
+ against" with one answer at a time, and a merge would leave no way to remove a
158
+ case.
159
+
160
+ ## License
161
+
162
+ MIT
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * harness codegen — writes a .d.ts describing every stored prompt's fields.
4
+ *
5
+ * SPENDGRAPH_API_KEY=sg_… SPENDGRAPH_URL=https://costs.example.com \
6
+ * npx harness codegen --out src/harness.generated.d.ts
7
+ *
8
+ * Reads through the paginated list endpoint, so a project with hundreds of
9
+ * prompts is many small requests rather than one enormous response.
10
+ */
11
+ import { writeFile, mkdir } from "node:fs/promises";
12
+ import { dirname, resolve } from "node:path";
13
+ import { emitPromptTypes } from "../dist/index.js";
14
+
15
+ const args = process.argv.slice(2);
16
+ const command = args[0];
17
+
18
+ function flag(name, fallback) {
19
+ const i = args.indexOf(`--${name}`);
20
+ return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
21
+ }
22
+
23
+ function fail(message) {
24
+ console.error(`harness: ${message}`);
25
+ process.exit(1);
26
+ }
27
+
28
+ if (command !== "codegen") {
29
+ console.log("usage: harness codegen [--out <file>] [--project <id>] [--module <specifier>]");
30
+ process.exit(command ? 1 : 0);
31
+ }
32
+
33
+ const apiKey = process.env.SPENDGRAPH_API_KEY;
34
+ const baseUrl = (process.env.SPENDGRAPH_URL ?? flag("url", "")).replace(/\/+$/, "");
35
+ if (!apiKey) fail("set SPENDGRAPH_API_KEY");
36
+ if (!baseUrl) fail("set SPENDGRAPH_URL, or pass --url");
37
+
38
+ const out = resolve(flag("out", "src/harness.generated.d.ts"));
39
+ const project = flag("project", "");
40
+ const moduleName = flag("module", "@spendgraph/harness");
41
+
42
+ const prompts = [];
43
+ let cursor = null;
44
+
45
+ // Bounded: a server that kept handing back a cursor must not spin forever.
46
+ for (let page = 0; page < 200; page++) {
47
+ // Archived prompts included on purpose. The dashboard hides them, but a
48
+ // generated type disappearing because somebody tidied a list would break the
49
+ // build of code that still pulls the prompt — archiving does not stop it
50
+ // serving, so it must not stop it type-checking either.
51
+ const params = new URLSearchParams({ limit: "200", archived: "all" });
52
+ if (project) params.set("project", project);
53
+ if (cursor) params.set("cursor", cursor);
54
+
55
+ const res = await fetch(`${baseUrl}/api/v1/prompts?${params}`, {
56
+ headers: { "x-api-key": apiKey },
57
+ });
58
+ if (!res.ok) {
59
+ const body = await res.text().catch(() => "");
60
+ fail(`${res.status} listing prompts — ${body.slice(0, 200)}`);
61
+ }
62
+
63
+ const body = await res.json();
64
+ prompts.push(...(body.prompts ?? []));
65
+ cursor = body.nextCursor;
66
+ if (!cursor) break;
67
+ }
68
+
69
+ await mkdir(dirname(out), { recursive: true });
70
+ await writeFile(out, emitPromptTypes(prompts, { module: moduleName }));
71
+
72
+ const named = prompts.filter((p) => (p.fieldSpec ?? []).some((f) => f.required)).length;
73
+ console.log(
74
+ `harness: wrote ${prompts.length} prompt${prompts.length === 1 ? "" : "s"} to ${out}` +
75
+ (named ? ` (${named} with required fields)` : "")
76
+ );
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Thrown when a client has spent its ceiling.
3
+ *
4
+ * Deliberately not a `HarnessError`: nothing was refused by the server, so a
5
+ * caller inspecting `status` or `retryable` would be told a story about HTTP
6
+ * that never happened.
7
+ */
8
+ export declare class BudgetExceededError extends Error {
9
+ readonly spentMicros: number;
10
+ readonly limitMicros: number;
11
+ constructor(spentMicros: number, limitMicros: number);
12
+ }
13
+ /**
14
+ * What this client has spent, and whether it may spend more.
15
+ *
16
+ * A loop that samples k rollouts across a dataset is the shape of an expensive
17
+ * accident: one bad `k`, one dataset larger than expected, and the bill arrives
18
+ * before anyone notices. The guard is a counter and a comparison, and a cost
19
+ * meter that shipped without one would be embarrassing.
20
+ *
21
+ * Governs spending this client *initiates* — `run`, `sample`, `runAll`. It does
22
+ * not gate `report`, which records money the caller already spent elsewhere;
23
+ * counting that would make the ceiling mean two different things at once.
24
+ */
25
+ export declare class Budget {
26
+ private used;
27
+ private readonly limit;
28
+ /**
29
+ * `undefined` means no ceiling and warns once — an unbounded client in a
30
+ * scheduled script is how a surprise bill happens. `null` means the same
31
+ * thing deliberately, and says nothing.
32
+ */
33
+ constructor(maxCostMicros?: number | null);
34
+ spent(): number;
35
+ remaining(): number;
36
+ add(costMicros: number): void;
37
+ /**
38
+ * Throws if the ceiling is already reached.
39
+ *
40
+ * Checked before a request rather than after: stopping once the bill has been
41
+ * incurred is a report, not a limit.
42
+ */
43
+ assertAffordable(): void;
44
+ }
45
+ /** Test seam: lets the once-only warning be exercised more than once. */
46
+ export declare function resetBudgetWarning(): void;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Thrown when a client has spent its ceiling.
3
+ *
4
+ * Deliberately not a `HarnessError`: nothing was refused by the server, so a
5
+ * caller inspecting `status` or `retryable` would be told a story about HTTP
6
+ * that never happened.
7
+ */
8
+ export class BudgetExceededError extends Error {
9
+ spentMicros;
10
+ limitMicros;
11
+ constructor(spentMicros, limitMicros) {
12
+ super(`Cost ceiling reached: spent $${(spentMicros / 1e6).toFixed(4)} of ` +
13
+ `$${(limitMicros / 1e6).toFixed(4)}. Raise maxCostMicros or start a new client.`);
14
+ this.name = "BudgetExceededError";
15
+ this.spentMicros = spentMicros;
16
+ this.limitMicros = limitMicros;
17
+ }
18
+ }
19
+ let warned = false;
20
+ /**
21
+ * What this client has spent, and whether it may spend more.
22
+ *
23
+ * A loop that samples k rollouts across a dataset is the shape of an expensive
24
+ * accident: one bad `k`, one dataset larger than expected, and the bill arrives
25
+ * before anyone notices. The guard is a counter and a comparison, and a cost
26
+ * meter that shipped without one would be embarrassing.
27
+ *
28
+ * Governs spending this client *initiates* — `run`, `sample`, `runAll`. It does
29
+ * not gate `report`, which records money the caller already spent elsewhere;
30
+ * counting that would make the ceiling mean two different things at once.
31
+ */
32
+ export class Budget {
33
+ used = 0;
34
+ limit;
35
+ /**
36
+ * `undefined` means no ceiling and warns once — an unbounded client in a
37
+ * scheduled script is how a surprise bill happens. `null` means the same
38
+ * thing deliberately, and says nothing.
39
+ */
40
+ constructor(maxCostMicros) {
41
+ this.limit = maxCostMicros ?? Infinity;
42
+ if (maxCostMicros === undefined && !warned) {
43
+ warned = true;
44
+ console.warn("[harness] No maxCostMicros set — this client can spend without limit. " +
45
+ "Pass maxCostMicros, or null to say you meant it.");
46
+ }
47
+ }
48
+ spent() {
49
+ return this.used;
50
+ }
51
+ remaining() {
52
+ return this.limit === Infinity ? Infinity : Math.max(0, this.limit - this.used);
53
+ }
54
+ add(costMicros) {
55
+ if (Number.isFinite(costMicros) && costMicros > 0)
56
+ this.used += costMicros;
57
+ }
58
+ /**
59
+ * Throws if the ceiling is already reached.
60
+ *
61
+ * Checked before a request rather than after: stopping once the bill has been
62
+ * incurred is a report, not a limit.
63
+ */
64
+ assertAffordable() {
65
+ if (this.used >= this.limit)
66
+ throw new BudgetExceededError(this.used, this.limit);
67
+ }
68
+ }
69
+ /** Test seam: lets the once-only warning be exercised more than once. */
70
+ export function resetBudgetWarning() {
71
+ warned = false;
72
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * LRU with a TTL, and staleness reported rather than enforced.
3
+ *
4
+ * `get` returning a stale entry instead of dropping it is what lets `pull`
5
+ * serve immediately and refresh behind the request. The alternative — evict on
6
+ * expiry — puts an HTTP round trip on the critical path of whichever unlucky
7
+ * request arrives one millisecond after the TTL lapses.
8
+ */
9
+ export interface CacheOptions<T = unknown> {
10
+ /** Entries older than this are stale, not gone. Default 300 (5 minutes). */
11
+ ttlSeconds?: number;
12
+ /** Default 100. */
13
+ maxSize?: number;
14
+ /** Injectable clock, so tests do not sleep. */
15
+ now?: () => number;
16
+ /**
17
+ * How long to leave a key alone after its refresh failed. Default 60.
18
+ *
19
+ * A handle that has stopped resolving is served from cache and retried on a
20
+ * schedule, rather than on every call.
21
+ */
22
+ retryAfterSeconds?: number;
23
+ /**
24
+ * Called when a background refresh throws.
25
+ *
26
+ * `report` has `onReportError` because silence there loses spend data; the
27
+ * same argument applies here, where silence means serving a wording that can
28
+ * never update. Without it a rename on the dashboard freezes a running
29
+ * service with nothing anywhere saying so.
30
+ */
31
+ onRefreshError?: (err: unknown, key: string) => void;
32
+ /**
33
+ * Every name a cached value answers to — for a prompt, its id and its slug.
34
+ *
35
+ * The cache is keyed on whatever handle the caller passed, which is why
36
+ * invalidation kept missing: a promotion knows the uuid, production pulled by
37
+ * the slug, and a fetch still in flight carries no payload to connect them.
38
+ * With this, an invalidation is recorded against the prompt rather than
39
+ * against one string, and a fetch is dropped when the thing it fetched was
40
+ * invalidated — not when some unrelated key was.
41
+ */
42
+ identify?: (value: T) => string[];
43
+ }
44
+ export interface CacheHit<T> {
45
+ value: T;
46
+ stale: boolean;
47
+ ageMs: number;
48
+ }
49
+ export declare class PullCache<T> {
50
+ private readonly store;
51
+ private readonly ttlMs;
52
+ private readonly maxSize;
53
+ private readonly now;
54
+ /** Keys with a refresh in flight, so N concurrent readers cause one fetch. */
55
+ private readonly refreshing;
56
+ /** When a key's refresh last failed, so a dead handle is not retried per call. */
57
+ private readonly failures;
58
+ private readonly retryAfterMs;
59
+ private readonly onRefreshError?;
60
+ private readonly identify?;
61
+ /**
62
+ * A monotonic counter and the tick at which each name was last invalidated.
63
+ *
64
+ * A fetch records the counter when it starts and, when it lands, asks whether
65
+ * anything it turned out to be — its key, its id, its slug — has been
66
+ * invalidated since. That is the whole protocol, and it holds for the cold
67
+ * path, the background refresh, and a handle nobody has seen before, none of
68
+ * which the previous per-key epoch could cover.
69
+ */
70
+ private tick;
71
+ private readonly invalidatedAt;
72
+ /**
73
+ * The tick of the last `clear()`.
74
+ *
75
+ * `clear` cannot name a fetch it has never seen — a cold one is in neither
76
+ * `store` nor `refreshing` — so it raises a floor instead: anything that
77
+ * started before it is out of date whatever it turns out to be.
78
+ */
79
+ private clearedAt;
80
+ /** Fetches outstanding, so the log above can be dropped when none remain. */
81
+ private outstanding;
82
+ constructor(opts?: CacheOptions<T>);
83
+ get(key: string): CacheHit<T> | undefined;
84
+ set(key: string, value: T): void;
85
+ /**
86
+ * Runs `refresh` unless one is already in flight for this key.
87
+ *
88
+ * Without the guard, a burst of requests arriving just after the TTL lapses
89
+ * each start their own refresh — the stampede the cache exists to prevent.
90
+ * Failures are swallowed on purpose: a background refresh that throws must not
91
+ * surface in a request that was already answered from cache.
92
+ */
93
+ refreshInBackground(key: string, refresh: () => Promise<T>): void;
94
+ /** Records a fetch starting, and the tick it must be judged against. */
95
+ beginFetch(): number;
96
+ /** Stores a landed fetch unless what it fetched was invalidated meanwhile. */
97
+ settle(key: string, value: T, startedAt: number): void;
98
+ /** Ends a fetch that threw, and backs the handle off. */
99
+ abandon(key: string): void;
100
+ /** Records that everything under this name is out of date. */
101
+ invalidate(name: string): void;
102
+ /** The log only matters while a fetch could still be judged against it. */
103
+ private forgetLogIfIdle;
104
+ /** Keeps `failures` inside the same bound as the entries themselves. */
105
+ private boundFailures;
106
+ /** Every key whose entry satisfies `match`. */
107
+ keysWhere(match: (value: T) => boolean): string[];
108
+ delete(key: string): void;
109
+ clear(): void;
110
+ get size(): number;
111
+ /**
112
+ * How many keys the cache holds bookkeeping for, entries aside.
113
+ *
114
+ * Exposed for the test that this stays bounded: `epochs` and `failures` are
115
+ * private and grow on paths `size` cannot see, so a test written against
116
+ * `size` passes whether or not they leak — which is what the first one did.
117
+ */
118
+ get trackedKeys(): number;
119
+ }
@@ -0,0 +1,206 @@
1
+ export class PullCache {
2
+ store = new Map();
3
+ ttlMs;
4
+ maxSize;
5
+ now;
6
+ /** Keys with a refresh in flight, so N concurrent readers cause one fetch. */
7
+ refreshing = new Set();
8
+ /** When a key's refresh last failed, so a dead handle is not retried per call. */
9
+ failures = new Map();
10
+ retryAfterMs;
11
+ onRefreshError;
12
+ identify;
13
+ /**
14
+ * A monotonic counter and the tick at which each name was last invalidated.
15
+ *
16
+ * A fetch records the counter when it starts and, when it lands, asks whether
17
+ * anything it turned out to be — its key, its id, its slug — has been
18
+ * invalidated since. That is the whole protocol, and it holds for the cold
19
+ * path, the background refresh, and a handle nobody has seen before, none of
20
+ * which the previous per-key epoch could cover.
21
+ */
22
+ tick = 0;
23
+ invalidatedAt = new Map();
24
+ /**
25
+ * The tick of the last `clear()`.
26
+ *
27
+ * `clear` cannot name a fetch it has never seen — a cold one is in neither
28
+ * `store` nor `refreshing` — so it raises a floor instead: anything that
29
+ * started before it is out of date whatever it turns out to be.
30
+ */
31
+ clearedAt = -1;
32
+ /** Fetches outstanding, so the log above can be dropped when none remain. */
33
+ outstanding = 0;
34
+ constructor(opts = {}) {
35
+ // A TTL of 0 means "always stale", which is a legitimate way to disable
36
+ // caching without a separate flag; only undefined falls back to the default.
37
+ this.ttlMs = (opts.ttlSeconds ?? 300) * 1000;
38
+ this.maxSize = Math.max(1, opts.maxSize ?? 100);
39
+ this.now = opts.now ?? (() => Date.now());
40
+ this.retryAfterMs = (opts.retryAfterSeconds ?? 60) * 1000;
41
+ this.onRefreshError = opts.onRefreshError;
42
+ this.identify = opts.identify;
43
+ }
44
+ get(key) {
45
+ const entry = this.store.get(key);
46
+ if (!entry)
47
+ return undefined;
48
+ // Re-insert to mark most-recently-used: Map iterates in insertion order, so
49
+ // deleting and re-setting moves the key to the end.
50
+ this.store.delete(key);
51
+ this.store.set(key, entry);
52
+ const ageMs = this.now() - entry.at;
53
+ return { value: entry.value, stale: ageMs > this.ttlMs, ageMs };
54
+ }
55
+ set(key, value) {
56
+ this.store.delete(key);
57
+ this.store.set(key, { value, at: this.now() });
58
+ while (this.store.size > this.maxSize) {
59
+ const oldest = this.store.keys().next().value;
60
+ if (oldest === undefined)
61
+ break;
62
+ this.store.delete(oldest);
63
+ // The bookkeeping goes with the entry unless a fetch for it is still
64
+ // running, otherwise these grow forever in a class whose contract is a
65
+ // bounded LRU — one permanent entry per distinct key ever seen.
66
+ if (!this.refreshing.has(oldest))
67
+ this.failures.delete(oldest);
68
+ }
69
+ }
70
+ /**
71
+ * Runs `refresh` unless one is already in flight for this key.
72
+ *
73
+ * Without the guard, a burst of requests arriving just after the TTL lapses
74
+ * each start their own refresh — the stampede the cache exists to prevent.
75
+ * Failures are swallowed on purpose: a background refresh that throws must not
76
+ * surface in a request that was already answered from cache.
77
+ */
78
+ refreshInBackground(key, refresh) {
79
+ if (this.refreshing.has(key))
80
+ return;
81
+ // Backed off after a failure. A handle that has stopped resolving — which a
82
+ // rename now makes routine, since it re-mints the slug — otherwise fires a
83
+ // fresh doomed request on every single pull, forever, while serving wording
84
+ // that can never update. The entry stays (stale is better than nothing);
85
+ // what stops is the stampede.
86
+ const failedAt = this.failures.get(key);
87
+ if (failedAt !== undefined && this.now() - failedAt < this.retryAfterMs)
88
+ return;
89
+ this.refreshing.add(key);
90
+ const startedAt = this.beginFetch();
91
+ void refresh()
92
+ .then((value) => this.settle(key, value, startedAt))
93
+ .catch((err) => {
94
+ this.abandon(key);
95
+ // Surfaced rather than swallowed. `report` has onReportError for exactly
96
+ // this reason; without a counterpart here a service can serve a frozen
97
+ // prompt indefinitely with nothing anywhere saying so.
98
+ //
99
+ // Wrapped, because nobody is holding this promise: `pullWithCache` has
100
+ // already returned the stale value, so a callback that throws would be
101
+ // an unhandled rejection — process exit in Node, isolate abort in a
102
+ // Worker.
103
+ try {
104
+ this.onRefreshError?.(err, key);
105
+ }
106
+ catch {
107
+ /* a reporter that cannot report is not worth an outage */
108
+ }
109
+ })
110
+ .finally(() => {
111
+ this.refreshing.delete(key);
112
+ this.forgetLogIfIdle();
113
+ });
114
+ }
115
+ /** Records a fetch starting, and the tick it must be judged against. */
116
+ beginFetch() {
117
+ this.outstanding++;
118
+ return this.tick;
119
+ }
120
+ /** Stores a landed fetch unless what it fetched was invalidated meanwhile. */
121
+ settle(key, value, startedAt) {
122
+ this.outstanding--;
123
+ const names = [key, ...(this.identify?.(value) ?? [])];
124
+ const overtaken = startedAt < this.clearedAt ||
125
+ names.some((n) => (this.invalidatedAt.get(n) ?? -1) > startedAt);
126
+ if (!overtaken) {
127
+ // Only once the value is kept: clearing first meant a fetch that lost the
128
+ // race still wiped a live backoff, turning bounded retry into a retry on
129
+ // every call.
130
+ this.failures.delete(key);
131
+ this.set(key, value);
132
+ }
133
+ this.forgetLogIfIdle();
134
+ }
135
+ /** Ends a fetch that threw, and backs the handle off. */
136
+ abandon(key) {
137
+ this.outstanding--;
138
+ // Recorded here too, not only on the background path. The cold path is the
139
+ // one a fresh isolate takes, so without this a permanently 404ing handle —
140
+ // which a rename now makes routine — issued one upstream request per pull,
141
+ // forever.
142
+ this.failures.set(key, this.now());
143
+ this.boundFailures();
144
+ this.forgetLogIfIdle();
145
+ }
146
+ /** Records that everything under this name is out of date. */
147
+ invalidate(name) {
148
+ this.invalidatedAt.set(name, ++this.tick);
149
+ }
150
+ /** The log only matters while a fetch could still be judged against it. */
151
+ forgetLogIfIdle() {
152
+ if (this.outstanding === 0 && this.refreshing.size === 0)
153
+ this.invalidatedAt.clear();
154
+ }
155
+ /** Keeps `failures` inside the same bound as the entries themselves. */
156
+ boundFailures() {
157
+ while (this.failures.size > this.maxSize) {
158
+ const oldest = this.failures.keys().next().value;
159
+ if (oldest === undefined)
160
+ break;
161
+ this.failures.delete(oldest);
162
+ }
163
+ }
164
+ /** Every key whose entry satisfies `match`. */
165
+ keysWhere(match) {
166
+ const out = [];
167
+ for (const [key, entry] of this.store)
168
+ if (match(entry.value))
169
+ out.push(key);
170
+ return out;
171
+ }
172
+ delete(key) {
173
+ this.store.delete(key);
174
+ // The backoff goes with it. Otherwise invalidate() could not clear a
175
+ // backoff, and a handle that has demonstrably recovered kept skipping its
176
+ // refresh for the whole retry window — which `retryAfterSeconds` being a
177
+ // public option makes arbitrarily long.
178
+ this.failures.delete(key);
179
+ this.invalidate(key);
180
+ // Nothing outstanding means nothing can be judged against the log, so it is
181
+ // residue — which is what made it grow by one per prompt ever invalidated.
182
+ this.forgetLogIfIdle();
183
+ }
184
+ clear() {
185
+ // Every key the cache has an opinion about, not only the ones with an entry:
186
+ // a cold-isolate fetch is in flight under a key that is not in `store` yet,
187
+ // and bumping only stored keys let it land after the invalidation.
188
+ this.clearedAt = ++this.tick;
189
+ this.store.clear();
190
+ this.failures.clear();
191
+ this.forgetLogIfIdle();
192
+ }
193
+ get size() {
194
+ return this.store.size;
195
+ }
196
+ /**
197
+ * How many keys the cache holds bookkeeping for, entries aside.
198
+ *
199
+ * Exposed for the test that this stays bounded: `epochs` and `failures` are
200
+ * private and grow on paths `size` cannot see, so a test written against
201
+ * `size` passes whether or not they leak — which is what the first one did.
202
+ */
203
+ get trackedKeys() {
204
+ return new Set([...this.invalidatedAt.keys(), ...this.failures.keys()]).size;
205
+ }
206
+ }