fix(vector-stores): Supabase 1000-row cap, RLS init probe, and col_info crash (#6695)

This commit is contained in:
Kartik
2026-07-31 20:36:41 +05:30
committed by GitHub
parent 8d45fb3c9a
commit 29fa41558c
5 changed files with 277 additions and 93 deletions
+51 -46
View File
@@ -31,6 +31,8 @@ interface SupabaseConfig extends VectorStoreConfig {
metadataColumnName?: string;
}
const POSTGREST_MAX_ROWS = 1000;
/*
SQL Migration to run in Supabase SQL Editor:
@@ -123,34 +125,19 @@ export class SupabaseDB implements VectorStore {
private async _doInitialize(): Promise<void> {
await this.ensureClient();
try {
// Verify table exists and vector operations work by attempting a test insert
const testVector = Array(1536).fill(0);
// First try to delete any existing test vector
try {
await this.client.from(this.tableName).delete().eq("id", "test_vector");
} catch {
// Ignore delete errors - table might not exist yet
}
// Try to insert the test vector
const { error: insertError } = await this.client
const { error: probeError } = await this.client
.from(this.tableName)
.insert({
id: "test_vector",
[this.embeddingColumnName]: testVector,
[this.metadataColumnName]: {},
})
.select();
.select(this.embeddingColumnName)
.limit(1);
// If we get a duplicate key error, that's actually fine - it means the table exists
if (insertError && insertError.code !== "23505") {
console.error("Test insert error:", insertError);
if (probeError) {
console.error("Table probe error:", probeError);
throw new Error(
`Vector operations failed. Please ensure:
1. The vector extension is enabled
2. The table "${this.tableName}" exists with correct schema
3. The match_vectors function is created
4. Row Level Security policies allow the configured Supabase key to read the table
RUN THE FOLLOWING SQL IN YOUR SUPABASE SQL EDITOR:
@@ -205,13 +192,6 @@ See the SQL migration instructions in the code comments.`,
);
}
// Clean up test vector - ignore errors here too
try {
await this.client.from(this.tableName).delete().eq("id", "test_vector");
} catch {
// Ignore delete errors
}
console.log("Connected to Supabase successfully");
} catch (error) {
console.error("Error during Supabase initialization:", error);
@@ -270,6 +250,13 @@ See the SQL migration instructions in the code comments.`,
if (!data) return [];
const results = data as VectorSearchResult[];
if (topK > POSTGREST_MAX_ROWS && results.length === POSTGREST_MAX_ROWS) {
console.warn(
`Supabase search requested topK=${topK} but match_vectors returned exactly the PostgREST row cap of ${POSTGREST_MAX_ROWS}; results were likely truncated.`,
);
}
return results.map((result) => ({
id: result.id,
payload: result.metadata,
@@ -364,27 +351,45 @@ See the SQL migration instructions in the code comments.`,
): Promise<[VectorStoreResult[], number]> {
await this.initialize();
try {
let query = this.client
.from(this.tableName)
.select("*", { count: "exact" })
.limit(topK);
const results: VectorStoreResult[] = [];
let totalCount = 0;
let offset = 0;
if (filters) {
Object.entries(filters).forEach(([key, value]) => {
query = query.eq(`${this.metadataColumnName}->>${key}`, value);
});
while (results.length < topK) {
const to = Math.min(offset + POSTGREST_MAX_ROWS, topK) - 1;
let query = this.client
.from(this.tableName)
.select("*", { count: "exact" })
.order("id", { ascending: true });
if (filters) {
Object.entries(filters).forEach(([key, value]) => {
query = query.eq(`${this.metadataColumnName}->>${key}`, value);
});
}
const { data, error, count } = await query.range(offset, to);
if (error) throw error;
totalCount = count ?? totalCount;
const page = (data ?? []).map((item: VectorData) => ({
id: item.id,
payload: item[this.metadataColumnName],
}));
results.push(...page);
const requestedPageSize = to - offset + 1;
if (page.length < requestedPageSize) break;
offset += page.length;
if (results.length >= totalCount) break;
}
const { data, error, count } = await query;
if (error) throw error;
const results = data.map((item: VectorData) => ({
id: item.id,
payload: item[this.metadataColumnName],
}));
return [results, count || 0];
return [results, totalCount];
} catch (error) {
console.error("Error listing vectors:", error);
throw error;
@@ -531,34 +531,43 @@ describe("Redis – backward compat with mocked client", () => {
// ───────────────────────────────────────────────────────────────────────────
describe("Supabase – backward compat with mocked client", () => {
let SupabaseDB: any;
let mockClient: any;
let mockTableApi: any;
let mockSelectBuilder: any;
beforeEach(() => {
jest.resetModules();
jest.doMock("@supabase/supabase-js", () => {
const mockClient = {
from: jest.fn().mockReturnValue({
insert: jest.fn().mockReturnValue({
select: jest.fn().mockReturnValue({ error: null }),
}),
select: jest.fn().mockReturnValue({
eq: jest.fn().mockReturnValue({ data: [], error: null }),
}),
delete: jest.fn().mockReturnValue({
eq: jest.fn().mockReturnValue({ error: null }),
}),
update: jest.fn().mockReturnValue({
eq: jest.fn().mockReturnValue({ error: null }),
}),
upsert: jest.fn().mockReturnValue({ error: null }),
}),
rpc: jest.fn().mockResolvedValue({ data: [], error: null }),
};
return {
createClient: jest.fn().mockReturnValue(mockClient),
__mockClient: mockClient,
};
});
mockSelectBuilder = {
limit: jest.fn().mockResolvedValue({ data: [{}], error: null }),
range: jest.fn().mockResolvedValue({ data: [], error: null, count: 0 }),
maybeSingle: jest.fn().mockResolvedValue({ data: null, error: null }),
};
mockSelectBuilder.eq = jest.fn().mockReturnValue(mockSelectBuilder);
mockSelectBuilder.order = jest.fn().mockReturnValue(mockSelectBuilder);
mockTableApi = {
select: jest.fn().mockReturnValue(mockSelectBuilder),
insert: jest.fn().mockReturnValue({ error: null }),
delete: jest.fn().mockReturnValue({
eq: jest.fn().mockReturnValue({ error: null }),
neq: jest.fn().mockReturnValue({ error: null }),
}),
update: jest.fn().mockReturnValue({
eq: jest.fn().mockReturnValue({ error: null }),
}),
upsert: jest.fn().mockReturnValue({ error: null }),
};
mockClient = {
from: jest.fn().mockReturnValue(mockTableApi),
rpc: jest.fn().mockResolvedValue({ data: [], error: null }),
};
jest.doMock("@supabase/supabase-js", () => ({
createClient: jest.fn().mockReturnValue(mockClient),
__mockClient: mockClient,
}));
SupabaseDB = require("../src/vector_stores/supabase").SupabaseDB;
});
@@ -598,7 +607,86 @@ describe("Supabase – backward compat with mocked client", () => {
const p1 = store.initialize();
const p2 = store.initialize();
await Promise.all([p1, p2]);
// No crash = idempotent (Supabase init runs test insert only once)
// No crash = idempotent (Supabase init runs a read-only probe query only once)
});
it("_doInitialize probes with a non-destructive select, not an insert", async () => {
const store = new SupabaseDB({
supabaseUrl: "https://example.supabase.co",
supabaseKey: "fake-key",
tableName: "memories",
collectionName: "test",
});
await store.initialize();
expect(mockTableApi.insert).not.toHaveBeenCalled();
expect(mockTableApi.select).toHaveBeenCalledWith("embedding");
expect(mockSelectBuilder.limit).toHaveBeenCalledWith(1);
});
it("list() paginates via .range() across multiple pages when count exceeds page size", async () => {
const totalRows = 1500;
const rangeMock = jest
.fn()
.mockImplementation((from: number, to: number) => {
const remaining = totalRows - from;
const rowCount = Math.max(0, Math.min(to - from + 1, remaining));
const data = Array.from({ length: rowCount }, (_, i) => ({
id: `id-${from + i}`,
metadata: { index: from + i },
}));
return Promise.resolve({ data, error: null, count: totalRows });
});
const listBuilder: any = {
limit: jest.fn().mockResolvedValue({ data: [{}], error: null }),
range: rangeMock,
};
listBuilder.eq = jest.fn().mockReturnValue(listBuilder);
listBuilder.order = jest.fn().mockReturnValue(listBuilder);
mockTableApi.select.mockReturnValue(listBuilder);
const store = new SupabaseDB({
supabaseUrl: "https://example.supabase.co",
supabaseKey: "fake-key",
tableName: "memories",
collectionName: "test",
});
const [results, count] = await store.list(undefined, totalRows);
expect(count).toBe(totalRows);
expect(results).toHaveLength(totalRows);
expect(rangeMock.mock.calls.length).toBeGreaterThan(1);
expect(listBuilder.order).toHaveBeenCalledWith("id", { ascending: true });
expect(new Set(results.map((r) => r.id)).size).toBe(totalRows);
});
it("search() warns when results are truncated at the PostgREST row cap", async () => {
const cappedResults = Array.from({ length: 1000 }, (_, i) => ({
id: `id-${i}`,
similarity: 0.9,
metadata: {},
}));
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
const store = new SupabaseDB({
supabaseUrl: "https://example.supabase.co",
supabaseKey: "fake-key",
tableName: "memories",
collectionName: "test",
});
mockClient.rpc.mockResolvedValue({ data: cappedResults, error: null });
const results = await store.search([0.1, 0.2, 0.3], 5000);
expect(results).toHaveLength(1000);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("5000"));
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("1000"));
warnSpy.mockRestore();
});
it("constructor does not emit an unhandled rejection when init fails", async () => {
@@ -606,14 +694,15 @@ describe("Supabase – backward compat with mocked client", () => {
jest.doMock("@supabase/supabase-js", () => {
const failing = {
from: jest.fn().mockReturnValue({
insert: jest.fn().mockReturnValue({
select: jest.fn().mockResolvedValue({
error: { code: "42P01", message: "no table" },
select: jest.fn().mockReturnValue({
limit: jest.fn().mockResolvedValue({
data: null,
error: {
code: "42501",
message: "permission denied for table memories",
},
}),
}),
delete: jest.fn().mockReturnValue({
eq: jest.fn().mockResolvedValue({ error: null }),
}),
}),
};
return { createClient: jest.fn().mockReturnValue(failing) };