diff --git a/.env.example b/.env.example index 4aa4870..3fddc71 100644 --- a/.env.example +++ b/.env.example @@ -6,9 +6,13 @@ DATABASE_URL=postgres://memory:memory-dev-password@localhost:5434/memory DB_POOL_MAX=8 -# Embeddings (compose.yml → Ollama on :11434). The engine never embeds -# in-process; it always calls this endpoint. Swap four vars for a hosted -# provider (e.g. EMBED_BASE_URL=https://api.openai.com, EMBED_API_STYLE=openai). +# Embeddings (compose.yml → Ollama on :11434). Optional — leave both +# EMBED_BASE_URL and EMBED_MODEL unset to run lexical-only (no dense +# retrieval; `add` and lexical `search` still work, +# degraded: ["dense_unavailable", "lexical_only"]). When set, both are +# required together. The engine never embeds in-process; it always calls +# this endpoint. Swap four vars for a hosted provider (e.g. +# EMBED_BASE_URL=https://api.openai.com, EMBED_API_STYLE=openai). EMBED_BASE_URL=http://localhost:11434 EMBED_MODEL=nomic-embed-text EMBED_API_STYLE=ollama diff --git a/CHANGELOG.md b/CHANGELOG.md index f24e7ff..8d6c247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `loadMemoryConfig` / `EngineConfig.embed` no longer requires an embed + endpoint: a host with a pgvector Postgres and no embed endpoint now + constructs and serves `add` + lexical `search`. Dense retrieval is skipped + (not attempted-and-failed) and `search` reports + `degraded: ["dense_unavailable", "lexical_only"]` so the state stays + observable (CL-6287). **Migration note:** `dense_unavailable` is now also + emitted on every search for a deliberately-unconfigured engine, not only on + a transient dense-retrieval failure — a host with an existing alert rule + keyed on `dense_unavailable` alone should also check for `lexical_only` in + the same `degraded` array to distinguish "opted into lexical-only" from an + actual regression. +- `add`'s `degraded` is now a reason array (`["embed_unavailable"]` and/or + `["embed_unavailable", "lexical_only"]`), matching `search`'s shape — + previously a bare boolean, which made it impossible to write one + "is this response degraded" check across both verbs (CL-6287). **Breaking + if a host coded against the boolean:** `degraded: true` is now + `degraded: [...]`; check array presence/length instead of truthiness (both + are still falsy/omitted when the document captured cleanly). +- `Memory.capabilities.embeddingsConfigured` (and the underlying + `DocumentStore.capabilities`) let a host learn recall is lexical-only at + construction time, without issuing a search first (CL-6287). - Feed `nextCursor` advances past the examined raw page after grant-tag post-filter (a fully denied page no longer stalls the consumer forever). - Distiller default system prompt uses the configured `agentId` for diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 3f60ec5..01f860d 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -88,14 +88,43 @@ fallback)` parses a positive integer or throws. | `DATABASE_URL` | **yes** | — | the engine's own pgvector Postgres | | `DB_POOL_MAX` | no | `8` | postgres-js pool size | | `FTS_LANGUAGE` | no | `english` | text search config for the lexical channel; fixed into the generated column at migration time — changing it later requires rebuilding the column (recipe below), and `runMemoryMigrations` fails loudly if config and column disagree. Unqualified `pg_catalog` config names only — a schema-qualified config (`myschema.mycfg`) is rejected explicitly, both when configuring and when read back from an already-migrated column. | -| `EMBED_BASE_URL` | **yes** | — | embed endpoint root, no path suffix | -| `EMBED_MODEL` | **yes** | — | model id/name passed to the embed endpoint | +| `EMBED_BASE_URL` | no | — | embed endpoint root, no path suffix; absent (with `EMBED_MODEL` also absent) => lexical-only, see below | +| `EMBED_MODEL` | no | — | model id/name passed to the embed endpoint; must be set together with `EMBED_BASE_URL` (both or neither — one without the other throws) | | `EMBED_API_STYLE` | no | `"openai"` | `"openai" \| "tei" \| "ollama"` | | `EMBED_API_KEY` | no | `undefined` | forwarded as `Authorization: Bearer ` | | `RERANK_BASE_URL` | no | `undefined` | absent => search degrades to fusion-only | | `RERANK_MODEL` | no | `undefined` | defaults to `bge-reranker-v2-m3` in the client | | `RERANK_API_KEY` | no | `undefined` | forwarded as Bearer token to the rerank endpoint | +**Lexical-only mode (CL-6287).** `EngineConfig.embed` is optional — leave both +`EMBED_BASE_URL`/`EMBED_MODEL` unset and the engine still constructs and +serves `add` + lexical `search` against a pgvector Postgres with no +embed endpoint configured. Dense retrieval is skipped rather than +attempted (no doomed HTTP call on every query), `add` still captures +documents (chunks stored, no vectors), and both verbs report a `degraded` +reason array — never a bare boolean, so a host can write one "is this +response degraded" check across both: `search` reports +`degraded: ["dense_unavailable", "lexical_only"]`; `add` reports +`degraded: ["embed_unavailable", "lexical_only"]` (or `["embed_unavailable"]` +alone when the endpoint IS configured but a specific embed pass failed — a +client error, timeout, or rejected chunk). The embed-model registry +(`ensureEmbedModel`/`activateEmbedModel`) is never reached in this mode. + +**Discoverability.** A host does not have to run a search to learn recall is +limited: `memory.capabilities.embeddingsConfigured` (on the `Memory` handle +`createMemory` returns) is `false` for a lexical-only engine, `true` +otherwise — known at construction, no query needed. A custom `documentStore` +that doesn't report its own `capabilities` defaults to `true` (this SDK +cannot introspect a vendor store it doesn't own); see +`DocumentStoreCapabilities` (ports/types.ts) for how a vendor store opts in. + +The replay/backfill pipeline (`runTransform`, `promoteGeneration` in +`services/transform.ts`) still requires an embed endpoint — re-deriving a +corpus is inherently a re-embedding operation — and fails loudly if run +against an engine with none configured; re-embedding documents captured while +lexical-only, once an endpoint is later added, is an open follow-up (not +implemented). + The engine's `EngineConfig.rerank` carries no `apiStyle` field of its own; `search.ts`'s `toRerankClientConfig` hardcodes `apiStyle: "tei"` when building the client config, i.e. the engine currently @@ -688,9 +717,9 @@ bun run test # unit suite (no external `compose.yml` provisions the pgvector Postgres (`memory` db, host port `5434`), an Ollama embeddings server (`:11434`), and a TEI reranker (`:8085`). -The engine **never embeds internally** — `EMBED_BASE_URL` must point at a real -endpoint. A model endpoint is just a URL + capability options, trusted the same -as `DATABASE_URL`: +The engine **never embeds internally** — when `EMBED_BASE_URL` is set, it must +point at a real endpoint. A model endpoint is just a URL + capability options, +trusted the same as `DATABASE_URL`: - **Local default**: Ollama at `http://localhost:11434` (`EMBED_API_STYLE=ollama`, `EMBED_MODEL=nomic-embed-text`). @@ -701,6 +730,11 @@ as `DATABASE_URL`: `RERANK_BASE_URL` is optional — unset runs lexical+dense+MMR without the cross-encoder (`degraded: ["rerank_unavailable"]`, still ranked/citable hits). +`EMBED_BASE_URL`/`EMBED_MODEL` are optional too — unset both to run +lexical-only (`degraded: ["dense_unavailable", "lexical_only"]`, no dense +channel, `add` still captures documents unvectorized). See the lexical-only +note above. + ## Testing `bun test ./src` (`bun run test`), coverage via `bun run test:coverage`. Every diff --git a/src/config.ts b/src/config.ts index d85eb20..c443f86 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,7 +21,16 @@ export type EngineConfig = { // trusted the same as DATABASE_URL — including a self-hosted endpoint on // localhost or a private IP. Self-hosted or managed makes no difference: // there is no self-host flag anywhere in the engine. - embed: { + // + // Absent entirely => no embed endpoint configured. The engine still + // constructs and serves `add` + lexical `search` in that case: dense + // retrieval is skipped (never attempted, so it never runs a doomed HTTP + // call), capture stores chunks without vectors, and search reports + // `["dense_unavailable", "lexical_only"]` so the state is observable + // rather than silent (see hybridSearch in services/search.ts). A pgvector + // Postgres with no embed endpoint is a legitimate, fully-capable + // lexical-only deployment, not a misconfiguration. + embed?: { baseUrl: string; model: string; apiStyle: string; diff --git a/src/core/degrade-metrics.ts b/src/core/degrade-metrics.ts index 70e69c5..2807d67 100644 --- a/src/core/degrade-metrics.ts +++ b/src/core/degrade-metrics.ts @@ -25,6 +25,7 @@ const DEGRADE_FLAG_SET = { live_timeout: true, live_error: true, memory_unavailable: true, + lexical_only: true, } satisfies Record; @@ -32,6 +33,15 @@ const DEGRADE_FLAG_SET = { // means adding a flag in hybrid-search.ts without adding it here is a // compile error (missing property), not a silent gap that only a test // iterating this same constant could ever have caught. +// +// `lexical_only` sits at a permanent ~100% windowed rate for a host that has +// deliberately opted out of dense retrieval (no embed endpoint configured) — +// unlike every other flag here, that is the intended, steady state rather +// than a regression, so it escalates to log.error and stays there for as +// long as the host runs lexical-only. That is accurate (the health snapshot +// should show "running degraded"), not a bug; a host that finds the +// permanent log.error noisy can raise its own highWatermark via +// `configureDegradeMetrics`. export const ALL_DEGRADE_FLAGS: readonly DegradeFlag[] = Object.keys( DEGRADE_FLAG_SET, ) as DegradeFlag[]; diff --git a/src/core/embed-worker.ts b/src/core/embed-worker.ts index 135d4d2..57787f2 100644 --- a/src/core/embed-worker.ts +++ b/src/core/embed-worker.ts @@ -10,6 +10,19 @@ export interface EmbeddableChunk { text: string; } +// Capture's counterpart to search's `DegradeFlag` (hybrid-search.ts) — an +// array, never a bare boolean, so a host can write one "is this response +// degraded" check across `add` and `search`. Lives here (not services/ +// capture.ts) so `ports/types.ts` can reference it for +// `DocumentStoreAddResult` the same way it already references `DegradeFlag` +// for `DocumentStoreSearchResult`. `embed_unavailable` is the embed pass's +// counterpart to `dense_unavailable` — it ran and failed (client error, +// timeout, or a rejected/dims-mismatched chunk); `lexical_only` is paired +// with it specifically when there's no embed endpoint configured at all, +// mirroring search's `dense_unavailable`/`lexical_only` pairing for the same +// "configured off" state. +export type CaptureDegradedReason = "embed_unavailable" | "lexical_only"; + export interface EmbedChunksResult { embedded: number; rejected: Array<{ chunkId: string; reason: string }>; diff --git a/src/core/engine-client-config.ts b/src/core/engine-client-config.ts index 8c31564..78ca6c1 100644 --- a/src/core/engine-client-config.ts +++ b/src/core/engine-client-config.ts @@ -17,10 +17,14 @@ const VALID_EMBED_API_STYLES = new Set(["openai", "tei", "ollama"]); // the trust boundary between config and the client — an invalid value is an // operator misconfiguration and must fail loudly, not silently degrade. // Built from the engine's own operator-configured embed endpoint — a trusted -// URL, the same as DATABASE_URL. +// URL, the same as DATABASE_URL. Absent `embed` => no embed endpoint +// configured => `undefined`, the same degrade-soft precedent already used by +// `toRerankClientConfig` below — a caller must skip dense retrieval / the +// embed pass entirely rather than dispatch a client with no endpoint. export function toEmbedClientConfig( embed: EngineConfig["embed"], -): EmbedClientConfig { +): EmbedClientConfig | undefined { + if (!embed) return undefined; if (!VALID_EMBED_API_STYLES.has(embed.apiStyle)) { throw new Error( `Invalid EMBED_API_STYLE "${embed.apiStyle}" — must be one of: ${[...VALID_EMBED_API_STYLES].join(", ")}`, diff --git a/src/core/hybrid-search.ts b/src/core/hybrid-search.ts index 4aeda1d..2e996c6 100644 --- a/src/core/hybrid-search.ts +++ b/src/core/hybrid-search.ts @@ -20,7 +20,16 @@ export type DegradeFlag = | "rerank_query_too_long" | "live_timeout" | "live_error" - | "memory_unavailable"; + | "memory_unavailable" + // The engine has no embed endpoint configured at all (EngineConfig.embed + // is absent) — a deliberate, structural lexical-only deployment, distinct + // from `dense_unavailable`'s per-call "dense contributed nothing this + // time" (which also covers a configured endpoint that's merely down, or a + // tenant with no active embed model yet). Always paired with + // `dense_unavailable` on the search response (see hybridSearch, + // services/search.ts) so an aggregate degrade-rate consumer still sees + // "dense didn't contribute" even if it only understands that one flag. + | "lexical_only"; export interface RankedCandidate { /** Stable identifier the candidate is keyed by across channels (a chunk id). */ diff --git a/src/index.ts b/src/index.ts index 1ddc32f..59230df 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,7 @@ export type { HybridSearchResult, MemoryAddParams, MemoryAddResult, + MemoryCapabilities, MemorySearchParams, MemoryIdentity, Memory, @@ -77,6 +78,7 @@ export type { DocumentStore, DocumentStoreAddParams, DocumentStoreAddResult, + DocumentStoreCapabilities, DocumentStoreSearchItem, DocumentStoreSearchParams, DocumentStoreSearchResult, diff --git a/src/memory.test.ts b/src/memory.test.ts index 562e0ad..980d5e1 100644 --- a/src/memory.test.ts +++ b/src/memory.test.ts @@ -173,6 +173,57 @@ describe("createMemory — construction validation", () => { }); }); +// CL-6287 review: a consumer (settings page, health check) must be able to +// learn recall is lexical-only WITHOUT issuing a search first. +describe("createMemory — capabilities.embeddingsConfigured (CL-6287)", () => { + it("reports true when EngineConfig.embed is configured", async () => { + const plane = createMemory({ + config: baseConfig({ + baseUrl: undefined, + model: undefined, + apiKey: undefined, + maxDocChars: undefined, + timeoutMs: undefined, + }), + }); + expect(plane.capabilities.embeddingsConfigured).toBe(true); + await plane.close(); + }); + + it("reports false when EngineConfig.embed is absent (lexical-only)", async () => { + const config: MemoryConfig = { + memory: { + databaseUrl: "postgres://localhost:5432/nonexistent-test-db", + dbPoolMax: 1, + ftsLanguage: "english", + rerank: { + baseUrl: undefined, + model: undefined, + apiKey: undefined, + maxDocChars: undefined, + timeoutMs: undefined, + }, + }, + }; + const plane = createMemory({ config }); + expect(plane.capabilities.embeddingsConfigured).toBe(false); + await plane.close(); + }); + + it("defaults to true for a custom DocumentStore that doesn't report its own capabilities", async () => { + const plane = createMemory({ + documentStore: { + add: async () => ({ documentId: "d1", versionId: "v1" }), + search: async () => ({ items: [] }), + list: async () => [], + close: async () => {}, + }, + }); + expect(plane.capabilities.embeddingsConfigured).toBe(true); + await plane.close(); + }); +}); + describe("createMemory.find — grant-tag post-filter wiring", () => { const hybridSearch = mock((): Promise => Promise.resolve({ diff --git a/src/memory.ts b/src/memory.ts index 6a9d3f9..f965d81 100644 --- a/src/memory.ts +++ b/src/memory.ts @@ -14,7 +14,7 @@ import { createFtsVerification, parseFtsLanguage } from "./core/fts-language.ts" import { createRawSqlClient } from "./core/embed-sql.ts"; import type { SearchHit } from "./core/schemas/search.ts"; import { validateRerankConfig } from "./core/rerank-client.ts"; -import { captureDocument } from "./services/capture.ts"; +import { captureDocument, type CaptureDegradedReason } from "./services/capture.ts"; import { hybridSearch, MemorySearchInputError, @@ -70,6 +70,7 @@ import type { MemoryConfig } from "./mount-config.ts"; import type { GrantConfig } from "./routes/deps.ts"; import type { DocumentStore, + DocumentStoreCapabilities, DocumentStoreSearchParams, SourceProvider, } from "./ports/types.ts"; @@ -89,9 +90,13 @@ export type { SearchHit } from "./core/schemas/search.ts"; export type { DocumentStore, DocumentStoreAddParams, + DocumentStoreCapabilities, LiveSearchItem, SourceProvider, } from "./ports/types.ts"; +// Alias so hosts read `MemoryCapabilities` (the name on the `Memory` handle +// they actually hold) rather than reaching for the port-level type name. +export type MemoryCapabilities = DocumentStoreCapabilities; export { SEARCH_LIMIT_MIN, SEARCH_LIMIT_MAX, @@ -200,6 +205,13 @@ export type MemoryAddResult = { * store or soft failure). Omitted when no peer share was requested. */ grantsMaterialized?: boolean; + /** + * Mirrors `search`'s `degraded` (a reason array, never a bare boolean) so + * a host can write one "is this response degraded" check across both + * verbs. Omitted when the document captured cleanly. See + * `CaptureDegradedReason` (services/capture.ts). + */ + degraded?: CaptureDegradedReason[]; }; export type SearchAttribution = { @@ -283,6 +295,14 @@ export type Memory = { search(params: MemorySearchParams): Promise; add(params: MemoryAddParams): Promise; list(params: MemoryListParams): Promise; + /** + * Static capability facts, known at construction — check + * `embeddingsConfigured` to learn recall is lexical-only WITHOUT issuing + * a search first (CL-6287). Always present; a custom DocumentStore that + * doesn't report its own capabilities defaults to + * `embeddingsConfigured: true` (see DocumentStoreCapabilities). + */ + readonly capabilities: MemoryCapabilities; /** * Cursor pull of new live versions (engine store only). Grant-checked like * search. See docs/FEED.md. @@ -788,7 +808,17 @@ function createPlaneFromStore( }); } + // A custom store that doesn't report its own capabilities is assumed + // embeddings-capable — the pre-CL-6287 default, since this SDK cannot + // introspect a vendor store it doesn't own. The engine store always + // reports one (see createEngineDocumentStore). + const capabilities: MemoryCapabilities = store.capabilities ?? { + embeddingsConfigured: true, + }; + const plane: Memory = { + capabilities, + async search(params) { return searchMerged(params); }, @@ -1294,6 +1324,9 @@ function createEngineDocumentStore(config: MemoryConfig): { return { documentId: captureResult.documentId, versionId: captureResult.versionId, + ...(captureResult.status === "captured" && captureResult.degraded + ? { degraded: captureResult.degraded } + : {}), }; }, @@ -1415,6 +1448,8 @@ function createEngineDocumentStore(config: MemoryConfig): { async close() { await sql.end({ timeout: 5 }); }, + + capabilities: { embeddingsConfigured: Boolean(engineConfig.embed) }, }, deps, }; diff --git a/src/mount-config.test.ts b/src/mount-config.test.ts index 28971ca..2665b3e 100644 --- a/src/mount-config.test.ts +++ b/src/mount-config.test.ts @@ -36,14 +36,14 @@ afterEach(() => { describe("loadMemoryConfig — EMBED_TIMEOUT_MS / RERANK_TIMEOUT_MS", () => { it("leaves embed.timeoutMs and rerank.timeoutMs undefined when unset, so the clients' own defaults apply", () => { const config = loadMemoryConfig(); - expect(config.memory.embed.timeoutMs).toBeUndefined(); + expect(config.memory.embed?.timeoutMs).toBeUndefined(); expect(config.memory.rerank.timeoutMs).toBeUndefined(); }); it("flows EMBED_TIMEOUT_MS through to embed.timeoutMs", () => { process.env.EMBED_TIMEOUT_MS = "20000"; const config = loadMemoryConfig(); - expect(config.memory.embed.timeoutMs).toBe(20_000); + expect(config.memory.embed?.timeoutMs).toBe(20_000); }); it("flows RERANK_TIMEOUT_MS through to rerank.timeoutMs", () => { @@ -59,3 +59,44 @@ describe("loadMemoryConfig — EMBED_TIMEOUT_MS / RERANK_TIMEOUT_MS", () => { ); }); }); + +describe("loadMemoryConfig — optional embed (CL-6287)", () => { + it("constructs with DATABASE_URL alone: embed is absent, not required", () => { + delete process.env.EMBED_BASE_URL; + delete process.env.EMBED_MODEL; + const config = loadMemoryConfig(); + expect(config.memory.embed).toBeUndefined(); + }); + + it("builds embed when both EMBED_BASE_URL and EMBED_MODEL are set", () => { + const config = loadMemoryConfig(); + expect(config.memory.embed).toEqual({ + baseUrl: "http://embed.example", + model: "test-model", + apiStyle: "openai", + apiKey: undefined, + timeoutMs: undefined, + }); + }); + + it("rejects EMBED_BASE_URL set without EMBED_MODEL", () => { + delete process.env.EMBED_MODEL; + expect(() => loadMemoryConfig()).toThrow( + "EMBED_BASE_URL and EMBED_MODEL must both be set or both be unset", + ); + }); + + it("rejects EMBED_MODEL set without EMBED_BASE_URL", () => { + delete process.env.EMBED_BASE_URL; + expect(() => loadMemoryConfig()).toThrow( + "EMBED_BASE_URL and EMBED_MODEL must both be set or both be unset", + ); + }); + + it("treats a whitespace-only EMBED_BASE_URL as unset, not as a blank baseUrl", () => { + process.env.EMBED_BASE_URL = " "; + expect(() => loadMemoryConfig()).toThrow( + "EMBED_BASE_URL and EMBED_MODEL must both be set or both be unset", + ); + }); +}); diff --git a/src/mount-config.ts b/src/mount-config.ts index df608f4..b107406 100644 --- a/src/mount-config.ts +++ b/src/mount-config.ts @@ -21,7 +21,7 @@ function requireEnv(name: string): string { function optionalEnv(name: string): string | undefined { const v = process.env[name]; - return v && v.length > 0 ? v : undefined; + return v && v.trim() !== "" ? v : undefined; } function intEnv(name: string, fallback: number): number { @@ -47,6 +47,31 @@ function optionalIntEnv(name: string): number | undefined { return n; } +// EMBED_BASE_URL and EMBED_MODEL are a pair: both set builds the embed +// block, neither set means the host is opting into lexical-only (no +// embed endpoint — dense retrieval skipped, capture stores chunks +// without vectors). Exactly one set is a real operator mistake — a typo'd +// var name, a copy-paste that dropped one line — and must fail loudly +// rather than silently landing in lexical-only mode. +function loadEmbedConfig(): EngineConfig["embed"] { + const baseUrl = optionalEnv("EMBED_BASE_URL"); + const model = optionalEnv("EMBED_MODEL"); + if (baseUrl === undefined && model === undefined) return undefined; + if (baseUrl === undefined || model === undefined) { + throw new Error( + "EMBED_BASE_URL and EMBED_MODEL must both be set or both be unset — " + + "set both to enable dense retrieval, or unset both to run lexical-only", + ); + } + return { + baseUrl, + model, + apiStyle: optionalEnv("EMBED_API_STYLE") ?? "openai", + apiKey: optionalEnv("EMBED_API_KEY"), + timeoutMs: optionalIntEnv("EMBED_TIMEOUT_MS"), + }; +} + /** * Build a config from environment variables — a convenience for env-driven * deploys. Hosts may also construct `MemoryConfig` programmatically (pass @@ -56,25 +81,24 @@ function optionalIntEnv(name: string): number | undefined { * as the host is fine; tables live under the `memory` schema, not public. */ export function loadMemoryConfig(): MemoryConfig { - return { - memory: { - databaseUrl: requireEnv("DATABASE_URL"), - dbPoolMax: intEnv("DB_POOL_MAX", 8), - ftsLanguage: parseFtsLanguage(optionalEnv("FTS_LANGUAGE")), - embed: { - baseUrl: requireEnv("EMBED_BASE_URL"), - model: requireEnv("EMBED_MODEL"), - apiStyle: optionalEnv("EMBED_API_STYLE") ?? "openai", - apiKey: optionalEnv("EMBED_API_KEY"), - timeoutMs: optionalIntEnv("EMBED_TIMEOUT_MS"), - }, - rerank: { - baseUrl: optionalEnv("RERANK_BASE_URL"), - model: optionalEnv("RERANK_MODEL"), - apiKey: optionalEnv("RERANK_API_KEY"), - maxDocChars: optionalIntEnv("RERANK_MAX_DOC_CHARS"), - timeoutMs: optionalIntEnv("RERANK_TIMEOUT_MS"), - }, - }, + const databaseUrl = requireEnv("DATABASE_URL"); + const dbPoolMax = intEnv("DB_POOL_MAX", 8); + const ftsLanguage = parseFtsLanguage(optionalEnv("FTS_LANGUAGE")); + const rerank = { + baseUrl: optionalEnv("RERANK_BASE_URL"), + model: optionalEnv("RERANK_MODEL"), + apiKey: optionalEnv("RERANK_API_KEY"), + maxDocChars: optionalIntEnv("RERANK_MAX_DOC_CHARS"), + timeoutMs: optionalIntEnv("RERANK_TIMEOUT_MS"), }; + + const embed = loadEmbedConfig(); + // Two explicit literals rather than spreading `embed` in conditionally: + // `EngineConfig.embed` is optional, not `X | undefined`, so under + // exactOptionalPropertyTypes the key must be omitted entirely when there's + // no embed config, not present-with-value-undefined. + if (embed) { + return { memory: { databaseUrl, dbPoolMax, ftsLanguage, embed, rerank } }; + } + return { memory: { databaseUrl, dbPoolMax, ftsLanguage, rerank } }; } diff --git a/src/ports/types.ts b/src/ports/types.ts index 65e0f72..de51a04 100644 --- a/src/ports/types.ts +++ b/src/ports/types.ts @@ -20,6 +20,7 @@ import type { SearchHitCitation, } from "../core/schemas/search.ts"; import type { DegradeFlag } from "../core/hybrid-search.ts"; +import type { CaptureDegradedReason } from "../core/embed-worker.ts"; /** Input the plane hands the store after content/file/share resolution. */ export type DocumentStoreAddParams = { @@ -159,6 +160,35 @@ export type DocumentStoreAddResult = { documentId: string; /** Active version written (or existing active on noop). */ versionId: string; + /** + * Reason array (never a bare boolean), mirroring + * `DocumentStoreSearchResult.degraded`'s shape. Omitted when the document + * captured cleanly. Vendor stores that never degrade may omit this + * entirely. + */ + degraded?: CaptureDegradedReason[]; +}; + +/** + * Static capability facts about a DocumentStore, surfaced on the `Memory` + * handle (`memory.capabilities`) so a consumer — a settings page, a health + * check — can learn them WITHOUT issuing a search/add first (CL-6287 + * review: running lexical-only for months while believing recall is normal + * is a silent downgrade; this is what makes it an honest, discoverable + * tier instead). + */ +export type DocumentStoreCapabilities = { + /** + * Whether dense (embedding-based) retrieval is available. `false` means + * every search is lexical-only and every `add` stores chunks unvectorized + * — see the `dense_unavailable`/`lexical_only` and + * `embed_unavailable`/`lexical_only` degrade pairs on search/add results + * respectively. The engine store reports this from its own + * `EngineConfig.embed`; a vendor store that omits `capabilities` + * altogether is assumed embeddings-capable (the pre-CL-6287 default, + * since this SDK cannot otherwise introspect a store it doesn't own). + */ + embeddingsConfigured: boolean; }; export type DocumentStore = { @@ -182,6 +212,8 @@ export type DocumentStore = { documentId: string, tags: readonly string[], ): Promise; + /** See DocumentStoreCapabilities. Optional — see its doc comment for the default. */ + capabilities?: DocumentStoreCapabilities; }; /** diff --git a/src/routes/add.ts b/src/routes/add.ts index a00c6ed..ab50244 100644 --- a/src/routes/add.ts +++ b/src/routes/add.ts @@ -19,6 +19,9 @@ import { const AddResponse = type({ documentId: "string", versionId: "string", + // A reason array, never a bare boolean — mirrors search's `degraded` so a + // host can write one "is this response degraded" check across both verbs. + "degraded?": "string[]", }); export function mountAddRoute(app: Hono, deps: RouteDeps): void { @@ -93,7 +96,11 @@ export function mountAddRoute(app: Hono, deps: RouteDeps): void { ? { validUntil: body.valid_until } : {}), }); - return c.json({ documentId: result.documentId, versionId: result.versionId }); + return c.json({ + documentId: result.documentId, + versionId: result.versionId, + ...(result.degraded ? { degraded: result.degraded } : {}), + }); } catch (err) { if (err instanceof MemoryError) { return c.json( diff --git a/src/routes/routes.test.ts b/src/routes/routes.test.ts index 028bd7b..b2185ac 100644 --- a/src/routes/routes.test.ts +++ b/src/routes/routes.test.ts @@ -40,6 +40,7 @@ function stubPlane(opts?: { }> = []; const catalog = opts?.timelineCatalog ?? []; const plane: Memory = { + capabilities: { embeddingsConfigured: true }, search: async (p) => { searched.push({ kinds: p.kinds, entityIds: p.entityIds, limit: p.limit }); return { items: [], evidence: "none" }; @@ -132,6 +133,7 @@ function stubMachinePlane(opts?: { const fed: { tenantId: string; principalId: string }[] = []; const catalog = opts?.timelineCatalog ?? []; const plane: Memory = { + capabilities: { embeddingsConfigured: true }, search: async (p) => { searched.push({ tenantId: p.tenantId, diff --git a/src/services/capture.test.ts b/src/services/capture.test.ts index 6db8267..ece44a5 100644 --- a/src/services/capture.test.ts +++ b/src/services/capture.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { toEmbedClientConfig } from "./capture.ts"; +import { embedInsertedChunksWithConfig, toEmbedClientConfig } from "./capture.ts"; import type { EngineConfig } from "../config.ts"; +import type { RawSql } from "../db/client.ts"; +import type { EmbeddableChunk } from "../core/embed-worker.ts"; // Regression for the capture path silently timing out at embed-client.ts's // default (10000ms) even when EMBED_TIMEOUT_MS was set: capture.ts used to @@ -20,7 +22,7 @@ describe("capture path embed client config", () => { const embedClientConfig = toEmbedClientConfig(embed); - expect(embedClientConfig.timeoutMs).toBe(5000); + expect(embedClientConfig?.timeoutMs).toBe(5000); }); it("leaves timeoutMs undefined (so embed-client.ts's own default applies) when EngineConfig doesn't set one", () => { @@ -34,6 +36,48 @@ describe("capture path embed client config", () => { const embedClientConfig = toEmbedClientConfig(embed); - expect(embedClientConfig.timeoutMs).toBeUndefined(); + expect(embedClientConfig?.timeoutMs).toBeUndefined(); + }); + + it("returns undefined when EngineConfig.embed is absent (no embed endpoint configured)", () => { + expect(toEmbedClientConfig(undefined)).toBeUndefined(); + }); +}); + +// CL-6287 review: `add`'s `degraded` must be a reason array (like search's +// `DegradeFlag[]`), never a bare boolean, so a host can write one +// "is this response degraded" check across both verbs. +describe("embedInsertedChunksWithConfig — degraded reason array (CL-6287)", () => { + function untouchableRawSql(): RawSql { + return { + unsafe: () => { + throw new Error("rawSql.unsafe must not be called when embed is unconfigured"); + }, + begin: () => { + throw new Error("rawSql.begin must not be called when embed is unconfigured"); + }, + } as unknown as RawSql; + } + + const oneChunk: EmbeddableChunk[] = [{ id: "chunk_1", text: "hello world" }]; + + it("returns an empty array (not a boolean) when there are no chunks to embed", async () => { + const result = await embedInsertedChunksWithConfig( + untouchableRawSql(), + "tenant-1", + [], + undefined, + ); + expect(result.degraded).toEqual([]); + }); + + it("reports [embed_unavailable, lexical_only] when no embed endpoint is configured, without touching the embed-model registry", async () => { + const result = await embedInsertedChunksWithConfig( + untouchableRawSql(), + "tenant-1", + oneChunk, + undefined, + ); + expect(result.degraded).toEqual(["embed_unavailable", "lexical_only"]); }); }); diff --git a/src/services/capture.ts b/src/services/capture.ts index 6fcecc7..305d1ab 100644 --- a/src/services/capture.ts +++ b/src/services/capture.ts @@ -28,7 +28,11 @@ import type { MemoryEdgeHint } from "../core/schemas/entity-edge.ts"; import { createRawSqlClient } from "../core/embed-sql.ts"; import { activateEmbedModel, ensureEmbedModel } from "../core/embed-model-registry.ts"; import type { EmbedClientConfig } from "../core/embed-client.ts"; -import { embedChunks, type EmbeddableChunk } from "../core/embed-worker.ts"; +import { + embedChunks, + type CaptureDegradedReason, + type EmbeddableChunk, +} from "../core/embed-worker.ts"; import { toEmbedClientConfig } from "../core/engine-client-config.ts"; type Tx = Parameters[0]>[0]; @@ -47,13 +51,18 @@ export type CaptureInput = { document: AdaptedDocument; }; +// Re-exported so callers (memory.ts, ports/types.ts) get the one shared +// vocabulary — see the doc comment on CaptureDegradedReason in +// core/embed-worker.ts for why it lives there. +export type { CaptureDegradedReason }; + export type CaptureResult = | { status: "captured"; documentId: string; versionId: string; chunks: number; - degraded?: boolean; + degraded?: CaptureDegradedReason[]; } | { status: "noop"; documentId: string; versionId: string; chunks: 0 }; @@ -527,14 +536,33 @@ export { toEmbedClientConfig }; // `promoteActive` (default true): live capture activates the model so dense // search targets it. Replay must pass false so ensureEmbedModel only creates // the table without flipping the tenant's active embed model (CL-5872). -async function embedInsertedChunksWithConfig( +// +// `embedClientConfig` is `undefined` when the engine has no embed endpoint +// configured at all (a lexical-only deployment) — chunks are already durable +// from the capture transaction, so this returns +// `degraded: ["embed_unavailable", "lexical_only"]` (unvectorized, the same +// pairing search's `degraded` uses for the same "configured off" state) +// WITHOUT touching the embed-model registry (`ensureEmbedModel`/ +// `activateEmbedModel`): there is no endpoint to probe dims against, and +// probing one that doesn't exist is exactly the doomed-call this feature +// exists to skip. Every other failure path here (client error, rejected +// chunks, an unexpected throw) reports `["embed_unavailable"]` alone — the +// endpoint IS configured, this specific pass just didn't land. +// +// Exported (like toEmbedClientConfig above) so tests can assert this +// specific decision directly, without standing up a fake transactional Db +// for the full captureDocument/deriveFromRawCapture path. +export async function embedInsertedChunksWithConfig( sql: RawSql, tenantId: string, chunks: EmbeddableChunk[], - embedClientConfig: EmbedClientConfig, + embedClientConfig: EmbedClientConfig | undefined, opts: { promoteActive?: boolean } = {}, -): Promise<{ degraded: boolean }> { - if (chunks.length === 0) return { degraded: false }; +): Promise<{ degraded: CaptureDegradedReason[] }> { + if (chunks.length === 0) return { degraded: [] }; + if (!embedClientConfig) { + return { degraded: ["embed_unavailable", "lexical_only"] }; + } try { const client = createRawSqlClient(sql); @@ -557,23 +585,23 @@ async function embedInsertedChunksWithConfig( `capture: embedding client failed; chunks remain pending: ${result.clientError}`, { tenantId, chunkCount: chunks.length, error: result.clientError }, ); - return { degraded: true }; + return { degraded: ["embed_unavailable"] }; } if (result.rejected.length > 0) { log.warn( `capture: ${result.rejected.length} chunk(s) rejected during embedding`, { tenantId, rejected: result.rejected }, ); - return { degraded: true }; + return { degraded: ["embed_unavailable"] }; } - return { degraded: false }; + return { degraded: [] }; } catch (err) { const errMessage = formatCaughtError(err); log.warn( `capture: embedding pass failed; chunks remain pending: ${errMessage}`, { tenantId, chunkCount: chunks.length, error: errMessage }, ); - return { degraded: true }; + return { degraded: ["embed_unavailable"] }; } } @@ -609,7 +637,7 @@ export async function captureDocument( documentId: txResult.documentId, versionId: txResult.versionId, chunks: txResult.insertedChunks.length, - ...(degraded ? { degraded: true } : {}), + ...(degraded.length > 0 ? { degraded } : {}), }; } @@ -658,6 +686,6 @@ export async function deriveFromRawCapture( documentId: txResult.documentId, versionId: txResult.versionId, chunks: txResult.insertedChunks.length, - ...(degraded ? { degraded: true } : {}), + ...(degraded.length > 0 ? { degraded } : {}), }; } diff --git a/src/services/search.test.ts b/src/services/search.test.ts index 0fa6d87..17273d5 100644 --- a/src/services/search.test.ts +++ b/src/services/search.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, mock } from "bun:test"; import { authorityWeightedScore, dedupeCandidatesPerDocument, @@ -8,7 +8,25 @@ import { snippet, toHit, type CandidateRow, + type hybridSearch as HybridSearchFn, } from "./search.ts"; +import { memoryChunk, memoryEdge } from "../db/schema.ts"; +import type { Db, RawSql } from "../db/client.ts"; +import type { EngineConfig } from "../config.ts"; + +// memory.test.ts uses `mock.module("./services/search.ts", ...)` around its +// own describe blocks; Bun's module registry is process-global, so a static +// `import { hybridSearch } from "./search.ts"` here can end up bound to that +// mock's fixture data when the whole suite runs (memory.test.ts's mock +// leaked across files empirically — reproduced with just these two files). +// A cache-busted dynamic import, the same trick memory.test.ts itself uses +// for `./memory.ts` (`?wiring-blocked=${Date.now()}`) to dodge its own +// mocking, sidesteps this: a fresh module specifier is never the one any +// mock.module call replaced. +async function loadHybridSearch(): Promise { + const mod = await import(`./search.ts?cl-6287-real=${Date.now()}`); + return mod.hybridSearch; +} function candidate(overrides: Partial = {}): CandidateRow { return { @@ -589,3 +607,141 @@ describe("toHit — wire attribution (CL-5870)", () => { expect(distilled.derived_from).toEqual(["kv_raw"]); }); }); + +// CL-6287: hybridSearch must construct and serve lexical results when the +// engine has no embed endpoint configured (EngineConfig.embed absent) — +// dense retrieval SKIPPED rather than attempted-and-failed. A minimal +// drizzle-shaped `Db` fake stands in for the lexical query + the +// attach*/entity-id follow-up queries, all of which return camelCase rows +// shaped exactly like a real query's aliased columns (CandidateRow already +// is that shape) so no real drizzle execution is needed. `rawSql` is a stub +// that throws if touched at all — proof that the embed-model registry +// (which only ever reaches Postgres through `createRawSqlClient(rawSql)`, +// see embed-sql.ts) is never consulted on this path. +describe("hybridSearch — embed unconfigured (CL-6287)", () => { + // A chainable stand-in for drizzle's query builder. Every step returns a + // thenable so `await db.select(...).from(t)...limit(n)` and + // `await db.select(...).from(t).where(...)` (the attach*/entity-id + // queries, which never call .limit) both resolve correctly regardless of + // how many chain steps run after `.from()`. Resolution is keyed on the + // table passed to `.from()` — the only piece of the call these + // functions' return value actually depends on for this test. + function fakeDb(lexicalRows: unknown[]): Db { + function chain(table: unknown) { + const rows = (): Promise => { + if (table === memoryChunk) return Promise.resolve(lexicalRows); + if (table === memoryEdge) return Promise.resolve([]); + return Promise.resolve([]); + }; + const builder = { + from: (t: unknown) => chain(t), + innerJoin: () => builder, + where: () => builder, + orderBy: () => builder, + limit: () => builder, + then: (onFulfilled: (v: unknown[]) => unknown, onRejected?: (e: unknown) => unknown) => + rows().then(onFulfilled, onRejected), + catch: (onRejected: (e: unknown) => unknown) => rows().catch(onRejected), + }; + return builder; + } + return { select: () => chain(undefined) } as unknown as Db; + } + + // No `.unsafe`/`.begin` call is valid on this path — dense retrieval must + // never be attempted, so nothing should ever reach for the raw sql handle + // (fetchDenseCandidates, the embed-model registry probe/activation, and + // fetchChunkVectors' MMR lookup all go through it). + function untouchableRawSql(): RawSql { + return { + unsafe: () => { + throw new Error("rawSql.unsafe must not be called when embed is unconfigured"); + }, + begin: () => { + throw new Error("rawSql.begin must not be called when embed is unconfigured"); + }, + } as unknown as RawSql; + } + + function unconfiguredEmbedConfig(): EngineConfig { + return { + databaseUrl: "postgres://fake", + dbPoolMax: 1, + ftsLanguage: "english", + rerank: { + baseUrl: undefined, + model: undefined, + apiKey: undefined, + maxDocChars: undefined, + timeoutMs: undefined, + }, + }; + } + + it("returns lexical results without ever calling the embed endpoint", async () => { + const lexicalRow = candidate({ + chunkId: "chunk_lexical", + documentId: "doc_lexical", + title: "Q3 roadmap notes", + snippetText: "west coast expansion roadmap", + rank: 0.8, + }); + const fetchImpl = mock(() => + Promise.reject(new Error("fetch must not be called when embed is unconfigured")), + ); + + const hybridSearch = await loadHybridSearch(); + const result = await hybridSearch( + { + db: fakeDb([lexicalRow]), + sql: untouchableRawSql(), + config: unconfiguredEmbedConfig(), + fetchImpl: fetchImpl as unknown as typeof fetch, + now: new Date("2026-01-01T00:00:00Z"), + }, + { query: "roadmap", tenantId: "tenant-1", principalId: null }, + ); + + expect(result.hits).toHaveLength(1); + expect(result.hits[0]?.chunk_id).toBe("chunk_lexical"); + expect(result.hits[0]?.channels_matched).toEqual(["lexical"]); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("reports dense_unavailable and lexical_only together, distinguishing configured-off from a runtime failure", async () => { + const hybridSearch = await loadHybridSearch(); + const result = await hybridSearch( + { + db: fakeDb([candidate()]), + sql: untouchableRawSql(), + config: unconfiguredEmbedConfig(), + fetchImpl: mock(() => Promise.reject(new Error("unreachable"))) as unknown as typeof fetch, + }, + { query: "hello", tenantId: "tenant-1", principalId: null }, + ); + + expect(result.degraded).toContain("dense_unavailable"); + expect(result.degraded).toContain("lexical_only"); + }); + + it("never dispatches an embed HTTP call or touches the embed-model registry", async () => { + // untouchableRawSql/fetchImpl both throw if reached at all — reaching + // the end of hybridSearch without throwing is itself the assertion that + // neither the dense channel nor the embed-model registry ran; the + // explicit mock-call check below is belt-and-suspenders. + const fetchImpl = mock(() => Promise.reject(new Error("unreachable"))); + + const hybridSearch = await loadHybridSearch(); + await hybridSearch( + { + db: fakeDb([candidate()]), + sql: untouchableRawSql(), + config: unconfiguredEmbedConfig(), + fetchImpl: fetchImpl as unknown as typeof fetch, + }, + { query: "hello", tenantId: "tenant-1", principalId: null }, + ); + + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/search.ts b/src/services/search.ts index b7f93b3..270b348 100644 --- a/src/services/search.ts +++ b/src/services/search.ts @@ -791,7 +791,13 @@ async function fetchChunkVectors( rawSql: RawSql, tenantId: string, chunkIds: readonly string[], - embedClientConfig: EmbedClientConfig, + // Optional: absent when the engine has no embed endpoint configured. The + // live-generation branch below never reads it (it resolves the tenant's + // active table directly); a non-live (replay) generation needs it to + // compute a model key, and has no active table to fall back on when it's + // absent — returns no vectors rather than throwing (mmrRerank already + // treats a missing vector as "can't diversity-rank, keep by score"). + embedClientConfig: EmbedClientConfig | undefined, generation: string = LIVE_GENERATION, ): Promise> { if (chunkIds.length === 0) return new Map(); @@ -801,6 +807,7 @@ async function fetchChunkVectors( if (generation === LIVE_GENERATION) { activeTable = await resolveActiveEmbedTable(embedSqlClient, tenantId); } else { + if (!embedClientConfig) return new Map(); const modelKey = computeModelKey( embedClientConfig.baseUrl, embedClientConfig.modelId, @@ -938,7 +945,12 @@ export interface HybridSearchResult { * degrade reason: the query alone left too little of the per-pair character * budget for the document, so the request was skipped rather than sent * guaranteed to exceed the model's token limit (see `RerankQueryTooLongError` - * in rerank-client.ts). + * in rerank-client.ts). When the engine has no embed endpoint configured at + * all (`EngineConfig.embed` absent), the dense channel is skipped entirely — + * never dispatched, so it never pays for a doomed HTTP call — and `degraded` + * reports `["dense_unavailable", "lexical_only"]` every call, distinguishing + * this deliberate, structural state from a configured endpoint that merely + * failed at runtime. * * `kinds`/`entityIds` narrow BOTH channels' candidate queries (see * `HybridSearchArgs`) before fusion runs, so every hit in the fused result @@ -1006,39 +1018,48 @@ export async function hybridSearch( includeDeprecated, }); - const embedClientConfig = - resolvedTuning?.embed ?? toEmbedClientConfig(config.embed); + const embedClientConfig = resolvedTuning?.embed ?? toEmbedClientConfig(config.embed); const rerankConfig = resolvedTuning?.rerank ?? toRerankClientConfig(config.rerank); let denseRows: CandidateRow[] = []; let degraded: DegradeFlag[] | undefined; - try { - const dense = await fetchDenseCandidates({ - sql: rawSql, - embedClientConfig, - fetchImpl, - tenantId, - principalId, - query, - overfetchLimit, - kinds, - entityIds, - generation, - includeDeprecated, - }); - if (dense === null) { + if (!embedClientConfig) { + // No embed endpoint configured (EngineConfig.embed absent) — a + // deliberate lexical-only deployment, not a runtime failure. Skip the + // dense channel entirely rather than dispatch a doomed HTTP call on + // every query; report both flags so an aggregate consumer that only + // understands `dense_unavailable` still sees "dense didn't contribute" + // (see the DegradeFlag doc comment, hybrid-search.ts). + degraded = ["dense_unavailable", "lexical_only"]; + } else { + try { + const dense = await fetchDenseCandidates({ + sql: rawSql, + embedClientConfig, + fetchImpl, + tenantId, + principalId, + query, + overfetchLimit, + kinds, + entityIds, + generation, + includeDeprecated, + }); + if (dense === null) { + degraded = ["dense_unavailable"]; + } else { + denseRows = dense; + } + } catch (err) { + const errMessage = formatCaughtError(err); + log.warn( + `search: dense retrieval failed; falling back to lexical only: ${errMessage}`, + { tenantId, error: errMessage }, + ); degraded = ["dense_unavailable"]; - } else { - denseRows = dense; } - } catch (err) { - const errMessage = formatCaughtError(err); - log.warn( - `search: dense retrieval failed; falling back to lexical only: ${errMessage}`, - { tenantId, error: errMessage }, - ); - degraded = ["dense_unavailable"]; } const rowsByChunk = new Map(); diff --git a/src/services/transform.test.ts b/src/services/transform.test.ts index d0453ea..ad040fc 100644 --- a/src/services/transform.test.ts +++ b/src/services/transform.test.ts @@ -3,8 +3,14 @@ import { type } from "arktype"; import { buildRerankClientConfig, isPromotableRunStatus, + promoteGeneration, + runTransform, + TransformPromoteError, } from "./transform.ts"; import { TransformConfigParamsSchema } from "../core/schemas/transform.ts"; +import { rawCapture, transformConfig, transformRun } from "../db/schema.ts"; +import type { Db, RawSql } from "../db/client.ts"; +import type { EngineConfig } from "../config.ts"; describe("buildRerankClientConfig", () => { it("returns undefined when no baseUrl is configured (falls through to engine defaults)", () => { @@ -87,3 +93,173 @@ describe("TransformConfigParamsSchema", () => { expect(parsed instanceof type.errors).toBe(true); }); }); + +// CL-6287 review: runTransform/promoteGeneration grew embed-absent guards in +// this PR with no coverage — a host running a replay or rebuild-derived job +// while lexical-only would execute code nobody had exercised. A minimal +// drizzle-shaped `Db` fake (same technique as services/search.test.ts's +// hybridSearch coverage) stands in for the handful of queries each function +// issues before reaching the guard; `.set`/`.values` calls are captured so +// assertions can inspect exactly what the code under test computed, rather +// than reading back through the same static fake. +describe("runTransform / promoteGeneration — embed-absent guards (CL-6287)", () => { + const TENANT = "tenant-1"; + const CONFIG_ID = "tcfg_1"; + + function configRow() { + return { + id: CONFIG_ID, + tenantId: TENANT, + name: "test-config", + version: 1, + params: { chunk: { strategy: "token.recursive" } }, + createdAt: new Date("2026-01-01T00:00:00Z"), + }; + } + + function runRow(overrides: Record = {}) { + return { + id: "trun_1", + tenantId: TENANT, + configId: CONFIG_ID, + scope: {}, + generation: "gen-1", + status: "completed", + rawCount: 1, + versionCount: 1, + error: null, + createdAt: new Date("2026-01-01T00:00:00Z"), + completedAt: new Date("2026-01-01T00:01:00Z"), + archivedLiveGeneration: null, + archivedLiveModelKey: null, + promotedAt: null, + ...overrides, + }; + } + + function unconfiguredEngineConfig(): EngineConfig { + return { + databaseUrl: "postgres://fake", + dbPoolMax: 1, + ftsLanguage: "english", + rerank: { + baseUrl: undefined, + model: undefined, + apiKey: undefined, + maxDocChars: undefined, + timeoutMs: undefined, + }, + }; + } + + // No `.unsafe`/`.begin` call is valid on this path — the guard must fire + // before either function ever reaches for the raw sql handle + // (createRawSqlClient/resolveActiveEmbedTable, or the embed-model registry + // a real per-row derivation would touch). + function untouchableRawSql(): RawSql { + return { + unsafe: () => { + throw new Error("rawSql.unsafe must not be called when embed is unconfigured"); + }, + begin: () => { + throw new Error("rawSql.begin must not be called when embed is unconfigured"); + }, + } as unknown as RawSql; + } + + // A chainable stand-in for drizzle's query/insert/update builders. Every + // step returns a thenable; resolution is keyed on the table identity + // passed to `.from()` (select) or directly (insert/update). `.values`/ + // `.set` record what they were called with instead of doing anything, so + // tests can assert on the exact payload the code under test computed. + function fakeDb(rows: { + transformConfig?: unknown[]; + transformRun?: unknown[]; + rawCapture?: unknown[]; + }): { db: Db; updates: unknown[]; inserts: unknown[] } { + const updates: unknown[] = []; + const inserts: unknown[] = []; + + function chain(table: unknown) { + const resolve = (): Promise => { + if (table === transformConfig) return Promise.resolve(rows.transformConfig ?? []); + if (table === transformRun) return Promise.resolve(rows.transformRun ?? []); + if (table === rawCapture) return Promise.resolve(rows.rawCapture ?? []); + return Promise.resolve([]); + }; + const builder = { + from: (t: unknown) => chain(t), + where: () => builder, + limit: () => builder, + values: (v: unknown) => { + inserts.push(v); + return builder; + }, + set: (v: unknown) => { + updates.push(v); + return builder; + }, + then: ( + onFulfilled: (v: unknown[]) => unknown, + onRejected?: (e: unknown) => unknown, + ) => resolve().then(onFulfilled, onRejected), + catch: (onRejected: (e: unknown) => unknown) => resolve().catch(onRejected), + }; + return builder; + } + + const db = { + select: () => chain(undefined), + insert: (table: unknown) => chain(table), + update: (table: unknown) => chain(table), + } as unknown as Db; + + return { db, updates, inserts }; + } + + it("runTransform fails the run loudly with a clear message, never reaching per-row derivation, when no embed endpoint is configured", async () => { + const { db, updates } = fakeDb({ + transformConfig: [configRow()], + transformRun: [runRow({ status: "failed" })], + // One row: if the guard were removed or moved after the per-row loop, + // this would be picked up and attempt real derivation (which needs + // `db.transaction`, absent from this fake) instead of failing with + // the guard's own clear message. + rawCapture: [{ id: "rc_1", adapter: "http", rawText: "{}" }], + }); + + const result = await runTransform( + { db, sql: untouchableRawSql(), config: unconfiguredEngineConfig() }, + { configId: CONFIG_ID }, + ); + + expect(result.status).toBe("failed"); + expect(updates).toHaveLength(1); + const update = updates[0] as { status: string; rawCount: number; versionCount: number; error: string | null }; + expect(update.status).toBe("failed"); + expect(update.rawCount).toBe(0); + expect(update.versionCount).toBe(0); + expect(update.error).toContain("embed endpoint"); + }); + + it("promoteGeneration rejects with a clear TransformPromoteError, never reaching the embed-model registry, when no embed endpoint is configured", async () => { + const { db } = fakeDb({ + transformConfig: [configRow()], + transformRun: [runRow()], + }); + + await expect( + promoteGeneration( + { db, sql: untouchableRawSql(), config: unconfiguredEngineConfig() }, + { tenantId: TENANT, generation: "gen-1" }, + ), + ).rejects.toThrow(TransformPromoteError); + + await expect( + promoteGeneration( + { db, sql: untouchableRawSql(), config: unconfiguredEngineConfig() }, + { tenantId: TENANT, generation: "gen-1" }, + ), + ).rejects.toThrow(/embed endpoint/); + }); +}); diff --git a/src/services/transform.ts b/src/services/transform.ts index af7b3c7..e53e62a 100644 --- a/src/services/transform.ts +++ b/src/services/transform.ts @@ -200,9 +200,12 @@ function buildChunker(params: TransformConfigParams["chunk"]): Chunker { // endpoint is just a URL + capability options, trusted the same as the // engine's own embed endpoint and DATABASE_URL — self-hosted or managed makes // no difference. +// A transform run always needs a real embed endpoint to re-derive against — +// its callers (runTransform below) must reject an unconfigured engine before +// reaching here, so `engineEmbed` is never `undefined` at this point. function buildEmbedClientConfig( params: TransformConfigParams["embed"], - engineEmbed: EngineConfig["embed"], + engineEmbed: NonNullable, ): EmbedClientConfig { const apiKey = params?.apiKey ?? engineEmbed.apiKey; const candidate = { @@ -418,10 +421,15 @@ export async function runTransform( const rawRows = await selectRawCaptureRows(deps.db, configRow.tenantId, scope); totalRows = rawRows.length; const chunker = buildChunker(configRow.params.chunk); - const embed = buildEmbedClientConfig( - configRow.params.embed, - deps.config.embed, - ); + // A replay re-derives (and re-embeds) a corpus; it makes no sense + // against an engine with no embed endpoint at all — caught below and + // reported as a normal failed run, same as any other per-row error. + if (!deps.config.embed) { + throw new Error( + "transform run requires an embed endpoint (EMBED_BASE_URL/EMBED_MODEL) — none is configured on this engine", + ); + } + const embed = buildEmbedClientConfig(configRow.params.embed, deps.config.embed); for (const row of rawRows) { try { @@ -551,6 +559,13 @@ export async function promoteGeneration( `transform_config tenant mismatch for generation ${input.generation}`, ); } + // A completed run only exists because runTransform already required an + // embed endpoint to produce it — this can't be reached with one absent. + if (!deps.config.embed) { + throw new TransformPromoteError( + "cannot promote generation: no embed endpoint is configured on this engine", + ); + } const embed = buildEmbedClientConfig(configRow.params.embed, deps.config.embed); const archiveGen = `archive_${run.id}_${Date.now()}`; const readClient = createRawSqlClient(deps.sql);