From 1d9b1aba469d9e1dff2d014b48854fdafb22ad3a Mon Sep 17 00:00:00 2001 From: Benjamin Coriou Date: Wed, 26 Aug 2026 22:52:01 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Maintenance:=20adversarial=20audit=20remedi?= =?UTF-8?q?ation=20=E2=80=94=20bounded=20glob=20matcher,=20input=20bounds,?= =?UTF-8?q?=20failure-path=20fixes,=20infra=20truth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 24 findings survived the refute pass; this lands the surgical fixes: - filter: bounded DP glob matcher (ReDoS-proof, parity-tested 408 asserts), eager pattern validation ([z-a] now 400 not silent empty/no-op delete), reject [] and chained brackets, no ''→0 coercion - bounds: vector elements rejected beyond Float32 range; /upsert data<=1M chars + metadata serialized-size cap (amplification fix) - read paths: fetch/delete ids route through validateId(); /range throws instead of silently truncating past ~1M keys - runtime: Bun.serve idleTimeout derived >= requestTimeout; watchdog preserves unhealthySince on failed reinit (next-tick retry per docstring); FT.INFO verification failures fail loud instead of caching unverified dimension; embedding deadline armed through body consumption; embedMany chunked (256 items / 250k chars) preserving order - infra: --frozen-lockfile in test.yml(2)/compat.yml(1)/maintenance.yml(2); biome schema 2.5.10; runtime image drops dev-inclusive node_modules (~94MB) Refuted during audit: default-namespace 'global wipe' and colon-ID aliasing (v:: prefix arithmetic), string relational operators (exact Upstash parity), health liveness/readiness split. Filed as issues: #13 zombie mutations, #14 query response budget, #15 watchdog slow-ping semantics, #16 rename race, #17 upsert allocation chunking. --- .github/workflows/compat.yml | 2 +- .github/workflows/maintenance.yml | 4 +- .github/workflows/test.yml | 4 +- CLAUDE.md | 4 +- Dockerfile | 1 - README.md | 15 +- biome.json | 2 +- src/embedding.ts | 153 +++++++++++--- src/filter/evaluator.ts | 228 ++++++++++++++++---- src/filter/index.ts | 23 +- src/filter/tokenizer.ts | 8 + src/index.ts | 38 +++- src/routes/delete.ts | 9 +- src/routes/fetch.ts | 10 +- src/routes/query.ts | 6 +- src/routes/range.ts | 57 +++-- src/routes/update.ts | 6 +- src/routes/upsert.ts | 29 ++- src/translate/index.ts | 52 ++--- src/translate/vectors.ts | 16 ++ tests/unit/embedding-chunking.test.ts | 291 ++++++++++++++++++++++++++ tests/unit/filter.test.ts | 252 +++++++++++++++++++--- tests/unit/idle-timeout.test.ts | 25 +++ tests/unit/index-verify.test.ts | 62 ++++++ tests/unit/route-validation.test.ts | 137 ++++++++++++ tests/unit/vector-limits.test.ts | 107 ++++++++++ 26 files changed, 1348 insertions(+), 193 deletions(-) create mode 100644 tests/unit/embedding-chunking.test.ts create mode 100644 tests/unit/idle-timeout.test.ts create mode 100644 tests/unit/index-verify.test.ts create mode 100644 tests/unit/route-validation.test.ts create mode 100644 tests/unit/vector-limits.test.ts diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml index 9dfbcc1..eea0879 100644 --- a/.github/workflows/compat.yml +++ b/.github/workflows/compat.yml @@ -48,7 +48,7 @@ jobs: key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} restore-keys: bun-${{ runner.os }}- - - run: bun install + - run: bun install --frozen-lockfile - name: Install latest @upstash/vector run: bun add -d @upstash/vector@latest diff --git a/.github/workflows/maintenance.yml b/.github/workflows/maintenance.yml index f19fd7c..1bb1ef5 100644 --- a/.github/workflows/maintenance.yml +++ b/.github/workflows/maintenance.yml @@ -118,7 +118,7 @@ jobs: with: bun-version: latest - - run: bun install + - run: bun install --frozen-lockfile - name: Bun version under test run: bun --version @@ -177,7 +177,7 @@ jobs: with: bun-version: ${{ env.BUN_PIN }} - - run: bun install + - run: bun install --frozen-lockfile - name: Start up-vector run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b115c67..b909d38 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,7 @@ jobs: key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} restore-keys: bun-${{ runner.os }}- - - run: bun install + - run: bun install --frozen-lockfile - run: bun run typecheck - run: bun run lint - run: bun test tests/unit @@ -66,7 +66,7 @@ jobs: key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} restore-keys: bun-${{ runner.os }}- - - run: bun install + - run: bun install --frozen-lockfile - name: Start up-vector run: | diff --git a/CLAUDE.md b/CLAUDE.md index 591e3e9..49742eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ bun run typecheck # tsc --noEmit ### Docker ```bash -docker compose up -d # Production (up-vector + Redis Stack) +docker compose up -d # Production (network-internal by design; Coolify/proxy fronts it — see README override for host access) docker compose -f docker-compose.yml -f docker-compose.dev.yml up # Dev (watch mode, debug logs) ``` @@ -154,7 +154,7 @@ All prefixed `UPVECTOR_`: ## Implementation Status Phases 1-6 complete. Dense-vector CRUD + query + filtering + namespaces + production hardening + provider-backed `/upsert-data`/`/query-data`. -383 tests passing (249 unit, 60 integration, 74 SDK compatibility). +444 tests passing (310 unit, 60 integration, 74 SDK compatibility). Production hardening includes structured JSON logging, graceful shutdown, health probes, request timeouts, Prometheus metrics (optional scrape token), body limits, binary vector round-trip protection, Redis client self-heal, and process-level error handlers. See `PLAN.md` for the full architecture and phase breakdown. diff --git a/Dockerfile b/Dockerfile index c5da4f7..2523e41 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,6 @@ RUN apk add --no-cache curl # upstream image. Running as root would let a process escape into the rest of # the filesystem if it ever found an arbitrary-write bug. COPY --from=builder --chown=bun:bun /app/dist ./dist -COPY --from=builder --chown=bun:bun /app/node_modules ./node_modules COPY --from=builder --chown=bun:bun /app/package.json ./ USER bun diff --git a/README.md b/README.md index cfae9d6..ff6337e 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,18 @@ cp .env.example .env docker compose up -d ``` -The API is now available at `http://localhost:8080`. +The stack intentionally publishes **no host port** (`expose` only), so platform proxies +like [Coolify](https://coolify.io) can join the Docker network and front the service directly. +To reach it from your own machine instead, drop in an override before `up -d`: + +```yaml +# docker-compose.override.yml +services: + up-vector: + ports: ["${UPVECTOR_PORT:-8080}:8080"] +``` + +The API is then available at `http://localhost:8080`. ## Usage with @upstash/vector @@ -314,7 +325,7 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for development setup and contribution ```bash cp .env.example .env # Set UPVECTOR_TOKEN -docker compose up -d # Starts up-vector + Redis Stack +docker compose up -d # Starts up-vector + Redis Stack (network-internal; see Quick Start for host access) ``` ### With up-redis (side-by-side) diff --git a/biome.json b/biome.json index 2f42f06..730cc01 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.10/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/src/embedding.ts b/src/embedding.ts index c48a1fb..3d2c537 100644 --- a/src/embedding.ts +++ b/src/embedding.ts @@ -85,6 +85,56 @@ type OpenAIEmbeddingResponse = { } } +/** + * Provider-side /embeddings requests have per-request budgets (~300k tokens on + * OpenAI); a locally-valid batch (up to 1000 inputs x up to ~1M chars each) + * can deterministically exceed them and fail the whole batch with a + * non-retryable 400. embedMany splits inputs into chunks bounded by both: + */ +export const EMBED_MAX_ITEMS_PER_REQUEST = 256 +export const EMBED_MAX_CHARS_PER_REQUEST = 250_000 + +/** + * Split inputs into provider-request-sized chunks (greedy: whichever bound — + * item count or total characters — fills first closes a chunk). A single + * oversized input still forms its own chunk; the provider enforces per-item + * limits. Chunk boundaries preserve input order. + */ +export function chunkEmbeddingInputs(inputs: string[]): string[][] { + const chunks: string[][] = [] + let current: string[] = [] + let currentChars = 0 + for (const input of inputs) { + if ( + current.length > 0 && + (current.length === EMBED_MAX_ITEMS_PER_REQUEST || + currentChars + input.length > EMBED_MAX_CHARS_PER_REQUEST) + ) { + chunks.push(current) + current = [] + currentChars = 0 + } + current.push(input) + currentChars += input.length + } + if (current.length > 0) chunks.push(current) + return chunks +} + +/** + * An in-flight provider request plus its deadline handle. The abort timer + * stays armed until `settle()` is called after the response BODY is fully + * consumed — a provider can deliver headers promptly and then stall mid-body, + * which would otherwise hang forever despite UPVECTOR_EMBEDDING_TIMEOUT_MS. + */ +type RequestDeadline = { + response: Response + /** Disarm the deadline (idempotent). Must be called exactly once, after body consumption. */ + settle: () => void + /** Whether the deadline fired before settle(). */ + timedOut: () => boolean +} + export class OpenAICompatibleEmbeddingProvider implements EmbeddingProvider { readonly name = "openai" readonly model: string @@ -110,29 +160,49 @@ export class OpenAICompatibleEmbeddingProvider implements EmbeddingProvider { async embedMany(inputs: string[]): Promise { if (inputs.length === 0) return [] + const chunks = chunkEmbeddingInputs(inputs) + // Chunks are embedded sequentially so strict input→embedding order holds; + // all-or-nothing semantics: any chunk that exhausts its retries fails + // the entire call. + const embeddings: number[][] = [] + for (const chunk of chunks) { + embeddings.push(...(await this.embedChunk(chunk))) + } + return embeddings + } + + private async embedChunk(chunk: string[]): Promise { for (let attempt = 0; attempt <= this.retries; attempt++) { try { - const response = await this.request(inputs) - if (response.ok) { - return await this.parseResponse(response, inputs.length) - } + const deadline = await this.request(chunk) + try { + if (deadline.response.ok) { + return await this.underDeadline(deadline, () => + this.parseResponse(deadline.response, chunk.length), + ) + } - if (isRetryableStatus(response.status) && attempt < this.retries) { - await sleep(this.retryDelayMs(attempt, response.headers.get("retry-after"))) - continue - } + if (isRetryableStatus(deadline.response.status) && attempt < this.retries) { + await sleep(this.retryDelayMs(attempt, deadline.response.headers.get("retry-after"))) + continue + } - const message = await readProviderError(response) - if (message) { - log.warn("embedding provider error", { - status: response.status, - providerMessage: message, - }) + const message = await this.underDeadline(deadline, () => + readProviderError(deadline.response), + ) + if (message) { + log.warn("embedding provider error", { + status: deadline.response.status, + providerMessage: message, + }) + } + throw new EmbeddingProviderError( + `Embedding provider failed with HTTP ${deadline.response.status}`, + 502, + ) + } finally { + deadline.settle() } - throw new EmbeddingProviderError( - `Embedding provider failed with HTTP ${response.status}`, - 502, - ) } catch (err) { if (err instanceof EmbeddingProviderError) { if (err.status === 504 && attempt < this.retries) { @@ -152,7 +222,7 @@ export class OpenAICompatibleEmbeddingProvider implements EmbeddingProvider { throw new EmbeddingProviderError("Embedding provider request failed", 502) } - private async request(inputs: string[]): Promise { + private async request(inputs: string[]): Promise { const body: Record = { model: this.model, input: inputs, @@ -161,30 +231,55 @@ export class OpenAICompatibleEmbeddingProvider implements EmbeddingProvider { body.dimensions = this.dimension } + const init = (): RequestInit => ({ + method: "POST", + headers: this.headers(), + body: JSON.stringify(body), + }) + if (this.timeoutMs === 0) { - return this.fetchFn(`${this.baseUrl}/embeddings`, { - method: "POST", - headers: this.headers(), - body: JSON.stringify(body), - }) + // Timeout disabled: no controller, nothing to arm or settle. + const response = await this.fetchFn(`${this.baseUrl}/embeddings`, init()) + return { response, settle: () => {}, timedOut: () => false } } const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), this.timeoutMs) try { - return await this.fetchFn(`${this.baseUrl}/embeddings`, { - method: "POST", - headers: this.headers(), - body: JSON.stringify(body), + const response = await this.fetchFn(`${this.baseUrl}/embeddings`, { + ...init(), signal: controller.signal, }) + // Keep the deadline armed across body reads; see RequestDeadline. + return { + response, + settle: () => clearTimeout(timeout), + timedOut: () => controller.signal.aborted, + } } catch (err) { + clearTimeout(timeout) if (controller.signal.aborted) { throw new EmbeddingProviderError("Embedding provider timed out", 504) } throw err + } + } + + /** + * Read the response body under the still-armed request deadline. A stalled + * body surfaces through the standard timeout error (504), which feeds the + * existing retry logic. + */ + private async underDeadline(deadline: RequestDeadline, read: () => Promise): Promise { + try { + return await read() + } catch (err) { + if (deadline.timedOut()) { + throw new EmbeddingProviderError("Embedding provider timed out", 504) + } + throw err } finally { - clearTimeout(timeout) + deadline.settle() } } diff --git a/src/filter/evaluator.ts b/src/filter/evaluator.ts index 00c4bc4..42d4f38 100644 --- a/src/filter/evaluator.ts +++ b/src/filter/evaluator.ts @@ -11,7 +11,7 @@ export function evaluate(node: FilterNode, metadata: Record): b return evalComparison(resolveField(metadata, node.field), node.op, node.value) case "glob": { const val = resolveField(metadata, node.field) - const match = typeof val === "string" && globToRegex(node.pattern).test(val) + const match = typeof val === "string" && compileGlob(node.pattern).test(val) return node.negated ? !match : match } case "in": { @@ -134,12 +134,20 @@ function looseEqual(a: unknown, b: unknown): boolean { if (typeof a === "number" && typeof b === "number") return a === b // Boolean comparison if (typeof a === "boolean" && typeof b === "boolean") return a === b - // Cross-type: number/string coercion - if (typeof a === "number" && typeof b === "string") return a === Number(b) - if (typeof a === "string" && typeof b === "number") return Number(a) === b - // Boolean/number coercion - if (typeof a === "boolean") return a === (b === 1 || b === true || b === "true") - if (typeof b === "boolean") return b === (a === 1 || a === true || a === "true") + // Cross-type: number/string coercion. The string must be a non-empty + // numeric literal — otherwise Number("") === 0 silently makes `= ''` + // match metadata values of 0. + if (typeof a === "number" && typeof b === "string") { + return b.trim() !== "" && !Number.isNaN(Number(b)) && a === Number(b) + } + if (typeof a === "string" && typeof b === "number") { + return a.trim() !== "" && !Number.isNaN(Number(a)) && Number(a) === b + } + // Boolean/non-boolean: only the truthy equivalents 1 and 'true' may cross + // the type boundary. Without this guard, `false` matched literal 0 because + // both sides of the old expression reduced to false. + if (typeof a === "boolean") return a ? b === 1 || b === "true" : false + if (typeof b === "boolean") return b ? a === 1 || a === "true" : false return a === b } @@ -151,58 +159,192 @@ function toNumber(val: unknown): number { } const MAX_GLOB_PATTERN_LENGTH = 512 -const globCache = new Map() +const globCache = new Map() const MAX_GLOB_CACHE_SIZE = 256 -export function globToRegex(pattern: string): RegExp { - if (pattern.length > MAX_GLOB_PATTERN_LENGTH) { - throw new ValidationError(`Glob pattern too long (max ${MAX_GLOB_PATTERN_LENGTH} chars)`) - } - const cached = globCache.get(pattern) - if (cached) { - // Refresh LRU position by re-inserting (matches the filter AST cache). - // Without this, hot patterns get evicted just because they were inserted - // early — defeating the point of caching them. - globCache.delete(pattern) - globCache.set(pattern, cached) - return cached +// A compiled glob pattern. Matching runs an NFA scan over the subject +// (worst-case O(n·k)) instead of building a RegExp whose free `.*` wildcards +// backtrack catastrophically on adversarial subjects. +export type GlobMatcher = { + test(subject: string): boolean +} + +type GlobToken = + | { kind: "literal"; ch: string } + | { kind: "any" } + | { kind: "class"; negated: boolean; singles: Set; ranges: Array<[number, number]> } + | { kind: "star" } + +// Compile one [...] character class. Ranges must be in ascending order +// ([z-a] throws), mirroring the SyntaxError the old RegExp-based compiler +// surfaced lazily during evaluation. +function compileGlobClass(body: string): GlobToken { + const negated = body.startsWith("^") + const content = negated ? body.slice(1) : body + const singles = new Set() + const ranges: Array<[number, number]> = [] + let j = 0 + while (j < content.length) { + if (j + 2 < content.length && content[j + 1] === "-") { + const lo = content.charCodeAt(j) + const hi = content.charCodeAt(j + 2) + if (lo > hi) { + throw new ValidationError( + `Invalid glob pattern: range out of order in character class '${content.slice(j, j + 3)}'`, + ) + } + ranges.push([lo, hi]) + j += 3 + } else { + singles.add(content[j]) + j++ + } } + return { kind: "class", negated, singles, ranges } +} - let regex = "" +// Tokenize the pattern into literals, ?, [...], and * (consecutive * collapse). +// An unclosed [ is treated as a literal, matching the old behavior. +function compileTokens(pattern: string): GlobToken[] { + const tokens: GlobToken[] = [] let i = 0 while (i < pattern.length) { const ch = pattern[i] if (ch === "*") { - // Collapse consecutive wildcards to prevent catastrophic backtracking while (i + 1 < pattern.length && pattern[i + 1] === "*") i++ - regex += ".*" + tokens.push({ kind: "star" }) } else if (ch === "?") { - regex += "." + tokens.push({ kind: "any" }) } else if (ch === "[") { - // Pass through character classes — ensure bracket is closed - i++ // skip [ - let classContent = "" - while (i < pattern.length && pattern[i] !== "]") { - classContent += pattern[i] - i++ - } - if (i >= pattern.length) { - // Unclosed bracket — treat the opening [ as a literal - regex += "\\[" - regex += classContent.replace(/[.*+?^${}()|\\[\]]/g, "\\$&") - continue // i is already at end, loop will terminate + const close = pattern.indexOf("]", i + 1) + if (close === -1) { + for (; i < pattern.length; i++) tokens.push({ kind: "literal", ch: pattern[i] }) + continue } - regex += `[${classContent}]` - // biome-ignore lint/suspicious/noTemplateCurlyInString: literal regex metacharacters, not template - } else if (".+^${}()|\\".includes(ch)) { - regex += `\\${ch}` + tokens.push(compileGlobClass(pattern.slice(i + 1, close))) + i = close } else { - regex += ch + tokens.push({ kind: "literal", ch }) } i++ } - const compiled = new RegExp(`^${regex}$`) - // Evict oldest entries if cache is full + return tokens +} + +function classMatches(token: Extract, ch: string): boolean { + let hit = token.singles.has(ch) + if (!hit && token.ranges.length > 0) { + const code = ch.charCodeAt(0) + for (const [lo, hi] of token.ranges) { + if (code >= lo && code <= hi) { + hit = true + break + } + } + } + return token.negated ? !hit : hit +} + +function createMatcher(tokens: GlobToken[]): GlobMatcher { + const n = tokens.length + const WORDS = (n >> 5) + 1 + + // State bitsets: bit i of dp means tokens[0..i-1] match some prefix of the + // subject. Transitions run word-parallel: consuming a character shifts the + // state set left one position and masks it with that character's acceptance + // mask; stars additionally persist in place (they absorb any sequence). + // Per character this costs O(k/32) word ops plus O(#stars) for the epsilon + // closure — worst-case O(n·k) overall, with no backtracking. + const starMask = new Uint32Array(WORDS) + const starIdx: number[] = [] + for (let i = 0; i < n; i++) { + if (tokens[i].kind === "star") { + starMask[i >> 5] |= 1 << (i & 31) + starIdx.push(i) + } + } + + // Acceptance masks are cached per character code and stored PRE-SHIFTED: + // a character accepted by token i sets bit i+1, matching how the DP + // transition pairs each shifted state with the token about to consume it. + // Subjects with unusually rich alphabets stop growing the cache and fall + // back to ad-hoc masks, so memory stays bounded. + const acceptCache = new Map() + const MAX_CACHED_CHARS = 1024 + + function accepts(token: GlobToken, ch: string): boolean { + if (token.kind === "any") return true + if (token.kind === "literal") return token.ch === ch + if (token.kind === "class") return classMatches(token, ch) + return false + } + + function acceptMask(code: number, ch: string): Uint32Array { + const cached = acceptCache.get(code) + if (cached) return cached + const mask = new Uint32Array(WORDS) + for (let i = 0; i < n; i++) { + if (accepts(tokens[i], ch)) { + const pos = i + 1 + mask[pos >> 5] |= 1 << (pos & 31) + } + } + if (acceptCache.size < MAX_CACHED_CHARS) acceptCache.set(code, mask) + return mask + } + + // Epsilon closure: a star may also match the empty sequence. + function propagateStars(buf: Uint32Array): void { + for (let s = 0; s < starIdx.length; s++) { + const idx = starIdx[s] + if (buf[idx >> 5] & (1 << (idx & 31))) buf[(idx + 1) >> 5] |= 1 << ((idx + 1) & 31) + } + } + + let dp = new Uint32Array(WORDS) + let next = new Uint32Array(WORDS) + + return { + test(subject: string): boolean { + dp.fill(0) + dp[0] = 1 + propagateStars(dp) + for (let s = 0; s < subject.length; s++) { + const ch = subject[s] + const mask = acceptMask(ch.charCodeAt(0), ch) + next.fill(0) + let carry = 0 + for (let w = 0; w < WORDS; w++) { + next[w] = (((dp[w] << 1) | carry) & mask[w]) | (dp[w] & starMask[w]) + carry = dp[w] >>> 31 + } + propagateStars(next) + let live = 0 + for (let w = 0; w < WORDS; w++) live |= next[w] + if (live === 0) return false + const swap = dp + dp = next + next = swap + } + return (dp[n >> 5] & (1 << (n & 31))) !== 0 + }, + } +} + +export function compileGlob(pattern: string): GlobMatcher { + if (pattern.length > MAX_GLOB_PATTERN_LENGTH) { + throw new ValidationError(`Glob pattern too long (max ${MAX_GLOB_PATTERN_LENGTH} chars)`) + } + const cached = globCache.get(pattern) + if (cached) { + // Refresh LRU position by re-inserting (matches the filter AST cache). + // Without this, hot patterns get evicted just because they were inserted + // early — defeating the point of caching them. + globCache.delete(pattern) + globCache.set(pattern, cached) + return cached + } + const compiled = createMatcher(compileTokens(pattern)) if (globCache.size >= MAX_GLOB_CACHE_SIZE) { const firstKey = globCache.keys().next().value if (firstKey !== undefined) globCache.delete(firstKey) diff --git a/src/filter/index.ts b/src/filter/index.ts index 84be37f..55c7b3b 100644 --- a/src/filter/index.ts +++ b/src/filter/index.ts @@ -1,9 +1,9 @@ -import { evaluate } from "./evaluator" +import { compileGlob, evaluate } from "./evaluator" import { parse } from "./parser" import { tokenize } from "./tokenizer" import type { FilterNode } from "./types" -export { evaluate, globToRegex, resolveField } from "./evaluator" +export { compileGlob, evaluate, resolveField } from "./evaluator" export { parse } from "./parser" export { tokenize } from "./tokenizer" export type { FilterNode, Token, TokenType, Value } from "./types" @@ -23,6 +23,7 @@ export function compileFilter(filter: string): FilterNode { return cached } const ast = parse(tokenize(filter)) + validateGlobPatterns(ast) if (filterAstCache.size >= FILTER_AST_CACHE_SIZE) { const oldest = filterAstCache.keys().next().value if (oldest !== undefined) filterAstCache.delete(oldest) @@ -31,6 +32,24 @@ export function compileFilter(filter: string): FilterNode { return ast } +// Eagerly compile every GLOB pattern in the AST so malformed patterns (e.g. +// out-of-order character classes) raise ValidationError at compile time. +// Without this, the error surfaced lazily inside evaluate() where query and +// delete consumers swallow it per-candidate — turning a bad filter into a +// silent no-op ({result: []} / {deleted: 0} with HTTP 200). +function validateGlobPatterns(node: FilterNode): void { + switch (node.type) { + case "and": + case "or": + validateGlobPatterns(node.left) + validateGlobPatterns(node.right) + break + case "glob": + compileGlob(node.pattern) + break + } +} + export function evaluateFilter(filter: string, metadata: Record): boolean { return evaluate(compileFilter(filter), metadata) } diff --git a/src/filter/tokenizer.ts b/src/filter/tokenizer.ts index 0230124..01acc99 100644 --- a/src/filter/tokenizer.ts +++ b/src/filter/tokenizer.ts @@ -114,10 +114,18 @@ export function tokenize(input: string): Token[] { throw new ValidationError(`Array index too long at position ${bracketStart}`) } } + if (bracketBody === 0) { + throw new ValidationError(`Empty array index at position ${bracketStart}`) + } if (i >= input.length) { throw new ValidationError(`Unclosed array index at position ${bracketStart}`) } i++ // skip ] + if (input[i] === "[") { + throw new ValidationError( + `Chained array indexing not supported at position ${bracketStart}`, + ) + } } else { break } diff --git a/src/index.ts b/src/index.ts index 07c1c8a..53d695c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,23 @@ function redactUrl(url: string): string { return "***" } } +/** + * Derive Bun.serve's idleTimeout (seconds) from the app-level request budget. + * + * Bun defaults idleTimeout to 10s, which silently resets the connection + * whenever a handler legitimately stays silent past that point — well below + * UPVECTOR_REQUEST_TIMEOUT's default of 30s, whose expiry is supposed to + * produce a proper HTTP 504. The derived ceiling must exceed the app-level + * budget so our timeout middleware wins the race against Bun killing the + * socket. Bun caps idleTimeout at 255 seconds. + */ +export function deriveIdleTimeoutSeconds(requestTimeoutMs: number): number { + if (requestTimeoutMs === 0) { + // App-level timeout disabled: keep sockets open up to Bun's maximum. + return 255 + } + return Math.min(255, Math.max(15, Math.ceil((requestTimeoutMs + 5000) / 1000))) +} async function main(): Promise { await initRedis() @@ -28,6 +45,7 @@ async function main(): Promise { fetch: app.fetch, port: config.port, hostname: config.host, + idleTimeout: deriveIdleTimeoutSeconds(config.requestTimeout), }) log.info("server started", { @@ -125,8 +143,10 @@ async function main(): Promise { unhealthySince = null } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err) - log.error("redis reinit failed, will retry", { error: msg }) - unhealthySince = now + // Leave unhealthySince untouched: it stays past the threshold, so + // the very next tick retries instead of waiting the full + // redisReinitAfterMs again. Reset to null only on success. + log.error("redis reinit failed, will retry on next tick", { error: msg }) } } } finally { @@ -138,9 +158,11 @@ async function main(): Promise { } } -main().catch((err) => { - const message = err instanceof Error ? err.message : String(err) - const stack = err instanceof Error ? err.stack : undefined - log.error("failed to start", { error: message, stack }) - process.exit(1) -}) +if (import.meta.main) { + main().catch((err) => { + const message = err instanceof Error ? err.message : String(err) + const stack = err instanceof Error ? err.stack : undefined + log.error("failed to start", { error: message, stack }) + process.exit(1) + }) +} diff --git a/src/routes/delete.ts b/src/routes/delete.ts index 7764b16..cdf1f41 100644 --- a/src/routes/delete.ts +++ b/src/routes/delete.ts @@ -5,6 +5,7 @@ import { compileFilter, evaluate } from "../filter" import { getClient } from "../redis" import { deleteKeysByPattern, + validateId, validateNamespace, validatePrefix, vectorKey, @@ -12,7 +13,6 @@ import { } from "../translate/keys" const MAX_SCAN_ITERATIONS = 10_000 -const MAX_ID_LENGTH = 1024 const idSchema = z .union([ @@ -20,8 +20,6 @@ const idSchema = z z.number().refine((n) => Number.isFinite(n), "Vector ID must be a finite number"), ]) .transform(String) - .refine((s) => s.length > 0, "Vector ID must not be empty") - .refine((s) => s.length <= MAX_ID_LENGTH, `Vector ID must not exceed ${MAX_ID_LENGTH} characters`) const DeleteBody = z .object({ @@ -40,6 +38,11 @@ export const deleteRoutes = new Hono() const handleDelete = async (c: Context) => { const body = await c.req.json() const parsed = DeleteBody.parse(body) + if (parsed.ids) { + // Delete paths share the write-path ID contract (update.ts) — empty ids, + // control characters, and over-length ids are rejected identically. + for (const id of parsed.ids) validateId(id) + } const ns = c.req.param("namespace") ?? "" validateNamespace(ns) const redis = getClient() diff --git a/src/routes/fetch.ts b/src/routes/fetch.ts index ac8c371..d41698e 100644 --- a/src/routes/fetch.ts +++ b/src/routes/fetch.ts @@ -3,6 +3,7 @@ import { z } from "zod" import { getClient } from "../redis" import { parseVectorKey, + validateId, validateNamespace, validatePrefix, vectorKey, @@ -11,16 +12,12 @@ import { import { decodeVectorBase64 } from "../translate/vectors" import type { Vector } from "../types" -const MAX_ID_LENGTH = 1024 - const idSchema = z .union([ z.string(), z.number().refine((n) => Number.isFinite(n), "Vector ID must be a finite number"), ]) .transform(String) - .refine((s) => s.length > 0, "Vector ID must not be empty") - .refine((s) => s.length <= MAX_ID_LENGTH, `Vector ID must not exceed ${MAX_ID_LENGTH} characters`) const FetchBody = z.object({ ids: z.array(idSchema).max(1000, "Batch must not exceed 1000 ids").optional(), @@ -37,6 +34,11 @@ const handleFetch = async (c: Context) => { const parsed = FetchBody.parse(body) const ns = c.req.param("namespace") ?? "" validateNamespace(ns) + if (parsed.ids) { + // Read paths share the write-path ID contract (update.ts) — empty ids, + // control characters, and over-length ids are rejected identically. + for (const id of parsed.ids) validateId(id) + } const redis = getClient() // Fetch by IDs (default path, also used when both ids and prefix are given) diff --git a/src/routes/query.ts b/src/routes/query.ts index 0d9ccbb..be8c1e3 100644 --- a/src/routes/query.ts +++ b/src/routes/query.ts @@ -8,7 +8,7 @@ import { getClient } from "../redis" import { loadDimension } from "../translate/index" import { indexName, parseVectorKey, validateNamespace } from "../translate/keys" import { normalizeScore } from "../translate/scores" -import { decodeVectorBase64, encodeVector } from "../translate/vectors" +import { decodeVectorBase64, encodeVector, finiteNumber } from "../translate/vectors" import type { QueryResult } from "../types" const OVER_FETCH_FACTOR = 3 @@ -19,10 +19,6 @@ const MAX_VECTOR_DIM = 16384 const MAX_BATCH_QUERIES = 100 const UnsupportedField = z.never().optional() -const finiteNumber = z.number().refine((n) => Number.isFinite(n), { - message: "Vector values must be finite numbers (no NaN or Infinity)", -}) - export const DenseQuerySchema = z.object({ vector: z .array(finiteNumber) diff --git a/src/routes/range.ts b/src/routes/range.ts index 7a9b431..996bb6c 100644 --- a/src/routes/range.ts +++ b/src/routes/range.ts @@ -1,5 +1,6 @@ import { type Context, Hono } from "hono" import { z } from "zod" +import { ValidationError } from "../errors" import { getClient } from "../redis" import { parseVectorKey, validateNamespace, validatePrefix, vectorPrefix } from "../translate/keys" import { decodeVectorBase64 } from "../translate/vectors" @@ -21,30 +22,28 @@ const RangeBody = z.object({ const MAX_SCAN_ITERATIONS = 10_000 -export const rangeRoutes = new Hono() - -const handleRange = async (c: Context) => { - const body = await c.req.json() - const parsed = RangeBody.parse(body) - const ns = c.req.param("namespace") ?? "" - validateNamespace(ns) - const redis = getClient() - - if (parsed.prefix) validatePrefix(parsed.prefix) - const basePrefix = vectorPrefix(ns) - const pattern = parsed.prefix ? `${basePrefix}${parsed.prefix}*` : `${basePrefix}*` - - // Upstash's public cursor is an offset string ("0", "100", ...), not a - // Redis SCAN cursor. Scan the namespace, sort for stable paging, then slice by - // offset so the endpoint never returns more than `limit`. - const offset = parsed.cursor === "" ? 0 : Number(parsed.cursor) +/** + * Scans every key matching `pattern`, deduplicating SCAN results. Throws a + * ValidationError when the scan-iteration cap is exhausted before Redis + * returns cursor "0", so oversized namespaces fail loudly instead of + * presenting a truncated enumeration as complete. + */ +export async function collectScanKeys( + redis: ReturnType, + pattern: string, + maxIterations: number = MAX_SCAN_ITERATIONS, +): Promise> { let scanCursor = "0" const collectedKeys = new Set() const seenKeys = new Set() let iterations = 0 do { - if (++iterations > MAX_SCAN_ITERATIONS) break + if (++iterations > maxIterations) { + throw new ValidationError( + `Namespace exceeds the scanable key limit (${maxIterations} SCAN iterations); range enumeration aborted`, + ) + } const result = await redis.scan(scanCursor, "MATCH", pattern, "COUNT", 100) const [next, keys] = result as unknown as [string, string[]] @@ -58,6 +57,28 @@ const handleRange = async (c: Context) => { scanCursor = next } while (scanCursor !== "0") + return collectedKeys +} + +export const rangeRoutes = new Hono() + +const handleRange = async (c: Context) => { + const body = await c.req.json() + const parsed = RangeBody.parse(body) + const ns = c.req.param("namespace") ?? "" + validateNamespace(ns) + const redis = getClient() + + if (parsed.prefix) validatePrefix(parsed.prefix) + const basePrefix = vectorPrefix(ns) + const pattern = parsed.prefix ? `${basePrefix}${parsed.prefix}*` : `${basePrefix}*` + + // Upstash's public cursor is an offset string ("0", "100", ...), not a + // Redis SCAN cursor. Scan the namespace, sort for stable paging, then slice by + // offset so the endpoint never returns more than `limit`. + const offset = parsed.cursor === "" ? 0 : Number(parsed.cursor) + const collectedKeys = await collectScanKeys(redis, pattern) + const pageKeys = Array.from(collectedKeys) .sort() .slice(offset, offset + parsed.limit) diff --git a/src/routes/update.ts b/src/routes/update.ts index 75f7a79..4ce35bf 100644 --- a/src/routes/update.ts +++ b/src/routes/update.ts @@ -6,11 +6,7 @@ import { EmbeddingProviderError, ValidationError } from "../errors" import { getClient } from "../redis" import { loadDimension } from "../translate/index" import { EMBEDDING_NS_REGISTRY, validateId, validateNamespace, vectorKey } from "../translate/keys" -import { encodeVector, encodeVectorBase64 } from "../translate/vectors" - -const finiteNumber = z.number().refine((n) => Number.isFinite(n), { - message: "Vector values must be finite numbers (no NaN or Infinity)", -}) +import { encodeVector, encodeVectorBase64, finiteNumber } from "../translate/vectors" // Numeric IDs that aren't finite would silently become "NaN" / "Infinity" // strings after the .transform(String) below — reject them up front so users diff --git a/src/routes/upsert.ts b/src/routes/upsert.ts index 11dcae7..9158c5d 100644 --- a/src/routes/upsert.ts +++ b/src/routes/upsert.ts @@ -4,11 +4,7 @@ import { ValidationError } from "../errors" import { getClient } from "../redis" import { ensureIndex, loadDimension, setDetectedDimension } from "../translate/index" import { NS_REGISTRY, validateId, validateNamespace, vectorKey } from "../translate/keys" -import { encodeVector, encodeVectorBase64 } from "../translate/vectors" - -const finiteNumber = z.number().refine((n) => Number.isFinite(n), { - message: "Vector values must be finite numbers (no NaN or Infinity)", -}) +import { encodeVector, encodeVectorBase64, finiteNumber } from "../translate/vectors" // Numeric IDs that aren't finite would silently become "NaN" / "Infinity" // strings after .transform(String) — reject them up front so users get a @@ -21,22 +17,37 @@ const idSchema = z .transform(String) const MAX_VECTOR_DIM = 16384 +// Parity with /upsert-data's data cap (data.ts MAX_DATA_LENGTH), plus a byte +// budget on serialized metadata so Redis hash values stay bounded regardless +// of entry point. +const MAX_DATA_LENGTH = 1_000_000 +const MAX_METADATA_BYTES = 131072 + const UnsupportedField = z.never().optional() -const VectorSchema = z.object({ +export const VectorSchema = z.object({ id: idSchema, vector: z .array(finiteNumber) .min(1, "Vector dimension must be at least 1") .max(MAX_VECTOR_DIM, `Vector dimension must not exceed ${MAX_VECTOR_DIM}`), sparseVector: UnsupportedField, - metadata: z.record(z.string(), z.unknown()).optional(), - data: z.string().optional(), + metadata: z + .record(z.string(), z.unknown()) + .optional() + .refine( + (m) => m === undefined || Buffer.byteLength(JSON.stringify(m), "utf8") <= MAX_METADATA_BYTES, + `Serialized metadata must not exceed ${MAX_METADATA_BYTES} bytes`, + ), + data: z + .string() + .max(MAX_DATA_LENGTH, `Data must not exceed ${MAX_DATA_LENGTH} characters`) + .optional(), }) const MAX_BATCH_SIZE = 1000 -const UpsertBody = z.union([ +export const UpsertBody = z.union([ VectorSchema, z .array(VectorSchema) diff --git a/src/translate/index.ts b/src/translate/index.ts index 316c592..41b5715 100644 --- a/src/translate/index.ts +++ b/src/translate/index.ts @@ -111,33 +111,33 @@ async function createIndexInternal(ns: string, dimension: number, idx: string): // values. Without the metric check, restarting with a different // UPVECTOR_METRIC against existing data silently normalizes scores // with the wrong formula. - if (msg.includes("Index already exists")) { - try { - const info = await redis.send("FT.INFO", [idx]) - const actualDim = parseDimensionFromInfo(info) - if (actualDim !== undefined) { - dimensionMap.set(ns, actualDim) - knownIndexes.add(idx) - validateIndexCompatibility( - ns, - actualDim, - parseMetricFromInfo(info), - dimension, - // FT.INFO reports the Redis-native name (L2/IP/COSINE); - // compare against what we would have created. - toRedisDistanceMetric(config.metric), - ) - return - } - } catch (infoErr) { - // Bubble up validation mismatches; tolerate transient FT.INFO failures - if (infoErr instanceof ValidationError) { - throw infoErr - } - } - } else { - throw err + if (!msg.includes("Index already exists")) throw err + + let info: unknown + try { + info = await redis.send("FT.INFO", [idx]) + } catch (infoErr: unknown) { + const detail = infoErr instanceof Error ? infoErr.message : String(infoErr) + throw new Error(`Existing vector index "${idx}" could not be verified via FT.INFO: ${detail}`) } + const actualDim = parseDimensionFromInfo(info) + if (actualDim === undefined) { + throw new Error( + `Existing vector index "${idx}" could not be verified via FT.INFO: no readable DIM attribute`, + ) + } + dimensionMap.set(ns, actualDim) + knownIndexes.add(idx) + validateIndexCompatibility( + ns, + actualDim, + parseMetricFromInfo(info), + dimension, + // FT.INFO reports the Redis-native name (L2/IP/COSINE); + // compare against what we would have created. + toRedisDistanceMetric(config.metric), + ) + return } knownIndexes.add(idx) dimensionMap.set(ns, dimension) diff --git a/src/translate/vectors.ts b/src/translate/vectors.ts index 30fd883..831a341 100644 --- a/src/translate/vectors.ts +++ b/src/translate/vectors.ts @@ -1,8 +1,24 @@ +import { z } from "zod" + export function encodeVector(vec: number[]): Buffer { const f32 = new Float32Array(vec) return Buffer.from(f32.buffer, f32.byteOffset, f32.byteLength) } +// Float32 max. Larger magnitudes survive JSON parsing as float64 but overflow +// to Infinity inside encodeVector's Float32Array, then serialize back as JSON +// null on read. Reject them here so clients get a clear validation error (400) +// instead of silently corrupted storage. +export const FLOAT32_MAX = 3.4028235e38 + +export const finiteNumber = z + .number() + .refine((n) => Number.isFinite(n) && Math.abs(n) <= FLOAT32_MAX, { + message: + `Vector values must be finite numbers representable as Float32 ` + + `(no NaN or Infinity, absolute value must not exceed ${FLOAT32_MAX})`, + }) + // Bun.redis decodes all responses as UTF-8, which destroys bytes >= 0x80. // We store a base64 copy (_vec field) alongside the raw binary (vec field). // The raw binary is for RediSearch HNSW indexing; base64 is for reading back. diff --git a/tests/unit/embedding-chunking.test.ts b/tests/unit/embedding-chunking.test.ts new file mode 100644 index 0000000..a8a18bc --- /dev/null +++ b/tests/unit/embedding-chunking.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, test } from "bun:test" +import { + chunkEmbeddingInputs, + EMBED_MAX_CHARS_PER_REQUEST, + EMBED_MAX_ITEMS_PER_REQUEST, + OpenAICompatibleEmbeddingProvider, +} from "../../src/embedding" +import { EmbeddingProviderError } from "../../src/errors" + +/** + * A 200 response whose headers resolve immediately but whose body never + * produces bytes on its own. The body only errors when the request deadline + * aborts, mimicking an upstream that hangs between headers and body — the + * provider-side behavior the armed-through-consumption deadline guards against. + */ +function stalledBodyResponse(signal: AbortSignal | null | undefined): Response { + let streamController!: ReadableStreamDefaultController + const body = new ReadableStream({ + start(controller) { + streamController = controller + signal?.addEventListener("abort", () => { + streamController.error(new DOMException("aborted", "AbortError")) + }) + }, + }) + return new Response(body, { status: 200, headers: { "Content-Type": "application/json" } }) +} + +describe("chunkEmbeddingInputs", () => { + test("returns no chunks for empty input lists", () => { + expect(chunkEmbeddingInputs([])).toEqual([]) + }) + + test("keeps a small batch under both bounds in a single chunk", () => { + const inputs = ["first", "second", "third"] + expect(chunkEmbeddingInputs(inputs)).toEqual([inputs]) + }) + + test("splits at the item-count bound", () => { + const inputs = Array.from({ length: EMBED_MAX_ITEMS_PER_REQUEST + 4 }, (_, i) => `i${i}`) + expect(chunkEmbeddingInputs(inputs)).toEqual([ + inputs.slice(0, EMBED_MAX_ITEMS_PER_REQUEST), + inputs.slice(EMBED_MAX_ITEMS_PER_REQUEST), + ]) + }) + + test("splits at the character budget bound and fills chunks greedily", () => { + // Each slice fits three of these under the budget, but a fourth overflows. + const wide = "w".repeat(Math.floor(EMBED_MAX_CHARS_PER_REQUEST / 3)) + const inputs = Array.from({ length: 6 }, () => wide) + + const chunks = chunkEmbeddingInputs(inputs) + + expect(chunks.map((chunk) => chunk.length)).toEqual([3, 3]) + for (const chunk of chunks) { + const chars = chunk.reduce((sum, item) => sum + item.length, 0) + expect(chars).toBeLessThanOrEqual(EMBED_MAX_CHARS_PER_REQUEST) + } + expect(chunks.flat()).toEqual(inputs) + }) + + test("keeps an exact character-budget fit in one chunk", () => { + const head = "a".repeat(EMBED_MAX_CHARS_PER_REQUEST - 1) + const tail = "t" + + expect(chunkEmbeddingInputs([head, tail])).toEqual([[head, tail]]) + }) + + test("gives an oversized single input its own chunk", () => { + const oversized = "z".repeat(EMBED_MAX_CHARS_PER_REQUEST + 1) + + expect(chunkEmbeddingInputs(["head", oversized, "tail"])).toEqual([ + ["head"], + [oversized], + ["tail"], + ]) + }) +}) + +describe("OpenAICompatibleEmbeddingProvider batching", () => { + test("skips provider calls entirely for empty inputs", async () => { + let calls = 0 + const provider = new OpenAICompatibleEmbeddingProvider({ + apiKey: "test-key", + retries: 0, + fetchFn: async () => { + calls++ + throw new Error("provider must not be called") + }, + }) + + await expect(provider.embedMany([])).resolves.toEqual([]) + expect(calls).toBe(0) + }) + + test("sends a small batch as one request and restores response order", async () => { + const requests: RequestInit[] = [] + const provider = new OpenAICompatibleEmbeddingProvider({ + apiKey: "test-key", + dimension: 3, + retries: 0, + fetchFn: async (_url, init) => { + requests.push(init ?? {}) + return Response.json({ + data: [ + { index: 1, embedding: [0, 1, 0] }, + { index: 0, embedding: [1, 0, 0] }, + ], + }) + }, + }) + + const embeddings = await provider.embedMany(["first", "second"]) + + expect(embeddings).toEqual([ + [1, 0, 0], + [0, 1, 0], + ]) + expect(requests.length).toBe(1) + expect(JSON.parse(requests[0].body as string)).toMatchObject({ + input: ["first", "second"], + }) + }) + + test("preserves strict input-to-embedding order across chunked batches", async () => { + // Two of these fit under the char budget, so six inputs become three + // sequential chunks with their own request boundaries. + const inputs = Array.from({ length: 6 }, (_, i) => `${i}:${"x".repeat(100_000)}`) + const indexByItem = new Map(inputs.map((value, i) => [value, i] as const)) + const index = (item: string) => indexByItem.get(item) ?? -1 + + const seenChunks: number[][] = [] + const provider = new OpenAICompatibleEmbeddingProvider({ + apiKey: "test-key", + dimension: 2, + retries: 0, + fetchFn: async (_url, init) => { + const items = (JSON.parse(init?.body as string) as { input: string[] }).input + seenChunks.push(items.map(index)) + // Shuffle to exercise the index-based order restoration per chunk. + const data = items + .map((item) => ({ index: index(item), embedding: [index(item), 0] })) + .reverse() + return Response.json({ data }) + }, + }) + + const embeddings = await provider.embedMany(inputs) + + expect(seenChunks).toEqual([ + [0, 1], + [2, 3], + [4, 5], + ]) + // output[i] corresponds to input[i], never to a chunk-local position. + expect(embeddings.map((vector) => vector[0])).toEqual([0, 1, 2, 3, 4, 5]) + expect(embeddings.map((vector) => vector[1])).toEqual([0, 0, 0, 0, 0, 0]) + }) + + test("fails the whole call when one chunk exhausts its retries", async () => { + // Two items per chunk under the char budget → three chunks in sequence. + const inputs = Array.from({ length: 6 }, (_, i) => `${i}:${"x".repeat(100_000)}`) + const indexByItem = new Map(inputs.map((value, i) => [value, i] as const)) + + const seenChunks: number[][] = [] + let calls = 0 + const provider = new OpenAICompatibleEmbeddingProvider({ + apiKey: "test-key", + dimension: 2, + retries: 1, + retryBaseDelayMs: 0, + fetchFn: async (_url, init) => { + calls++ + const items = (JSON.parse(init?.body as string) as { input: string[] }).input + const indices = items.map((item) => indexByItem.get(item) ?? -1) + if (indices[0] !== 2) { + // First chunk succeeds; any later chunk stays unreachable here. + seenChunks.push(indices) + return Response.json({ + data: indices.map((index) => ({ index, embedding: [index, 0] })), + }) + } + return Response.json({ error: { message: "overloaded" } }, { status: 500 }) + }, + }) + + try { + await provider.embedMany(inputs) + throw new Error("expected provider to fail") + } catch (err) { + expect(err).toBeInstanceOf(EmbeddingProviderError) + expect((err as EmbeddingProviderError).status).toBe(502) + expect((err as Error).message).toContain("HTTP 500") + } + expect(seenChunks).toEqual([[0, 1]]) + // One success + one retry burn for the failing chunk; nothing further. + expect(calls).toBe(3) + }) +}) + +// Deliberate platform-clock use below: the subject arms its own real +// setTimeout deadlines inside the module under test, which is precisely the +// behavior being verified; fake timers cannot reach that internal timer. + +describe("OpenAICompatibleEmbeddingProvider deadlines", () => { + test("keeps the deadline armed across body consumption", async () => { + let calls = 0 + const provider = new OpenAICompatibleEmbeddingProvider({ + apiKey: "test-key", + timeoutMs: 20, + retries: 0, + fetchFn: async (_url, init) => { + calls++ + return stalledBodyResponse(init?.signal) + }, + }) + + // Same timeout error shape as a request-phase timeout (see the hung + // request test above): a prompt-header stall still lands on the 504 path. + await expect(provider.embedMany(["hello"])).rejects.toMatchObject({ + name: "EmbeddingProviderError", + status: 504, + }) + expect(calls).toBe(1) + }) + + test("routes stalled-body timeouts through the standard timeout retry path", async () => { + let calls = 0 + const provider = new OpenAICompatibleEmbeddingProvider({ + apiKey: "test-key", + timeoutMs: 15, + retries: 1, + retryBaseDelayMs: 0, + fetchFn: async (_url, init) => { + calls++ + return stalledBodyResponse(init?.signal) + }, + }) + + await expect(provider.embedMany(["hello"])).rejects.toMatchObject({ + name: "EmbeddingProviderError", + status: 504, + }) + // The 504 consumed the configured retry before surfacing. + expect(calls).toBe(2) + }) + + test("does not arm a timeout when timeoutMs is 0", async () => { + let deliverBody: ((payload: string) => void) | undefined + const provider = new OpenAICompatibleEmbeddingProvider({ + apiKey: "test-key", + dimension: 2, + timeoutMs: 0, + retries: 0, + fetchFn: async () => { + const encoder = new TextEncoder() + // Headers and metadata arrive now; only delivering the payload body + // lets json() settle. + const body = new ReadableStream({ + start(controller) { + deliverBody = (payload) => { + controller.enqueue(encoder.encode(payload)) + controller.close() + } + }, + }) + return new Response(body, { status: 200, headers: { "Content-Type": "application/json" } }) + }, + }) + + const pending = provider.embedMany(["hello"]) + + // While the body stalls, the call stays pending — no deadline rejects it. + // The probe window is safe only because timeoutMs === 0 arms nothing; + // no internal timer exists that this wait could race against. + const { promise: probeFired, resolve: markPending } = Promise.withResolvers<"probe">() + setTimeout(markPending, 10) + const state = await Promise.race([ + pending.then( + () => "settled" as const, + () => "settled" as const, + ), + probeFired.then(() => "pending" as const), + ]) + expect(state).toBe("pending") + + if (!deliverBody) throw new Error("expected fetch to have been called") + deliverBody(JSON.stringify({ data: [{ index: 0, embedding: [1, 0] }] })) + await expect(pending).resolves.toEqual([[1, 0]]) + }) +}) diff --git a/tests/unit/filter.test.ts b/tests/unit/filter.test.ts index d840296..a529b8c 100644 --- a/tests/unit/filter.test.ts +++ b/tests/unit/filter.test.ts @@ -3,9 +3,9 @@ import { ValidationError } from "../../src/errors" import { _clearFilterCache, compileFilter, + compileGlob, evaluate, evaluateFilter, - globToRegex, parse, resolveField, tokenize, @@ -303,48 +303,48 @@ describe("resolveField", () => { }) }) -// ─── globToRegex ───────────────────────────────────────────────────────────── +// ─── compileGlob ───────────────────────────────────────────────────────────── -describe("globToRegex", () => { +describe("compileGlob", () => { test("* matches any characters", () => { - expect(globToRegex("The *").test("The Lord")).toBe(true) - expect(globToRegex("The *").test("Something")).toBe(false) + expect(compileGlob("The *").test("The Lord")).toBe(true) + expect(compileGlob("The *").test("Something")).toBe(false) }) test("? matches single character", () => { - expect(globToRegex("h?t").test("hat")).toBe(true) - expect(globToRegex("h?t").test("hot")).toBe(true) - expect(globToRegex("h?t").test("heat")).toBe(false) + expect(compileGlob("h?t").test("hat")).toBe(true) + expect(compileGlob("h?t").test("hot")).toBe(true) + expect(compileGlob("h?t").test("heat")).toBe(false) }) test("character class", () => { - expect(globToRegex("[abc]at").test("cat")).toBe(true) - expect(globToRegex("[abc]at").test("dat")).toBe(false) + expect(compileGlob("[abc]at").test("cat")).toBe(true) + expect(compileGlob("[abc]at").test("dat")).toBe(false) }) test("negated character class", () => { - expect(globToRegex("[^abc]at").test("dat")).toBe(true) - expect(globToRegex("[^abc]at").test("cat")).toBe(false) + expect(compileGlob("[^abc]at").test("dat")).toBe(true) + expect(compileGlob("[^abc]at").test("cat")).toBe(false) }) test("character range", () => { - expect(globToRegex("[a-c]at").test("bat")).toBe(true) - expect(globToRegex("[a-c]at").test("fat")).toBe(false) + expect(compileGlob("[a-c]at").test("bat")).toBe(true) + expect(compileGlob("[a-c]at").test("fat")).toBe(false) }) test("escapes regex specials", () => { - expect(globToRegex("file.txt").test("file.txt")).toBe(true) - expect(globToRegex("file.txt").test("filextxt")).toBe(false) + expect(compileGlob("file.txt").test("file.txt")).toBe(true) + expect(compileGlob("file.txt").test("filextxt")).toBe(false) }) test("rejects patterns exceeding max length", () => { const longPattern = "*".repeat(600) - expect(() => globToRegex(longPattern)).toThrow("Glob pattern too long") + expect(() => compileGlob(longPattern)).toThrow("Glob pattern too long") }) test("caches compiled regex", () => { - const r1 = globToRegex("cache_test_*") - const r2 = globToRegex("cache_test_*") + const r1 = compileGlob("cache_test_*") + const r2 = compileGlob("cache_test_*") expect(r1).toBe(r2) // same RegExp instance }) @@ -353,19 +353,19 @@ describe("globToRegex", () => { // evicted just because it was inserted early. Re-accessing should // refresh the LRU position so it stays cached. const HOT = "hot_glob_pattern_*" - const hotInstance = globToRegex(HOT) + const hotInstance = compileGlob(HOT) // Touch the hot pattern several times so it's the most-recently-used. - for (let i = 0; i < 5; i++) globToRegex(HOT) + for (let i = 0; i < 5; i++) compileGlob(HOT) // Then push 256 distinct patterns through the cache, more than enough // to evict everything that isn't being touched. for (let i = 0; i < 256; i++) { - globToRegex(`evict_${i}_*`) + compileGlob(`evict_${i}_*`) // Periodically refresh the hot pattern's LRU position, mimicking a // real workload where the hot filter is queried often. - if (i % 10 === 0) globToRegex(HOT) + if (i % 10 === 0) compileGlob(HOT) } // The hot pattern should still be the same RegExp instance. - expect(globToRegex(HOT)).toBe(hotInstance) + expect(compileGlob(HOT)).toBe(hotInstance) }) }) @@ -575,22 +575,22 @@ describe("evaluateFilter", () => { // ─── Hardening: edge cases ────────────────────────────────────────────────── -describe("globToRegex hardening", () => { +describe("compileGlob hardening", () => { test("consecutive wildcards are collapsed", () => { - const re = globToRegex("a***b") + const re = compileGlob("a***b") expect(re.test("axyzb")).toBe(true) expect(re.test("ab")).toBe(true) expect(re.test("axb")).toBe(true) }) test("unclosed bracket treated as literal", () => { - const re = globToRegex("[abc") + const re = compileGlob("[abc") expect(re.test("[abc")).toBe(true) expect(re.test("a")).toBe(false) }) test("empty pattern matches empty string", () => { - const re = globToRegex("") + const re = compileGlob("") expect(re.test("")).toBe(true) expect(re.test("a")).toBe(false) }) @@ -748,8 +748,8 @@ describe("filter errors are typed ValidationError", () => { expect(() => parse(tokenize("x = 1 y = 2"))).toThrow(ValidationError) }) - test("globToRegex throws ValidationError on overly long pattern", () => { - expect(() => globToRegex("*".repeat(600))).toThrow(ValidationError) + test("compileGlob throws ValidationError on overly long pattern", () => { + expect(() => compileGlob("*".repeat(600))).toThrow(ValidationError) }) test("filter length cap throws ValidationError", () => { @@ -821,3 +821,195 @@ describe("compileFilter cache", () => { expect(() => compileFilter("x =")).toThrow() }) }) + +// ─── Bounded glob matcher (ReDoS hardening) ───────────────────────────────── + +// Reference implementation of the OLD regex-based glob compiler. The bounded +// matcher must produce an identical result-set for every benign pattern. +function legacyRegexGlob(pattern: string): RegExp { + let regex = "" + let i = 0 + while (i < pattern.length) { + const ch = pattern[i] + if (ch === "*") { + while (i + 1 < pattern.length && pattern[i + 1] === "*") i++ + regex += ".*" + } else if (ch === "?") { + regex += "." + } else if (ch === "[") { + i++ + let classContent = "" + while (i < pattern.length && pattern[i] !== "]") { + classContent += pattern[i] + i++ + } + if (i >= pattern.length) { + regex += "\\[" + regex += classContent.replace(/[.*+?^${}()|\\[\]]/g, "\\$&") + continue + } + regex += `[${classContent}]` + } else if (".+^${}()|\\".includes(ch)) { + regex += `\\${ch}` + } else { + regex += ch + } + i++ + } + return new RegExp(`^${regex}$`) +} + +describe("bounded glob matcher", () => { + const benignPatterns = [ + "The *", + "*Lord", + "*middle*", + "h?t", + "?at", + "[abc]at", + "[^abc]at", + "[a-c]x?*[0-9]", + "file.txt", + "a*b*c*d", + "***", + "", + "x", + "[!ab]*end", + "a[0-9]b[^0-9]c", + "*a*a*a*a*a*b", + "prefix_???_*", + ] + const subjects = [ + "The Lord", + "Something", + "Lord", + "middle-of-road", + "hat", + "hot", + "heat", + "cat", + "dat", + "bat", + "fat", + "file.txt", + "filextxt", + "abcd", + "abc", + "", + "x", + "!zend", + "a1bXc", + "a9b9c", + "aaaaaaab", + "prefix_abc_def", + "b", + "axxxbxxx cxxxd", + ] + + test("matches identical result-set as the old regex implementation on benign patterns", () => { + for (const pattern of benignPatterns) { + for (const subject of subjects) { + expect(compileGlob(pattern).test(subject)).toBe(legacyRegexGlob(pattern).test(subject)) + } + } + }) + + test("pathological input completes in bounded time (no catastrophic backtracking)", () => { + // Old regex form: ^(.*a){30 times}b$ — freezes the event loop on long + // non-matching subjects. The NFA scan must stay linear in n·k. + const pattern = `${"*a".repeat(30)}b` + const subject = "a".repeat(100000) + const start = performance.now() + const r1 = compileGlob(pattern).test(subject) + const elapsed = performance.now() - start + expect(r1).toBe(false) + expect(elapsed).toBeLessThan(50) + // Deterministic: same answer on a repeat run + expect(compileGlob(pattern).test(subject)).toBe(false) + }) +}) + +// ─── Eager glob validation at compile time ────────────────────────────────── + +describe("eager glob validation in compileFilter", () => { + test("[z-a] raises ValidationError at compileFilter, not lazily during evaluate", () => { + expect(() => compileFilter("name GLOB '[z-a]'")).toThrow(ValidationError) + }) + + test("invalid pattern is not cached — every compile attempt throws", () => { + _clearFilterCache() + expect(() => compileFilter("name GLOB '[z-a]'")).toThrow(ValidationError) + expect(() => compileFilter("name GLOB '[z-a]'")).toThrow(ValidationError) + }) + + test("overly long glob raises at compileFilter", () => { + _clearFilterCache() + expect(() => compileFilter(`name GLOB '${"*".repeat(600)}'`)).toThrow(ValidationError) + }) + + test("negated GLOB with invalid class also raises eagerly", () => { + _clearFilterCache() + expect(() => compileFilter("NOT name GLOB '[9-0]'")).toThrow(ValidationError) + }) + + test("valid glob filters still compile and evaluate", () => { + _clearFilterCache() + const ast = compileFilter("name GLOB 'a[b-d]*'") + expect(evaluate(ast, { name: "ace" })).toBe(true) + expect(evaluate(ast, { name: "axe" })).toBe(false) + }) +}) + +// ─── Tokenizer: bracket content validation ────────────────────────────────── + +describe("tokenizer rejects degenerate bracket groups", () => { + test("empty brackets are rejected", () => { + expect(() => tokenize("tags[]")).toThrow(ValidationError) + expect(() => tokenize("tags[]")).toThrow(/Empty array index/) + }) + + test("chained brackets are rejected", () => { + expect(() => tokenize("m[0][1]")).toThrow(ValidationError) + expect(() => tokenize("m[0][1]")).toThrow(/Chained array indexing/) + expect(() => tokenize("m[#-1][0]")).toThrow(ValidationError) + }) + + test("valid single bracket group still tokenizes", () => { + expect(tokenize("items[0].name")[0].value).toBe("items[0].name") + }) +}) + +// ─── Empty-string coercion guard in looseEqual ────────────────────────────── + +describe("empty-string coercion guard", () => { + test("empty string no longer matches numeric 0", () => { + expect(evaluateFilter("count = ''", { count: 0 })).toBe(false) + expect(evaluateFilter("count = 0", { count: "" })).toBe(false) + // Same-type comparison unaffected + expect(evaluateFilter("count = ''", { count: "" })).toBe(true) + expect(evaluateFilter("count = 0", { count: 0 })).toBe(true) + }) + + test("whitespace-only string does not coerce to 0 either", () => { + expect(evaluateFilter("count = 0", { count: " " })).toBe(false) + }) + + test("boolean false no longer matches numeric 0", () => { + expect(evaluateFilter("flag = false", { flag: 0 })).toBe(false) + expect(evaluateFilter("flag = 0", { flag: false })).toBe(false) + }) + + test("boolean true still matches its 1 / 'true' equivalents", () => { + expect(evaluateFilter("flag = true", { flag: true })).toBe(true) + expect(evaluateFilter("flag = true", { flag: 1 })).toBe(true) + expect(evaluateFilter("flag = true", { flag: "true" })).toBe(true) + expect(evaluateFilter("flag = false", { flag: false })).toBe(true) + }) + + test("non-empty numeric strings still coerce across types", () => { + expect(evaluateFilter("count = '5'", { count: 5 })).toBe(true) + expect(evaluateFilter("count = 5", { count: "5" })).toBe(true) + expect(evaluateFilter("count = '5.5'", { count: 5.5 })).toBe(true) + expect(evaluateFilter("count = '-3'", { count: -3 })).toBe(true) + }) +}) diff --git a/tests/unit/idle-timeout.test.ts b/tests/unit/idle-timeout.test.ts new file mode 100644 index 0000000..124ee93 --- /dev/null +++ b/tests/unit/idle-timeout.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test" +import { deriveIdleTimeoutSeconds } from "../../src/index" + +describe("deriveIdleTimeoutSeconds", () => { + test("derives the default request budget with headroom", () => { + expect(deriveIdleTimeoutSeconds(30000)).toBe(35) + }) + + test("returns Bun's maximum ceiling when the app-level timeout is disabled", () => { + expect(deriveIdleTimeoutSeconds(0)).toBe(255) + }) + + test("clamps to the 15s floor for tiny budgets", () => { + expect(deriveIdleTimeoutSeconds(1000)).toBe(15) + expect(deriveIdleTimeoutSeconds(9500)).toBe(15) + }) + + test("rounds up partial seconds above the floor", () => { + expect(deriveIdleTimeoutSeconds(10001)).toBe(16) + }) + + test("clamps to the 255s ceiling for huge budgets", () => { + expect(deriveIdleTimeoutSeconds(600000)).toBe(255) + }) +}) diff --git a/tests/unit/index-verify.test.ts b/tests/unit/index-verify.test.ts new file mode 100644 index 0000000..1b5a3f9 --- /dev/null +++ b/tests/unit/index-verify.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, mock, test } from "bun:test" +import { ValidationError } from "../../src/errors" + +type SendFn = (command: string, args: string[]) => Promise +let send: SendFn + +// createIndexInternal reaches Redis through ../redis's getClient(); stub it so +// the "Index already exists" verification path can be driven deterministically. +mock.module("../../src/redis", () => ({ + getClient: () => ({ + send, + }), +})) + +// Dynamic import: the module under test must bind the stubbed redis module. +const { ensureIndex, getDetectedDimension } = await import("../../src/translate/index") + +const resp3Info = (dim: number, metric = "COSINE") => ({ + num_docs: 0, + attributes: [{ dim, distance_metric: metric }], +}) + +function existingIndex(info: unknown): void { + send = async (command) => { + if (command === "FT.CREATE") throw new Error("Index already exists") + if (command === "FT.INFO") return info + throw new Error(`unexpected command ${command}`) + } +} + +describe("existing-index FT.INFO verification", () => { + test("throws instead of caching when FT.INFO itself fails", async () => { + send = async (command) => { + if (command === "FT.CREATE") throw new Error("Index already exists") + if (command === "FT.INFO") throw new Error("ERR transient proxy failure") + throw new Error(`unexpected command ${command}`) + } + const ns = "verify-ftinfo-failure" + await expect(ensureIndex(ns, 4)).rejects.toThrow(/could not be verified/) + expect(getDetectedDimension(ns)).toBeUndefined() + }) + + test("throws instead of caching when FT.INFO yields no readable dimension", async () => { + existingIndex({ num_docs: 3, attributes: [{ distance_metric: "COSINE" }] }) + const ns = "verify-no-dim" + await expect(ensureIndex(ns, 4)).rejects.toThrow(/could not be verified/) + expect(getDetectedDimension(ns)).toBeUndefined() + }) + + test("caches the detected dimension when verification succeeds", async () => { + existingIndex(resp3Info(7)) + const ns = "verify-success" + await expect(ensureIndex(ns, 7)).resolves.toBeUndefined() + expect(getDetectedDimension(ns)).toBe(7) + }) + + test("still raises ValidationError on a real dimension mismatch", async () => { + existingIndex(resp3Info(8)) + const ns = "verify-mismatch" + await expect(ensureIndex(ns, 4)).rejects.toThrow(ValidationError) + }) +}) diff --git a/tests/unit/route-validation.test.ts b/tests/unit/route-validation.test.ts new file mode 100644 index 0000000..2c2280f --- /dev/null +++ b/tests/unit/route-validation.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, mock, test } from "bun:test" +import type { RedisClient } from "bun" +import { Hono } from "hono" +import { ValidationError } from "../../src/errors" +import { errorHandler } from "../../src/middleware/error-handler" + +// Routes resolve the Redis client lazily via getClient(); register the stub +// BEFORE the route modules load, so their `../redis` bindings pick up the mock. +let scanImpl: (cursor: string) => Promise<[string, string[]]> = async () => { + throw new Error("unexpected SCAN call") +} +const delCalls: string[][] = [] +const fakeRedis = { + hgetall: async () => ({}), + del: async (...keys: string[]) => { + delCalls.push(keys) + return keys.length + }, + scan: (cursor: string) => scanImpl(cursor), +} + +mock.module("../../src/redis", () => ({ getClient: () => fakeRedis })) + +// Dynamic imports are required here: the route modules must be evaluated after +// mock.module registers the redis stub, which static hoisting prevents. +const [{ deleteRoutes }, { fetchRoutes }, { collectScanKeys, rangeRoutes }] = await Promise.all([ + import("../../src/routes/delete"), + import("../../src/routes/fetch"), + import("../../src/routes/range"), +]) + +// Bun's RedisClient can't be constructed in tests; the stub implements the +// subset of methods these handlers call. +const stubClient = fakeRedis as unknown as RedisClient + +function appWith(routes: Hono): Hono { + const app = new Hono() + app.onError(errorHandler) + app.route("/", routes) + return app +} + +async function postJson(app: Hono, path: string, body: unknown): Promise { + return app.request(path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) +} + +interface Envelope { + error?: string + status?: number + result?: { + deleted?: number + nextCursor?: string + vectors?: Array<{ id: string }> + } +} + +describe("fetch ID validation parity with write path", () => { + test("rejects an id containing a control character", async () => { + const res = await postJson(appWith(fetchRoutes), "/fetch/ns", { ids: ["a\nb"] }) + expect(res.status).toBe(400) + const json = (await res.json()) as Envelope + expect(json.error).toBe("Vector ID must not contain control characters") + }) + + test("rejects an empty id", async () => { + const res = await postJson(appWith(fetchRoutes), "/fetch/ns", { ids: [""] }) + expect(res.status).toBe(400) + const json = (await res.json()) as Envelope + expect(json.error).toBe("Vector ID must not be empty") + }) + + test("still accepts plain string ids", async () => { + const res = await postJson(appWith(fetchRoutes), "/fetch/ns", { ids: ["doc-1"] }) + expect(res.status).toBe(200) + }) +}) + +describe("delete ID validation parity with write path", () => { + test("rejects an id containing a control character without touching redis", async () => { + const before = delCalls.length + const res = await postJson(appWith(deleteRoutes), "/delete/ns", { ids: ["a\nb"] }) + expect(res.status).toBe(400) + const json = (await res.json()) as Envelope + expect(json.error).toBe("Vector ID must not contain control characters") + expect(delCalls.length).toBe(before) + }) + + test("rejects an over-length id", async () => { + const res = await postJson(appWith(deleteRoutes), "/delete/ns", { ids: ["x".repeat(1025)] }) + expect(res.status).toBe(400) + }) + + test("still deletes plain string ids", async () => { + const res = await postJson(appWith(deleteRoutes), "/delete/ns", { ids: ["doc-1"] }) + expect(res.status).toBe(200) + const json = (await res.json()) as Envelope + expect(json.result?.deleted).toBe(1) + }) +}) + +describe("collectScanKeys", () => { + test("returns deduplicated keys when the scan terminates", async () => { + scanImpl = async (cursor: string): Promise<[string, string[]]> => + cursor === "0" ? ["5", ["v:ns:b", "v:ns:a"]] : ["0", ["v:ns:a"]] + const keys = await collectScanKeys(stubClient, "v:ns:*", 10) + expect([...keys].sort()).toEqual(["v:ns:a", "v:ns:b"]) + }) + + test("throws instead of silently truncating when the iteration cap is hit", async () => { + scanImpl = async (): Promise<[string, string[]]> => ["9", ["v:ns:x"]] + let caught: unknown + try { + await collectScanKeys(stubClient, "v:ns:*", 3) + } catch (err) { + caught = err + } + expect(caught).toBeInstanceOf(ValidationError) + if (caught instanceof Error) { + expect(caught.message).toContain("exceeds the scanable key limit") + } + }) +}) + +describe("range endpoint behavior below the scan cap", () => { + test("returns a complete page with an empty nextCursor", async () => { + scanImpl = async (): Promise<[string, string[]]> => ["0", ["v:ns:b", "v:ns:a"]] + const res = await postJson(appWith(rangeRoutes), "/range/ns", { cursor: "", limit: 100 }) + expect(res.status).toBe(200) + const json = (await res.json()) as Envelope + expect(json.result?.nextCursor).toBe("") + expect(json.result?.vectors?.map((v) => v.id)).toEqual(["a", "b"]) + }) +}) diff --git a/tests/unit/vector-limits.test.ts b/tests/unit/vector-limits.test.ts new file mode 100644 index 0000000..721fe28 --- /dev/null +++ b/tests/unit/vector-limits.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test" +import { ZodError } from "zod" +import { DenseQuerySchema } from "../../src/routes/query" +import { UpsertBody, VectorSchema } from "../../src/routes/upsert" +import { encodeVectorBase64 } from "../../src/translate/vectors" + +const validVector = { + id: "vec-1", + vector: [0.1, -0.5, 42.123], + metadata: { source: "test" }, + data: "hello", +} + +describe("vector element validation (Float32 range)", () => { + test("accepts values within Float32 range", () => { + expect(VectorSchema.parse(validVector)).toBeDefined() + }) + + test("rejects 1e39 (overflows Float32 to Infinity)", () => { + // 1e39 passes a plain finite-float64 check but overflows to Infinity in + // the stored Float32 array, then reads back as JSON null. Must be 400. + expect(() => VectorSchema.parse({ ...validVector, vector: [1e39] })).toThrow(ZodError) + try { + VectorSchema.parse({ ...validVector, vector: [1e39] }) + } catch (err) { + expect((err as ZodError).issues[0].message).toContain("Float32") + } + }) + + test("rejects negative overflow", () => { + expect(() => VectorSchema.parse({ ...validVector, vector: [-1e39] })).toThrow(ZodError) + }) + + test("still rejects NaN and Infinity", () => { + expect(() => VectorSchema.parse({ ...validVector, vector: [Number.NaN] })).toThrow(ZodError) + expect(() => + VectorSchema.parse({ ...validVector, vector: [Number.POSITIVE_INFINITY] }), + ).toThrow(ZodError) + }) + + test("accepts boundary value 3.4028235e38", () => { + const parsed = VectorSchema.parse({ ...validVector, vector: [3.4028235e38, -3.4028235e38] }) + // The boundary must survive Float32 encoding without becoming Infinity + expect(encodeVectorBase64(parsed.vector)).not.toContain("7f8000") + }) + + test("rejects values just past the boundary", () => { + expect(() => VectorSchema.parse({ ...validVector, vector: [3.5e38] })).toThrow(ZodError) + }) + + test("query vectors enforce the same bound", () => { + expect(() => DenseQuerySchema.parse({ vector: [1e39] })).toThrow(ZodError) + expect(DenseQuerySchema.parse({ vector: [-3.4028235e38] })).toBeDefined() + }) +}) + +describe("upsert field caps", () => { + test("accepts data at exactly 1_000_000 characters", () => { + const body = { ...validVector, data: "x".repeat(1_000_000) } + expect(VectorSchema.parse(body)).toBeDefined() + }) + + test("rejects data over 1_000_000 characters", () => { + const body = { ...validVector, data: "x".repeat(1_000_001) } + expect(() => VectorSchema.parse(body)).toThrow(/Data must not exceed 1000000 characters/) + }) + + test("accepts metadata under the byte budget", () => { + const metadata: Record = {} + for (let i = 0; i < 500; i++) metadata[`key-${i}`] = "y".repeat(200) + expect(VectorSchema.parse({ ...validVector, metadata })).toBeDefined() + }) + + test("rejects metadata whose JSON serialization exceeds 131072 bytes", () => { + const metadata: Record = {} + for (let i = 0; i < 1500; i++) metadata[`key-${i}`] = "y".repeat(200) + expect(JSON.stringify(metadata).length).toBeGreaterThan(131072) + try { + VectorSchema.parse({ ...validVector, metadata }) + throw new Error("expected metadata over budget to be rejected") + } catch (err) { + expect(err).toBeInstanceOf(ZodError) + expect((err as ZodError).issues[0].message).toBe( + "Serialized metadata must not exceed 131072 bytes", + ) + } + }) + + test("omitted metadata and data remain valid", () => { + const { id, vector } = validVector + expect(VectorSchema.parse({ id, vector })).toEqual({ id, vector }) + }) +}) + +describe("UpsertBody", () => { + test("single-object payloads still parse", () => { + expect(UpsertBody.parse(validVector)).toBeDefined() + }) + + test("batch payloads still parse", () => { + expect(UpsertBody.parse([validVector, { id: "vec-2", vector: [1, 2] }])).toHaveLength(2) + }) + + test("batch containing an out-of-range element is rejected", () => { + expect(() => UpsertBody.parse([validVector, { id: "vec-2", vector: [1e39] }])).toThrow(ZodError) + }) +}) From 31244de859b950f2a4c3f19baf05396bbe5eb567 Mon Sep 17 00:00:00 2001 From: Benjamin Coriou Date: Wed, 26 Aug 2026 22:58:20 +0200 Subject: [PATCH 2/2] =?UTF-8?q?test:=20complete=20mock.module=20surfaces?= =?UTF-8?q?=20for=20src/redis=20=E2=80=94=20Bun=201.4=20hard-fails=20parti?= =?UTF-8?q?al=20ESM=20mocks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/index-verify.test.ts | 7 +++++++ tests/unit/route-validation.test.ts | 10 +++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/unit/index-verify.test.ts b/tests/unit/index-verify.test.ts index 1b5a3f9..7b16597 100644 --- a/tests/unit/index-verify.test.ts +++ b/tests/unit/index-verify.test.ts @@ -10,6 +10,13 @@ mock.module("../../src/redis", () => ({ getClient: () => ({ send, }), + // Mirror the real module's remaining exports: Bun 1.4 validates statically + // bound named exports against whatever this registry hands out, and other + // suites in the same process bind isRedisHealthy et al. + initRedis: async () => {}, + isRedisHealthy: async () => true, + reinitRedis: async () => {}, + closeRedis: async () => {}, })) // Dynamic import: the module under test must bind the stubbed redis module. diff --git a/tests/unit/route-validation.test.ts b/tests/unit/route-validation.test.ts index 2c2280f..cfe9188 100644 --- a/tests/unit/route-validation.test.ts +++ b/tests/unit/route-validation.test.ts @@ -19,7 +19,15 @@ const fakeRedis = { scan: (cursor: string) => scanImpl(cursor), } -mock.module("../../src/redis", () => ({ getClient: () => fakeRedis })) +mock.module("../../src/redis", () => ({ + getClient: () => fakeRedis, + // Full export surface: these modules share the test process with files + // whose code statically binds other named exports from ../redis. + initRedis: async () => {}, + isRedisHealthy: async () => true, + reinitRedis: async () => {}, + closeRedis: async () => {}, +})) // Dynamic imports are required here: the route modules must be evaluated after // mock.module registers the redis stub, which static hoisting prevents.