Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions src/services/supabase-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ export async function upsertRecord<T = unknown>(
/**
* Semantic search across tables
*
* Generates an embedding for the query, then calls the gitmem_semantic_search
* Generates an embedding for the query, then calls the match_<table>
* RPC function directly via PostgREST.
*/
export async function semanticSearch<T = unknown>(
Expand All @@ -211,8 +211,11 @@ export async function semanticSearch<T = unknown>(
return [];
}

// Call the RPC function directly via PostgREST
const rpcName = `${getTableName("").replace(/_$/, "")}_semantic_search`;
// GIT-93: same defect as scarSearch below — this built `${prefix}_semantic_search`,
// which exists under no prefix. The deployed function is match_<table>, 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, {
Expand Down Expand Up @@ -579,7 +582,7 @@ export async function loadScarsWithEmbeddings<T = unknown>(
/**
* Scar search with severity weighting
*
* Generates an embedding for the query, then calls the gitmem_scar_search
* Generates an embedding for the query, then calls the match_<table>_weighted
* RPC function directly via PostgREST. No Edge Function required.
*/
export async function scarSearch<T = unknown>(
Expand All @@ -600,8 +603,20 @@ export async function scarSearch<T = unknown>(
return [];
}

// Call the RPC function directly via PostgREST
const rpcName = `${getTableName("").replace(/_$/, "")}_scar_search`;
// 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 response = await fetch(url, {
Expand All @@ -614,7 +629,12 @@ export async function scarSearch<T = unknown>(
body: JSON.stringify({
query_embedding: `[${embedding.join(",")}]`,
match_count: matchCount,
similarity_threshold: 0.0,
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),
});
Expand Down
118 changes: 118 additions & 0 deletions tests/unit/services/scar-search-rpc-name.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* GIT-93: the retrieval RPCs must be called by the name that is actually
* deployed, which is derived from the TABLE being searched.
*
* scarSearch and semanticSearch built their RPC name by taking the table prefix
* and appending a verb: `${prefix}_scar_search` / `${prefix}_semantic_search`.
* That produced "orchestra_scar_search" under GITMEM_TABLE_PREFIX=orchestra_ and
* "gitmem_scar_search" by default. A survey of the functions PostgREST exposes
* found neither, under any prefix — the deployed names are match_<table> and
* match_<table>_weighted. Every call returned PGRST202.
*
* It went unnoticed because these are fallbacks: recall only reaches them while
* the local vector index is still loading. In that window — which includes the
* first recall of every session, the one the SessionStart hook triggers —
* retrieval returned nothing at all.
*
* These tests assert the URL rather than the response, because the defect was
* entirely in name construction. They are hermetic: fetch is stubbed, so they
* fail on a wrong name rather than on network conditions, and they hold for a
* deployment whose functions this developer cannot reach.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

const ORIGINAL_ENV = { ...process.env };

/** Captures the URL and body of the single fetch each search performs. */
function stubFetch(): { calls: Array<{ url: string; body: Record<string, unknown> }> } {
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
vi.stubGlobal("fetch", vi.fn(async (url: string, init?: { body?: string }) => {
calls.push({ url: String(url), body: init?.body ? JSON.parse(init.body) : {} });
return {
ok: true,
status: 200,
json: async () => [],
text: async () => "[]",
} as unknown as Response;
}));
return { calls };
}

async function loadClient(prefix: string) {
process.env.GITMEM_TABLE_PREFIX = prefix;
process.env.SUPABASE_URL = "https://example.supabase.co";
process.env.SUPABASE_SERVICE_ROLE_KEY = "test-key";
process.env.GITMEM_TIER = "pro";
// Reset the module registry so SUPABASE_URL and the prefix are re-read.
vi.resetModules();
vi.doMock("../../../src/services/embedding.js", () => ({
embed: async () => new Array(1536).fill(0.01),
}));
return import("../../../src/services/supabase-client.js");
}

describe("GIT-93: retrieval RPC names are derived from the table, not the prefix", () => {
beforeEach(() => { vi.resetModules(); });

afterEach(() => {
vi.unstubAllGlobals();
vi.doUnmock("../../../src/services/embedding.js");
process.env = { ...ORIGINAL_ENV };
});

it("scarSearch calls match_<table>_weighted under a non-default prefix", async () => {
const { calls } = stubFetch();
const client = await loadClient("orchestra_");

await client.scarSearch("any query", 3);

expect(calls).toHaveLength(1);
expect(calls[0].url).toContain("/rest/v1/rpc/match_orchestra_learnings_weighted");
// The name that was being built before the fix. Asserted explicitly so this
// test fails loudly if the prefix-plus-verb construction ever returns.
expect(calls[0].url).not.toContain("orchestra_scar_search");
});

it("scarSearch sends match_threshold, which is what the weighted function takes", async () => {
const { calls } = stubFetch();
const client = await loadClient("orchestra_");

await client.scarSearch("any query", 3);

// The unweighted variant takes similarity_threshold; sending the wrong one
// to the weighted function silently loses the threshold.
expect(calls[0].body).toHaveProperty("match_threshold");
expect(calls[0].body).not.toHaveProperty("similarity_threshold");
});

it("scarSearch does not narrow the fallback to one project", async () => {
const { calls } = stubFetch();
const client = await loadClient("orchestra_");

await client.scarSearch("any query", 3);

// It stands in for the unified CROSS-PROJECT vector cache. Filtering here
// would make the cold path return a narrower set than the warm path.
expect(calls[0].body).not.toHaveProperty("project_filter");
});

it("semanticSearch calls match_<table>", async () => {
const { calls } = stubFetch();
const client = await loadClient("orchestra_");

await client.semanticSearch({ query: "any query", match_count: 5 });

expect(calls[0].url).toContain("/rest/v1/rpc/match_orchestra_learnings");
expect(calls[0].url).not.toContain("orchestra_semantic_search");
});

it("tracks the prefix rather than hardcoding one deployment's table", async () => {
const { calls } = stubFetch();
const client = await loadClient("gitmem_");

await client.scarSearch("any query", 3);

expect(calls[0].url).toContain("/rest/v1/rpc/match_gitmem_learnings_weighted");
});
});
Loading