fix(vector-stores): Supabase 1000-row cap, RLS init probe, and col_info crash (#6695)
This commit is contained in:
@@ -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) };
|
||||
|
||||
Reference in New Issue
Block a user