diff --git a/.changeset/harden-tenant-isolation.md b/.changeset/harden-tenant-isolation.md index ec04412..013e240 100644 --- a/.changeset/harden-tenant-isolation.md +++ b/.changeset/harden-tenant-isolation.md @@ -15,6 +15,8 @@ Tenant-isolation hardening, a type-safe reactive search index, and a consistent - The eve sandbox denies network egress by default. Its `upstash()` backend config is now the `@upstash/box` `BoxConfig` passed through verbatim (`runtime`/`size`/`apiKey`/`keepAlive`/`initCommand`/`env`/`skills`/…) plus an optional `redis`/`templatePrefix` — the invented `resources.vcpus` hint and runtime-string coercion (`"node24"`) are removed (use `runtime`/`size` as Box expects), and `networkPolicy` is no longer a config knob (egress is governed by the deny-all default plus per-session `use({ networkPolicy })`). - The eve sandbox now reuses prewarmed Box snapshots correctly: the `templateKey → snapshotId` map is stored in a durable Redis registry (Box has no static snapshot lookup, and `prewarm`/`create` run in different processes), so `create` restores the prewarmed template instead of spinning a fresh, empty box. `prewarm` builds no box when there's nothing to bake. It also bridges Eve's `/workspace` root to Box's `/workspace/home` working directory in both file ops and raw commands, so the agent's `find`/`grep`/file tools hit the right directory. - The eve sandbox now reuses one box per conversation instead of creating a new box on every session open: `create` reattaches to the box from `existingMetadata` (Eve re-opens a session many times per turn) and `dispose` no longer tears the box down. `keepAlive` defaults to `false` (Box's pause-based idle lifecycle), so idle boxes are auto-paused/reaped rather than leaked. +- The eve sandbox no longer silently drops Eve's per-domain network rules. Box's network policy is a plain domain/CIDR allow-list, so a policy carrying `transform` (firewall header injection / credential brokering) or `forwardURL` now **throws** instead of being quietly reduced to a bare allow-list (which would send the request unauthenticated). For credential brokering, set Box's `attachHeaders` at backend creation via `upstash({ attachHeaders })`. +- `createRateLimit`'s `redis` is now optional and defaults to `Redis.fromEnv()`, matching the "`redis` defaults everywhere" convention — previously it was the one feature that required an explicit client. **Reactive search index** diff --git a/packages/ai-sdk/README.md b/packages/ai-sdk/README.md index 864952b..c801bcd 100644 --- a/packages/ai-sdk/README.md +++ b/packages/ai-sdk/README.md @@ -144,7 +144,7 @@ before the model and short-circuit when over the limit. ```ts import { createRateLimit, Ratelimit } from "@upstash/agentkit-ai-sdk"; -const ratelimit = createRateLimit({ redis, limiter: Ratelimit.slidingWindow(20, "1 m") }); +const ratelimit = createRateLimit({ limiter: Ratelimit.slidingWindow(20, "1 m") }); const { success } = await ratelimit.limit(userId); if (!success) throw new Error("rate limited"); // or return a 429 from your route @@ -154,7 +154,7 @@ if (!success) throw new Error("rate limited"); // or return a 429 from your rout Options - **`limiter`** _(required)_ — e.g. `Ratelimit.slidingWindow(20, "1 m")` or `fixedWindow(...)`. -- `redis` — the Upstash Redis client backing the limiter. +- `redis` — defaults to `Redis.fromEnv()`. - `prefix` — base key prefix; keys are `:` (default `agentkit:rateLimit`). There is no model wrapper; pass a per-user `identifier` to `.limit()` to throttle per user. diff --git a/packages/eve/README.md b/packages/eve/README.md index 48a4d16..e4e218e 100644 --- a/packages/eve/README.md +++ b/packages/eve/README.md @@ -178,6 +178,45 @@ readable by code running in the box; don't pass secrets you wouldn't want it to +
+Brokering credentials (injecting headers) + +Box network policies are plain domain/CIDR allow-lists. Eve's per-domain firewall rules (`transform` +header injection, `forwardURL`) have no Box equivalent, so passing them in `use({ networkPolicy })` +**throws** rather than silently sending the request unauthenticated: + +```ts +// ❌ throws — Box can't inject headers via a per-session policy +export default defineSandbox({ + backend: upstash({ runtime: "node" }), + async onSession({ use }) { + await use({ + networkPolicy: { + allow: { "api.example.com": [{ transform: [{ headers: { authorization: "Bearer …" } }] }] }, + }, + }); + }, +}); +``` + +Broker credentials with Box's `attachHeaders` instead (set at backend creation; a proxy on the box +injects them), and open the domain with a plain allow-list: + +```ts +// ✅ headers injected at the firewall; the secret never enters the box +export default defineSandbox({ + backend: upstash({ + runtime: "node", + attachHeaders: { "api.example.com": { Authorization: "Bearer …" } }, + }), + async onSession({ use }) { + await use({ networkPolicy: { allow: ["api.example.com"] } }); + }, +}); +``` + +
+
Lifecycle: one box per conversation diff --git a/packages/eve/src/sandbox.test.ts b/packages/eve/src/sandbox.test.ts index 9b78b5e..948eaf4 100644 --- a/packages/eve/src/sandbox.test.ts +++ b/packages/eve/src/sandbox.test.ts @@ -1,7 +1,7 @@ import { config } from "dotenv"; import { describe, expect, it } from "vitest"; import { Box } from "@upstash/box"; -import { rewriteWorkspacePaths, toBoxPath, upstash } from "./sandbox.js"; +import { rewriteWorkspacePaths, toBoxNetworkPolicy, toBoxPath, upstash } from "./sandbox.js"; import { hasRedisCreds, testRedis, uniquePrefix } from "./test-support.js"; config(); // load repo-root .env for UPSTASH_BOX_API_KEY @@ -45,6 +45,28 @@ describe("upstash() backend (offline)", () => { ); expect(rewriteWorkspacePaths("cat ./workspace/x")).toBe("cat ./workspace/x"); // relative, untouched }); + + // Box's policy is a plain domain/CIDR allow-list. It must not silently drop Eve's per-domain + // credential-brokering rules (transform / forwardURL); those throw instead. + it("maps plain network policies and throws on per-domain rules", () => { + expect(toBoxNetworkPolicy("deny-all")).toEqual({ mode: "deny-all" }); + expect(toBoxNetworkPolicy("allow-all")).toEqual({ mode: "allow-all" }); + expect(toBoxNetworkPolicy({ allow: ["github.com"] } as never)).toEqual({ + mode: "custom", + allowedDomains: ["github.com"], + }); + // Empty rule arrays just allow the domains — fine. + expect(toBoxNetworkPolicy({ allow: { "github.com": [], "*": [] } } as never)).toEqual({ + mode: "custom", + allowedDomains: ["github.com", "*"], + }); + // A transform rule (firewall header injection) has no dynamic Box equivalent — throw, don't drop. + expect(() => + toBoxNetworkPolicy({ + allow: { "github.com": [{ transform: [{ headers: { authorization: "Basic x" } }] }] }, + } as never), + ).toThrow(/attachHeaders/); + }); }); describe.skipIf(!hasBoxCreds)("upstash() backend (live Upstash Box)", () => { @@ -111,6 +133,85 @@ describe.skipIf(!hasBoxCreds)("upstash() backend (live Upstash Box)", () => { await Box.delete({ boxIds: handle.session.id }).catch(() => {}); } }, 180_000); + + /** + * Users don't call `create` — they pass the backend to `defineSandbox`, and eve calls `create`, then + * drives `useSessionFn` (its implementation of the `use({ networkPolicy })` you write in + * `bootstrap`/`onSession`) and the session's `setNetworkPolicy` (the agent changing policy mid-run). + * + * The user code that hits this error — a per-domain `transform` (firewall header injection) in `use()`: + * + * ```ts + * // agent/sandbox.ts + * export default defineSandbox({ + * backend: upstash({ runtime: "node" }), + * async onSession({ use }) { + * await use({ + * networkPolicy: { + * allow: { "api.example.com": [{ transform: [{ headers: { authorization: "Bearer …" } }] }] }, + * }, + * }); // throws: Box can't inject headers via a per-session policy + * }, + * }); + * ``` + * + * What to do instead — broker credentials with Box's `attachHeaders` (set at backend creation), and + * just open the domain per session: + * + * ```ts + * export default defineSandbox({ + * backend: upstash({ + * runtime: "node", + * attachHeaders: { "api.example.com": { Authorization: "Bearer …" } }, // injected by Box's proxy + * }), + * async onSession({ use }) { + * await use({ networkPolicy: { allow: ["api.example.com"] } }); // plain allow-list, no rules + * }, + * }); + * ``` + * + * The test drives the same backend methods eve calls (`useSessionFn` = `use()`, plus `setNetworkPolicy`), + * since standing up the full eve runtime in a unit test isn't practical. + */ + it("rejects a per-session transform rule on use()/setNetworkPolicy; a plain allow-list works", async () => { + const backend = upstash({ runtime: "node" }); + const handle = await backend.create(createInput); // eve does this for you + const transformPolicy = { + allow: { "api.example.com": [{ transform: [{ headers: { authorization: "Bearer x" } }] }] }, + } as never; + try { + // The user's `use({ networkPolicy })` (eve → useSessionFn): errors, pointing to attachHeaders. + await expect(handle.useSessionFn({ networkPolicy: transformPolicy })).rejects.toThrow( + /attachHeaders/, + ); + // The agent's `session.setNetworkPolicy(...)`: same guard. + await expect(handle.session.setNetworkPolicy(transformPolicy)).rejects.toThrow( + /attachHeaders/, + ); + + // Works instead: a bare domain allow-list (no per-domain rules). + await expect( + handle.useSessionFn({ networkPolicy: ["api.example.com"] as never }), + ).resolves.toBeDefined(); + } finally { + await Box.delete({ boxIds: handle.session.id }).catch(() => {}); + } + }, 120_000); + + // The supported credential-brokering path: Box's `attachHeaders`, set at backend creation. We can't + // observe injection without an echo endpoint, but the box must at least be created with it and run. + it("accepts attachHeaders at backend creation (credential brokering)", async () => { + const backend = upstash({ + runtime: "node", + attachHeaders: { "api.example.com": { Authorization: "Bearer test" } }, + }); + const handle = await backend.create(createInput); + try { + expect((await handle.session.run({ command: "echo ok" })).stdout).toContain("ok"); + } finally { + await Box.delete({ boxIds: handle.session.id }).catch(() => {}); + } + }, 120_000); }); // Bug fix: prewarm (build/startup) and create (per request) run in different processes, so the diff --git a/packages/eve/src/sandbox.ts b/packages/eve/src/sandbox.ts index 1120f62..80947b3 100644 --- a/packages/eve/src/sandbox.ts +++ b/packages/eve/src/sandbox.ts @@ -145,12 +145,39 @@ export function rewriteWorkspacePaths(command: string): string { */ const DEFAULT_NETWORK_POLICY: SandboxNetworkPolicy = "deny-all"; -/** Map Eve's (Vercel-shaped) network policy onto Box's network policy. */ -function toBoxNetworkPolicy(policy: SandboxNetworkPolicy): BoxNetworkPolicy { +/** + * Map Eve's (Vercel-shaped) network policy onto Box's. Box's policy is a plain domain/CIDR allow-list, + * so it can't honor Eve's per-domain firewall rules: `transform` (inject headers at the firewall to + * broker credentials so secrets never enter the box) or `forwardURL`. Silently dropping those would send + * the request unauthenticated, or push the model to embed the secret inside the box, so we **throw** + * rather than quietly downgrade a security control. (Plain allow-lists and empty rule arrays map fine.) + * + * For credential brokering on Box, set `attachHeaders` at backend creation instead: + * `upstash({ attachHeaders: { "api.example.com": { Authorization: "Bearer ..." } } })`. + */ +export function toBoxNetworkPolicy(policy: SandboxNetworkPolicy): BoxNetworkPolicy { if (policy === "allow-all") return { mode: "allow-all" }; if (policy === "deny-all") return { mode: "deny-all" }; const allow = policy.allow; - const allowedDomains = Array.isArray(allow) ? allow : allow ? Object.keys(allow) : undefined; + let allowedDomains: string[] | undefined; + if (Array.isArray(allow)) { + allowedDomains = allow; + } else if (allow) { + for (const [domain, rules] of Object.entries(allow)) { + if ( + Array.isArray(rules) && + rules.some((r) => r && (r.transform || r.forwardURL || r.match)) + ) { + throw new Error( + `UpstashSandboxBackend: the Upstash Box backend can't honor per-domain network rules ` + + `(transform / forwardURL / match) for "${domain}"; its network policy is a plain ` + + `domain/CIDR allow-list. To inject credentials into outbound requests, set Box's ` + + `attachHeaders at backend creation: upstash({ attachHeaders: { "${domain}": { ... } } }).`, + ); + } + } + allowedDomains = Object.keys(allow); + } return { mode: "custom", ...(allowedDomains ? { allowedDomains } : {}), diff --git a/packages/eve/src/search-tools.ts b/packages/eve/src/search-tools.ts index 7c7b77d..baa2145 100644 --- a/packages/eve/src/search-tools.ts +++ b/packages/eve/src/search-tools.ts @@ -28,30 +28,25 @@ function wrap(def: SearchToolDef): ToolDefinition { } /** - * Build eve `search` / `aggregate` / `count` tools over an Upstash Redis Search index — the eve + * Build eve `search` / `aggregate` / `count` tools over an Upstash Redis Search index, the eve * counterpart to the AI SDK adapter's `createSearchTools`. Returns a record of ready (already * `defineTool`-branded) tools; the index is created on first use (reactively). `redis` defaults to env. * - * eve is file-centric (filename = tool name), so build the set once in `agent/lib/` and re-export each - * tool from its own file: + * eve snapshots each tool file and resolves only **package** imports, so every `agent/tools/*.ts` file + * must be self-contained: call `defineSearchTools` in each file and export the member you want, repeating + * the same `schema` + `indexName` across them. Don't import a shared `agent/` module (e.g. a + * `../redis.js` or `../lib/book-search.js`) — it fails at the turn step with `Cannot find module`. Omit + * `redis` so it defaults to `Redis.fromEnv()`. * * ```ts - * // agent/lib/book-search.ts + * // agent/tools/search_books.ts * import { s } from "@upstash/redis"; * import { defineSearchTools } from "@upstash/agentkit-eve"; - * import { redis } from "../redis.js"; * - * export const bookSearch = defineSearchTools({ + * export default defineSearchTools({ * schema: s.object({ title: s.string(), author: s.string().noTokenize(), year: s.number() }), * indexName: "books", - * redis, - * }); - * ``` - * - * ```ts - * // agent/tools/search_books.ts - * import { bookSearch } from "../lib/book-search.js"; - * export default bookSearch.search; // also: aggregate_books.ts → bookSearch.aggregate, etc. + * }).search; // aggregate_books.ts → .aggregate, count_books.ts → .count (repeat schema + indexName) * ``` */ export function defineSearchTools(config: DefineSearchToolsConfig): SearchToolSet { diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 4c53c0c..2dee936 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -155,7 +155,7 @@ if (!success) throw new Error("rate limited"); Options - **`limiter`** _(required)_ — e.g. `Ratelimit.slidingWindow(20, "1 m")` or `fixedWindow(...)`. -- `redis` — the Upstash Redis client backing the limiter. +- `redis` — the Upstash Redis client backing the limiter; defaults to `Redis.fromEnv()`. - `prefix` — base key prefix; keys are `:` (default `agentkit:rateLimit`). There is no model wrapper; pass a per-user `identifier` to `.limit()` to throttle per user. @@ -168,7 +168,7 @@ Memoize deterministic tool results in Redis, keyed by user, then tool, then a ha ```ts // `wrap` returns a memoized version of your execute, keyed by userId + "getWeather" + the args hash. -const getWeather = tools.wrap("user-123", "getWeather", (args) => fetchWeather(args)); +const getWeather = cache.wrap("user-123", "getWeather", (args) => fetchWeather(args)); ```
diff --git a/packages/sdk/src/rate-limit.test.ts b/packages/sdk/src/rate-limit.test.ts index b6b4b34..d322f71 100644 --- a/packages/sdk/src/rate-limit.test.ts +++ b/packages/sdk/src/rate-limit.test.ts @@ -24,4 +24,14 @@ describe.skipIf(!hasRedisCreds)("createRateLimit (live Redis)", () => { const second = await ratelimit.limit(id); expect(second.success).toBe(false); }); + + // `redis` is optional everywhere in AgentKit; createRateLimit falls back to Redis.fromEnv(). + it("defaults redis to Redis.fromEnv() when omitted", async () => { + const ratelimit = createRateLimit({ + limiter: Ratelimit.slidingWindow(1, "60 s"), + prefix: `${prefix}:fromenv`, + }); + const result = await ratelimit.limit("user-2"); + expect(result.success).toBe(true); + }); }); diff --git a/packages/sdk/src/rate-limit.ts b/packages/sdk/src/rate-limit.ts index c8cfcc4..a9990c0 100644 --- a/packages/sdk/src/rate-limit.ts +++ b/packages/sdk/src/rate-limit.ts @@ -1,5 +1,5 @@ import { Ratelimit, type Duration } from "@upstash/ratelimit"; -import type { Redis } from "@upstash/redis"; +import { Redis } from "@upstash/redis"; // Re-export the `@upstash/ratelimit` surface AgentKit users need so they never have to import from // (or install) `@upstash/ratelimit` directly. `Ratelimit` is the class whose static helpers build a @@ -12,8 +12,8 @@ type Limiter = ConstructorParameters[0]["limiter"]; /** Configuration for {@link createRateLimit}. */ export interface RateLimitConfig { - /** Upstash Redis client used to back the limiter. */ - redis: Redis; + /** Upstash Redis client backing the limiter. Defaults to `Redis.fromEnv()`. */ + redis?: Redis; /** The limiter algorithm, e.g. `Ratelimit.slidingWindow(10, "60 s")` or `Ratelimit.fixedWindow(...)`. */ limiter: Limiter; /** Key prefix for the limiter. Defaults to `agentkit:rateLimit`; keys are `:`. */ @@ -33,7 +33,7 @@ export interface RateLimitConfig { */ export function createRateLimit(config: RateLimitConfig): Ratelimit { return new Ratelimit({ - redis: config.redis, + redis: config.redis ?? Redis.fromEnv(), limiter: config.limiter, prefix: config.prefix ?? "agentkit:rateLimit", }); diff --git a/packages/sdk/src/reactive-index.test.ts b/packages/sdk/src/reactive-index.test.ts new file mode 100644 index 0000000..8cff7dc --- /dev/null +++ b/packages/sdk/src/reactive-index.test.ts @@ -0,0 +1,197 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { s } from "@upstash/redis"; +import type { Redis } from "@upstash/redis"; +import { ReactiveSearchIndex } from "./reactive-index.js"; +import { cleanupKeys, hasRedisCreds, testRedis, uniquePrefix } from "./test-support.js"; + +/** + * These run against real Upstash Redis (per the project's no-mock-Redis policy). We never fake a + * Redis response — we only *observe* requests, by spying on the real `createIndex`/op functions with + * `vi.spyOn` left calling through. That lets us assert the central invariant of the reactive index: + * + * 1. A *missing* index is signalled differently per op, and the wrapper provisions + retries + * exactly once on that signal. + * 2. An *existing but empty* index returns ordinary empties — NOT the missing-index sentinels — + * so the wrapper must NOT re-provision (no duplicate create-index request) just because there + * happens to be no data. + * + * How each raw op behaves in the two states (verified by the first two tests below): + * + * | op | no index (missing) | index exists, no data | + * | ----------- | ------------------- | --------------------- | + * | `query` | `null` | `[]` | + * | `count` | `{ count: -1 }` | `{ count: 0 }` | + * | `aggregate` | throws | resolves (empty) | + * + * Counting `createIndex` calls needs a trick: `redis.search` is a getter that returns a *fresh* + * object every access, so `vi.spyOn(redis.search, "createIndex")` would spy on a throwaway. We + * snapshot the namespace once and feed it to the wrapper through a Proxy, so the wrapper and the spy + * share the same `createIndex` (which still closes over the real client and hits real Redis). + */ + +// A fuzzy text field, an exact-match (noTokenize) field for filtering, and a numeric field (numeric +// fields are FAST, so they're valid aggregation targets — a noTokenize string is not). +const Schema = s.object({ + title: s.string(), + tenant: s.string().noTokenize(), + views: s.number(), +}); + +// A concrete, always-valid filter that matches nothing here (so reads are genuinely empty/missing). +const FILTER = { tenant: { $eq: "nobody" } } as Parameters< + ReactiveSearchIndex["count"] +>[0]["filter"]; + +// A stats aggregation over the numeric field. +const AGGS = { aggregations: { viewsStats: { $stats: { field: "views" } } } } as Parameters< + ReactiveSearchIndex["aggregate"] +>[0]; + +// Index names must be identifier-safe (the real features sanitize the prefix the same way). +const idxName = (prefix: string) => prefix.replace(/[^a-zA-Z0-9_]/g, "_"); + +type CreateParams = Parameters[0]; + +describe.skipIf(!hasRedisCreds)("ReactiveSearchIndex (live Redis)", () => { + const redis = testRedis(); + // The DB caps at 10 search indexes, so tear everything down after each test. + const cleanups: Array<() => Promise> = []; + + afterEach(async () => { + vi.restoreAllMocks(); + for (const teardown of cleanups.splice(0).reverse()) { + await teardown().catch(() => { + /* best-effort cleanup */ + }); + } + }); + + /** Fresh, unique index name + prefix, with drop + key cleanup registered. */ + function names(label: string) { + const prefix = uniquePrefix(label); + const name = idxName(prefix); + cleanups.push(() => redis.search.index({ name, schema: Schema }).drop()); + cleanups.push(() => cleanupKeys(redis, prefix)); + return { prefix, name }; + } + + /** Create an existing-but-empty index (no documents) and wait until it's queryable. */ + async function seedEmptyIndex(prefix: string, name: string) { + await redis.search.createIndex({ + name, + dataType: "json", + prefix: `${prefix}:`, + schema: Schema, + existsOk: true, + } as CreateParams); + await redis.search.index({ name, schema: Schema }).waitIndexing(); + } + + /** + * Build a ReactiveSearchIndex whose `redis.search` is a single stable snapshot, and return a spy on + * its `createIndex` (call-through). Call AFTER any setup `createIndex` so setup isn't counted. + */ + function bind(prefix: string, name: string) { + const search = redis.search; // snapshot once so the spy survives repeated `.search` access + const createIndexSpy = vi.spyOn(search, "createIndex"); + const proxy = new Proxy(redis, { + get: (target, prop) => (prop === "search" ? search : Reflect.get(target, prop)), + }) as unknown as Redis; + const ri = new ReactiveSearchIndex({ + redis: proxy, + indexName: name, + prefix: `${prefix}:`, + schema: Schema, + }); + return { ri, createIndexSpy }; + } + + // --- 1. Characterize the raw index: how does each op signal "no index" vs "no data"? ------------- + + it("raw index signals a missing index per op (query→null, count→{count:-1}, aggregate→throws)", async () => { + const { name } = names("raw-missing"); + const raw = redis.search.index({ name, schema: Schema }); + + expect(await raw.query({ filter: FILTER })).toBeNull(); + expect(await raw.count({ filter: FILTER })).toEqual({ count: -1 }); + await expect(raw.aggregate(AGGS)).rejects.toThrow(); + }); + + it("raw existing-but-empty index returns ordinary empties, NOT the missing sentinels", async () => { + const { prefix, name } = names("raw-empty"); + await seedEmptyIndex(prefix, name); + const raw = redis.search.index({ name, schema: Schema }); + + // Distinct from the missing case: an array (not null), count 0 (not -1), and aggregate resolves. + expect(await raw.query({ filter: FILTER })).toEqual([]); + expect(await raw.count({ filter: FILTER })).toEqual({ count: 0 }); + await expect(raw.aggregate(AGGS)).resolves.toBeDefined(); + }); + + // --- 2. Reactive wrapper: provision exactly once on a missing index, retrying the op ------------- + + it("provisions once and retries the op on a missing index (query)", async () => { + const { prefix, name } = names("reactive-query"); + const { ri, createIndexSpy } = bind(prefix, name); + const querySpy = vi.spyOn(ri.index, "query"); + + expect(await ri.query({ filter: FILTER })).toEqual([]); // empty after provisioning + expect(createIndexSpy).toHaveBeenCalledTimes(1); // one create-index request + expect(querySpy).toHaveBeenCalledTimes(2); // initial (null) + retry after provisioning + }); + + it("provisions once and retries the op on a missing index (count)", async () => { + const { prefix, name } = names("reactive-count"); + const { ri, createIndexSpy } = bind(prefix, name); + const countSpy = vi.spyOn(ri.index, "count"); + + expect(await ri.count({ filter: FILTER })).toEqual({ count: 0 }); + expect(createIndexSpy).toHaveBeenCalledTimes(1); + expect(countSpy).toHaveBeenCalledTimes(2); + }); + + it("provisions once and retries the op on a missing index (aggregate)", async () => { + const { prefix, name } = names("reactive-agg"); + const { ri, createIndexSpy } = bind(prefix, name); + const aggSpy = vi.spyOn(ri.index, "aggregate"); + + await expect(ri.aggregate(AGGS)).resolves.toBeDefined(); + expect(createIndexSpy).toHaveBeenCalledTimes(1); + expect(aggSpy).toHaveBeenCalledTimes(2); + }); + + // --- 3. Reactive wrapper: do NOT provision just because the data is missing ---------------------- + + it("does NOT send a create-index request when the index exists but has no data", async () => { + const { prefix, name } = names("reactive-empty"); + await seedEmptyIndex(prefix, name); // create the index BEFORE spying so setup isn't counted + const { ri, createIndexSpy } = bind(prefix, name); + const querySpy = vi.spyOn(ri.index, "query"); + const countSpy = vi.spyOn(ri.index, "count"); + const aggSpy = vi.spyOn(ri.index, "aggregate"); + + expect(await ri.query({ filter: FILTER })).toEqual([]); + expect(await ri.count({ filter: FILTER })).toEqual({ count: 0 }); + await expect(ri.aggregate(AGGS)).resolves.toBeDefined(); + + // The crux: empty results are not the missing-index sentinels, so there is no re-provision... + expect(createIndexSpy).not.toHaveBeenCalled(); + // ...and each op ran exactly once (no duplicate request from a spurious retry). + expect(querySpy).toHaveBeenCalledTimes(1); + expect(countSpy).toHaveBeenCalledTimes(1); + expect(aggSpy).toHaveBeenCalledTimes(1); + }); + + // --- 4. Reactive wrapper: provisioning is memoized across reads on one instance ------------------ + + it("provisions only once across repeated reads on the same instance", async () => { + const { prefix, name } = names("reactive-memo"); + const { ri, createIndexSpy } = bind(prefix, name); + + await ri.query({ filter: FILTER }); // missing → provisions + await ri.query({ filter: FILTER }); // now exists → no re-provision + await ri.count({ filter: FILTER }); + + expect(createIndexSpy).toHaveBeenCalledTimes(1); + }); +});