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);
+ });
+});