diff --git a/src/services/supabase-client.ts b/src/services/supabase-client.ts index ca1855a..d56b8cd 100644 --- a/src/services/supabase-client.ts +++ b/src/services/supabase-client.ts @@ -14,7 +14,7 @@ import type { SupabaseSearchOptions, } from "../types/index.js"; import { getCache } from "./cache.js"; -import { getTableName } from "./tier.js"; +import { getTableName, getTablePrefix } from "./tier.js"; // --- PostgREST Input Sanitization --- @@ -159,61 +159,6 @@ export async function upsertRecord( return directUpsert(table, data); } -/** - * Semantic search across tables - * - * Generates an embedding for the query, then calls the match_ - * RPC function directly via PostgREST. - */ -export async function semanticSearch( - options: SupabaseSearchOptions -): Promise { - if (!isConfigured()) { - throw new Error("Supabase not configured"); - } - - const { query, match_count = 10 } = options; - - // Generate embedding for the query - const { embed } = await import("./embedding.js"); - const embedding = await embed(query); - - if (!embedding) { - console.error("[semantic-search] No embedding provider configured — cannot run semantic search"); - return []; - } - - // GIT-93: same defect as scarSearch below — this built `${prefix}_semantic_search`, - // which exists under no prefix. The deployed function is match_
, and it - // takes similarity_threshold (unlike the _weighted variant), which is what the - // body already sends. - const rpcName = `match_${getTableName("learnings")}`; - const url = `${SUPABASE_URL}/rest/v1/rpc/${rpcName}`; - - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - apikey: SUPABASE_KEY, - Authorization: `Bearer ${SUPABASE_KEY}`, - }, - body: JSON.stringify({ - query_embedding: `[${embedding.join(",")}]`, - match_count, - similarity_threshold: 0.0, - }), - signal: AbortSignal.timeout(15_000), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Supabase RPC error: ${response.status} - ${text.slice(0, 200)}`); - } - - const rows = (await response.json()) as T[]; - return rows || []; -} - // ============================================================================ // DIRECT SUPABASE QUERIES // ============================================================================ @@ -677,49 +622,107 @@ export async function scarSearch( return []; } - // GIT-93: the RPC is named after the TABLE it searches, not after the prefix - // with a verb appended. This built `${prefix}_scar_search` — "orchestra_scar_search" - // under GITMEM_TABLE_PREFIX=orchestra_, and "gitmem_scar_search" by default. - // Neither exists: PostgREST exposes match_orchestra_learnings_weighted, and a - // survey of the deployed functions found no *_scar_search under any prefix. So - // this fallback returned PGRST202 on every call, on every deployment, since it - // was written — invisible because it is only reached while the local vector - // index is cold. - // - // _weighted is the right one of the pair: this function is documented as scar - // search WITH SEVERITY WEIGHTING, and it is the variant that returns - // decay_multiplier, which recall consumes. Note it takes match_threshold, not - // similarity_threshold — the unweighted variant takes the latter. - const rpcName = `match_${getTableName("learnings")}_weighted`; - const url = `${SUPABASE_URL}/rest/v1/rpc/${rpcName}`; + const rows = await callScarSearchRpc(embedding, matchCount); + return (await withLearningColumns(rows)) as T[]; +} - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - apikey: SUPABASE_KEY, - Authorization: `Bearer ${SUPABASE_KEY}`, - }, - body: JSON.stringify({ - query_embedding: `[${embedding.join(",")}]`, - match_count: matchCount, - match_threshold: 0.0, - // project_filter is deliberately not sent. The primary path this falls back - // from is the unified CROSS-PROJECT vector cache, so filtering here would - // make the fallback return a different, narrower result set than the path - // it stands in for — a silent behaviour change on exactly the cold-start - // calls that are hardest to notice. - }), - signal: AbortSignal.timeout(15_000), - }); +/** + * GIT-114: which scar-search function this store has. + * + * setup.sql (every version a customer can have) defines gitmem_scar_search, + * taking similarity_threshold. It does NOT define match_
_weighted — + * GIT-93 renamed the call to that after surveying nTEG's store, which has it, + * so every customer's cold-index recall got PGRST202. + * + * Order of preference: + * - default prefix: gitmem_scar_search (setup.sql), then + * match_gitmem_learnings_weighted for a store that predates or lacks it; + * - any other prefix: only match_
_weighted — gitmem_scar_search reads + * gitmem_learnings by name and would search the wrong table. + * The first function that exists is remembered for the process. + */ +interface ScarSearchRpc { + name: string; + thresholdParam: "similarity_threshold" | "match_threshold"; +} + +let resolvedScarSearchRpc: ScarSearchRpc | null = null; + +/** For tests. */ +export function resetScarSearchRpc(): void { + resolvedScarSearchRpc = null; +} + +function scarSearchCandidates(): ScarSearchRpc[] { + const weighted: ScarSearchRpc = { name: `match_${getTableName("learnings")}_weighted`, thresholdParam: "match_threshold" }; + return getTablePrefix() === "gitmem_" + ? [{ name: "gitmem_scar_search", thresholdParam: "similarity_threshold" }, weighted] + : [weighted]; +} + +async function callScarSearchRpc(embedding: number[], matchCount: number): Promise { + const candidates = resolvedScarSearchRpc ? [resolvedScarSearchRpc] : scarSearchCandidates(); + let lastError = ""; + for (const rpc of candidates) { + const response = await fetch(`${SUPABASE_URL}/rest/v1/rpc/${rpc.name}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + apikey: SUPABASE_KEY, + Authorization: `Bearer ${SUPABASE_KEY}`, + }, + body: JSON.stringify({ + query_embedding: `[${embedding.join(",")}]`, + match_count: matchCount, + [rpc.thresholdParam]: 0.0, + // project_filter is deliberately not sent. The primary path this falls + // back from is the unified CROSS-PROJECT vector cache, so filtering here + // would make the fallback return a narrower set than the path it stands + // in for — a silent behaviour change on the cold-start calls. + }), + signal: AbortSignal.timeout(15_000), + }); + + if (response.ok) { + resolvedScarSearchRpc = rpc; + const rows = (await response.json()) as T[]; + return rows || []; + } - if (!response.ok) { const text = await response.text(); - throw new Error(`Supabase RPC error: ${response.status} - ${text.slice(0, 200)}`); + lastError = `Supabase RPC error: ${response.status} - ${text.slice(0, 200)}`; + // PGRST202: no such function (with these parameters) — try the next name. + // Anything else is a real failure of a function that exists. + if (!(response.status === 404 && text.includes("PGRST202"))) break; + console.error(`[scar-search] ${rpc.name} not on this store (PGRST202)`); } + throw new Error(lastError); +} - const rows = (await response.json()) as T[]; - return rows || []; +/** + * gitmem_scar_search returns id, title, description, severity, scar_type, + * counter_arguments, decay_multiplier and similarity — not the fields recall + * renders from (learning_type, applies_when, why_this_matters, …). Fetch them + * for the returned ids in one request. If that request fails the rows are + * still returned: recall renders what it has rather than losing the result. + */ +const RECALL_EXTRA_COLUMNS = + "id,learning_type,problem_context,solution_approach,applies_when,domain,keywords,source_linear_issue,why_this_matters,action_protocol,self_check_criteria,project"; + +async function withLearningColumns(rows: T[]): Promise { + if (rows.length === 0 || rows.every((r) => r.learning_type !== undefined)) return rows; + try { + const extra = await directQuery & { id: string }>(getTableName("learnings"), { + select: RECALL_EXTRA_COLUMNS, + filters: { id: `in.(${rows.map((r) => r.id).join(",")})` }, + }); + const byId = new Map(extra.map((e) => [e.id, e])); + return rows.map((r) => ({ ...(byId.get(r.id) ?? {}), ...r })); + } catch (error) { + console.error("[scar-search] Could not fetch recall columns for the matched scars (returning without them):", + error instanceof Error ? error.message : error); + return rows; + } } /** diff --git a/tests/e2e/blank-supabase.mjs b/tests/e2e/blank-supabase.mjs index 0a16761..39e5a63 100644 --- a/tests/e2e/blank-supabase.mjs +++ b/tests/e2e/blank-supabase.mjs @@ -200,7 +200,7 @@ function isolatedHome(tag) { return { home, gitmemDir }; } const STRIP = /^(OPENAI_API_KEY|OPENROUTER_API_KEY|GITMEM_|SUPABASE_|OLLAMA_|NODE_OPTIONS$|CLAUDE_CODE_ENTRYPOINT$)/; -async function startServer({ home, gitmemDir, ollamaUrl, netlog, stderrSink }) { +async function startServer({ home, gitmemDir, ollamaUrl, netlog, stderrSink, extraEnv = {} }) { const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !STRIP.test(k))); Object.assign(env, { HOME: home, @@ -216,6 +216,7 @@ async function startServer({ home, gitmemDir, ollamaUrl, netlog, stderrSink }) { GITMEM_NETLOG: netlog, NODE_OPTIONS: `--import=${pathToFileURL(NETLOG_PRELOAD).href}`, NO_COLOR: "1", + ...extraEnv, }); const transport = new StdioClientTransport({ command: process.execPath, args: [SERVER], env, cwd: home, stderr: "pipe" }); transport.stderr?.on("data", (d) => stderrSink.push(d.toString())); @@ -422,6 +423,33 @@ async function flow() { const relevance = relevanceRows.map((m) => ({ tool: m.tool_name, memories_applied: m.metadata.memories_applied, memory_relevance: m.metadata.memory_relevance })); await waitQuiet(netlog, { minMs: 1000, quietMs: 2000 }); await srv.close(); + + // GIT-114: recall served by the store, not the local index. (1) Forced with + // GITMEM_SEARCH_MODE=remote. (2) A fresh server asked before its index has + // loaded — the cold-start path every customer hits first. Both must return a + // seeded scar; the store must answer the RPC (a 404 fails the run below). + const recallProbe = async (tag, extraEnv) => { + const h = isolatedHome(`${label}-${tag}`); + const probeLog = join(outDir, `netlog-${label}-${tag}.jsonl`); + writeFileSync(probeLog, ""); + const errs = []; + const p = await startServer({ home: h.home, gitmemDir: h.gitmemDir, ollamaUrl: ollama.url, netlog: probeLog, stderrSink: errs, extraEnv }); + let text = ""; + try { + text = await p.call("recall", { plan: "Supabase migrations must be dry-run before push", project: PROJECT, match_count: 3 }); + } catch (e) { text = `ERROR ${e}`; } + await p.close(); + allVenueRequests.push(...venueReqs(readNet(probeLog))); + const errText = errs.join(""); + return { + found_seeded_scar: /Supabase migrations must be dry-run/.test(text), + used_store_rpc: venueReqs(readNet(probeLog)).some((r) => r.path.includes("/rpc/") && r.status < 300), + fell_back_from_local: /Local cache not ready/.test(errText), + text: text.slice(0, 300), + }; + }; + const recallRemote = await recallProbe("recall-remote", { GITMEM_SEARCH_MODE: "remote" }); + const recallCold = await recallProbe("recall-cold", {}); ollama.close(); const reqs = readNet(netlog); @@ -441,6 +469,8 @@ async function flow() { remote_sessions_after: await count("gitmem_sessions"), remote_scar_usage_after: await count("gitmem_scar_usage"), session_close_persisted: closedRows.length === 1 && closedRows[0].closing_reflection != null, + recall_remote: recallRemote, + recall_cold: recallCold, missing_payload_named: missingPayloadNamed, resolve_thread_durable: resolveDurable, close_warns: closeWarns, @@ -648,6 +678,12 @@ if (mode === "flow" && !result.thread_triples_linked) { if (mode === "flow" && result.false_stranded_notice) { failureCheck.unexpected.push("session_start reported the store it reads as 'NOT being read' (GIT-107)"); } +if (mode === "flow" && !(result.recall_remote.found_seeded_scar && result.recall_remote.used_store_rpc)) { + failureCheck.unexpected.push(`recall with GITMEM_SEARCH_MODE=remote did not return a seeded scar from the store (GIT-114): ${result.recall_remote.text}`); +} +if (mode === "flow" && !result.recall_cold.found_seeded_scar) { + failureCheck.unexpected.push(`cold-start recall returned no seeded scar (GIT-114): ${result.recall_cold.text}`); +} if (mode === "flow" && !result.session_close_persisted) { failureCheck.unexpected.push(`session_close did not persist session ${result.session_id} (closing_reflection missing)`); } diff --git a/tests/helpers/schema-drift.ts b/tests/helpers/schema-drift.ts index 67b19dc..bfd092f 100644 --- a/tests/helpers/schema-drift.ts +++ b/tests/helpers/schema-drift.ts @@ -527,3 +527,99 @@ export function compare(scan: ScanResult, floor: Schema, current: Schema): Findi for (const t of scan.tables) if (!floor.has(t.table)) note(t.table, null, "table", `${t.file}:${t.line}`); return [...byKey.values()].sort((x, y) => `${x.table}.${x.column}`.localeCompare(`${y.table}.${y.column}`)); } + +// ---------------------------------------------------------------- RPCs (GIT-114) + +/** setup.sql -> function names (CREATE [OR REPLACE] FUNCTION name(...)). */ +export function parseFunctions(sql: string): Set { + const names = new Set(); + const re = /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+(?:public\.)?(\w+)\s*\(/gi; + let m: RegExpExecArray | null; + const clean = sql.replace(/--[^\n]*/g, ""); + while ((m = re.exec(clean))) names.add(m[1]); + return names; +} + +export interface RpcRef { + file: string; + line: number; + name: string; + /** A fixed https:// URL — another project (e.g. licensing), not the customer's store. */ + external: boolean; +} + +/** + * Text of a string or template literal, with getTableName("x") substitutions + * expanded to the default table name; any other substitution becomes "\0". + */ +function literalText(n: ts.Node): string | null { + if (ts.isStringLiteralLike(n)) return n.text; + if (ts.isTemplateExpression(n)) { + let out = n.head.text; + for (const span of n.templateSpans) { + const e = span.expression; + out += ts.isCallExpression(e) && ts.isIdentifier(e.expression) && e.expression.text === "getTableName" && + e.arguments[0] && ts.isStringLiteralLike(e.arguments[0]) + ? DEFAULT_PREFIX + e.arguments[0].text + : "\0"; + out += span.literal.text; + } + return out; + } + return null; +} + +/** + * Every RPC name src/ can call. Two shapes: + * - a literal or template URL containing "/rpc/"; + * - in a file that builds "/rpc/${…}" dynamically, every `name:` property of + * an object literal (the candidate list the URL is built from). + * Returns the refs and the dynamic call sites whose names could not be resolved. + */ +export function scanRpcNames(srcDir: string): { refs: RpcRef[]; unresolved: Unresolved[] } { + const refs: RpcRef[] = []; + const unresolved: Unresolved[] = []; + const files: string[] = []; + const walk = (d: string) => { + for (const e of fs.readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, e.name); + if (e.isDirectory()) walk(p); + else if (e.name.endsWith(".ts") && !e.name.endsWith(".test.ts") && !e.name.endsWith(".d.ts")) files.push(p); + } + }; + walk(srcDir); + for (const file of files) { + const rel = path.relative(path.dirname(srcDir), file); + const sf = ts.createSourceFile(file, fs.readFileSync(file, "utf-8"), ts.ScriptTarget.Latest, true); + const lineOf = (n: ts.Node) => sf.getLineAndCharacterOfPosition(n.getStart()).line + 1; + const dynamicSites: ts.Node[] = []; + const nameProps: ts.PropertyAssignment[] = []; + const visit = (n: ts.Node): void => { + const text = literalText(n); + if (text !== null && text.includes("/rpc/")) { + const seg = text.slice(text.indexOf("/rpc/") + 5).split(/[/?"'\s]/)[0]; + if (seg && !seg.includes("\0")) { + refs.push({ file: rel, line: lineOf(n), name: seg, external: /^https?:\/\//.test(text) }); + } else { + dynamicSites.push(n); + } + } + if (ts.isPropertyAssignment(n) && propName(n) === "name") nameProps.push(n); + ts.forEachChild(n, visit); + }; + visit(sf); + if (dynamicSites.length === 0) continue; + let found = 0; + for (const p of nameProps) { + const t = literalText(p.initializer); + if (t && /^[a-z][a-z0-9_]*$/.test(t)) { + refs.push({ file: rel, line: lineOf(p), name: t, external: false }); + found++; + } + } + if (found === 0) { + for (const d of dynamicSites) unresolved.push({ file: rel, line: lineOf(d), call: "rpc", what: `dynamic RPC name ${d.getText().slice(0, 60)}` }); + } + } + return { refs, unresolved }; +} diff --git a/tests/unit/schema-drift.test.ts b/tests/unit/schema-drift.test.ts index d29936b..83bb05e 100644 --- a/tests/unit/schema-drift.test.ts +++ b/tests/unit/schema-drift.test.ts @@ -21,7 +21,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import { parseSchema, scanSources, compare, selectColumns } from "../helpers/schema-drift.js"; +import { parseSchema, scanSources, compare, selectColumns, parseFunctions, scanRpcNames } from "../helpers/schema-drift.js"; import type { Finding, ScanResult, Schema } from "../helpers/schema-drift.js"; import { SESSION_COLUMNS, PRODUCTION_ONLY_SESSION_COLUMNS } from "../../src/services/session-columns.js"; @@ -177,3 +177,63 @@ describe("src/ against the v1.8.0 schema", () => { expect(missing, "SESSION_COLUMNS entries a v1.8.0 store rejects").toEqual([]); }); }); + +// --------------------------------------------------------------------------- +// GIT-114: RPC names +// --------------------------------------------------------------------------- + +/** + * RPCs src/ may call that v1.8.0's setup.sql does not define, each owned by a + * ticket. Remove an entry when it goes; a stale entry fails. + */ +const ALLOWED_RPCS: Array<{ name: string; ticket: string; reason: string }> = [ + { name: "match_gitmem_learnings_weighted", ticket: "GIT-114", reason: "fallback only after gitmem_scar_search answers PGRST202 (a store without the setup.sql function, e.g. nTEG's)" }, + { name: "match_transcript_chunks", ticket: "GIT-106", reason: "transcript search: dev tier only; setup.sql creates neither the table nor the function" }, +]; + +describe("RPC names against the v1.8.0 schema (GIT-114)", () => { + const functions = parseFunctions(fs.readFileSync(FLOOR_SQL, "utf-8")); + const rpc = scanRpcNames(path.join(REPO, "src")); + const storeRefs = rpc.refs.filter((r) => !r.external); + + it("parses setup.sql's functions", () => { + expect([...functions]).toEqual(expect.arrayContaining([ + "gitmem_scar_search", "gitmem_semantic_search", "refresh_scar_behavioral_scores", + ])); + }); + + it("finds the RPC call sites, including the scar-search candidate list", () => { + const names = storeRefs.map((r) => r.name); + expect(names).toEqual(expect.arrayContaining(["gitmem_scar_search", "refresh_scar_behavioral_scores"])); + expect(rpc.unresolved).toEqual([]); + }); + + it("every RPC on the customer's store is defined by setup.sql or allowlisted by ticket", () => { + const missing = storeRefs.filter((r) => !functions.has(r.name) && !ALLOWED_RPCS.some((a) => a.name === r.name)); + expect(missing.map((r) => `${r.name} at ${r.file}:${r.line}`)).toEqual([]); + }); + + it("every allowlisted RPC is still referenced and names a ticket", () => { + for (const a of ALLOWED_RPCS) { + expect(a.ticket).toMatch(/^GIT-\d+$/); + expect(storeRefs.some((r) => r.name === a.name), `stale ALLOWED_RPCS entry ${a.name}`).toBe(true); + } + }); + + it("catches a planted call to a function setup.sql lacks, and a dynamic name it cannot resolve", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gitmem-rpc-drift-")); + try { + fs.mkdirSync(path.join(dir, "src")); + fs.writeFileSync(path.join(dir, "src", "a.ts"), ` + declare const U: string; declare function getTableName(b: string): string; declare const x: string; + export const a = \`\${U}/rest/v1/rpc/match_\${getTableName("learnings")}_weighted\`; + export const b = "https://example.supabase.co/rest/v1/rpc/licence_check"; + export const c = \`\${U}/rest/v1/rpc/\${x}\`;`); + const r = scanRpcNames(path.join(dir, "src")); + expect(r.refs.map((x) => `${x.name}:${x.external}`).sort()).toEqual(["licence_check:true", "match_gitmem_learnings_weighted:false"]); + expect(r.unresolved).toHaveLength(1); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/services/scar-search-rpc-name.test.ts b/tests/unit/services/scar-search-rpc-name.test.ts index 2754cfd..8fe94e5 100644 --- a/tests/unit/services/scar-search-rpc-name.test.ts +++ b/tests/unit/services/scar-search-rpc-name.test.ts @@ -97,22 +97,96 @@ describe("GIT-93: retrieval RPC names are derived from the table, not the prefix expect(calls[0].body).not.toHaveProperty("project_filter"); }); - it("semanticSearch calls match_
", async () => { + // GIT-114: this used to assert match_gitmem_learnings_weighted for the + // default prefix — the name GIT-93 took from nTEG's store. No setup.sql + // defines it, so every customer's cold-index recall got PGRST202. The + // default prefix now calls setup.sql's function first. + it("default prefix: calls setup.sql's gitmem_scar_search with similarity_threshold", async () => { const { calls } = stubFetch(); - const client = await loadClient("orchestra_"); + const client = await loadClient("gitmem_"); - await client.semanticSearch({ query: "any query", match_count: 5 }); + await client.scarSearch("any query", 3); - expect(calls[0].url).toContain("/rest/v1/rpc/match_orchestra_learnings"); - expect(calls[0].url).not.toContain("orchestra_semantic_search"); + expect(calls[0].url).toMatch(/\/rest\/v1\/rpc\/gitmem_scar_search$/); + expect(calls[0].body).toHaveProperty("similarity_threshold", 0); + expect(calls[0].body).not.toHaveProperty("match_threshold"); + expect(calls[0].body).not.toHaveProperty("project_filter"); }); +}); - it("tracks the prefix rather than hardcoding one deployment's table", async () => { - const { calls } = stubFetch(); +// --------------------------------------------------------------------------- +// GIT-114: fallback and missing columns +// --------------------------------------------------------------------------- + +type Reply = { status: number; body: unknown }; +function scriptedFetch(replies: Array<(url: string) => Reply | null>) { + const calls: Array<{ url: string; body: Record }> = []; + vi.stubGlobal("fetch", vi.fn(async (url: string, init?: { body?: string }) => { + calls.push({ url: String(url), body: init?.body ? JSON.parse(init.body) : {} }); + let r: Reply | null = null; + for (const f of replies) { r = f(String(url)); if (r) break; } + r = r ?? { status: 200, body: [] }; + const text = typeof r.body === "string" ? r.body : JSON.stringify(r.body); + return { ok: r.status < 300, status: r.status, json: async () => JSON.parse(text), text: async () => text } as unknown as Response; + })); + return { calls }; +} +const PGRST202 = { status: 404, body: '{"code":"PGRST202","message":"Could not find the function"}' }; +const SCAR = { id: "11111111-2222-4333-8444-555555555555", title: "t", description: "d", severity: "high", similarity: 0.8 }; + +describe("GIT-114: remote scar search on a setup.sql store", () => { + beforeEach(() => { vi.resetModules(); }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.doUnmock("../../../src/services/embedding.js"); + process.env = { ...ORIGINAL_ENV }; + }); + + it("a store without gitmem_scar_search (PGRST202) falls back to match_gitmem_learnings_weighted, and remembers it", async () => { + const { calls } = scriptedFetch([ + (u) => (u.endsWith("/rpc/gitmem_scar_search") ? PGRST202 : null), + (u) => (u.endsWith("/rpc/match_gitmem_learnings_weighted") ? { status: 200, body: [{ ...SCAR, learning_type: "scar" }] } : null), + ]); const client = await loadClient("gitmem_"); - await client.scarSearch("any query", 3); + const rows = await client.scarSearch("q", 3); + expect(rows).toHaveLength(1); + expect(calls.map((c) => c.url.split("/rpc/")[1])).toEqual(["gitmem_scar_search", "match_gitmem_learnings_weighted"]); + expect(calls[1].body).toHaveProperty("match_threshold"); + + calls.length = 0; + await client.scarSearch("q", 3); + expect(calls.map((c) => c.url.split("/rpc/")[1])).toEqual(["match_gitmem_learnings_weighted"]); + }); + + it("a real error from an existing function is not retried under another name", async () => { + const { calls } = scriptedFetch([(u) => (u.includes("/rpc/") ? { status: 500, body: "boom" } : null)]); + const client = await loadClient("gitmem_"); + await expect(client.scarSearch("q", 3)).rejects.toThrow(/500/); + expect(calls).toHaveLength(1); + }); + + it("fetches the columns gitmem_scar_search does not return, by id, in one request", async () => { + const { calls } = scriptedFetch([ + (u) => (u.endsWith("/rpc/gitmem_scar_search") ? { status: 200, body: [SCAR] } : null), + (u) => (u.includes("/rest/v1/gitmem_learnings?") ? { status: 200, body: [{ id: SCAR.id, learning_type: "scar", applies_when: ["deploy"], why_this_matters: "w" }] } : null), + ]); + const client = await loadClient("gitmem_"); + + const [row] = await client.scarSearch>("q", 3); + expect(row).toMatchObject({ ...SCAR, learning_type: "scar", applies_when: ["deploy"], why_this_matters: "w" }); + const follow = new URL(calls[1].url); + expect(follow.searchParams.get("id")).toBe(`in.(${SCAR.id})`); + expect(follow.searchParams.get("select")).toContain("learning_type"); + }); - expect(calls[0].url).toContain("/rest/v1/rpc/match_gitmem_learnings_weighted"); + it("if the follow-up select fails, the matched scars are still returned", async () => { + scriptedFetch([ + (u) => (u.endsWith("/rpc/gitmem_scar_search") ? { status: 200, body: [SCAR] } : null), + (u) => (u.includes("/rest/v1/gitmem_learnings?") ? { status: 503, body: "down" } : null), + ]); + const client = await loadClient("gitmem_"); + const rows = await client.scarSearch>("q", 3); + expect(rows).toEqual([SCAR]); }); });