diff --git a/.changeset/secure-supabase-vector-rpc.md b/.changeset/secure-supabase-vector-rpc.md new file mode 100644 index 00000000..aa8a99f9 --- /dev/null +++ b/.changeset/secure-supabase-vector-rpc.md @@ -0,0 +1,5 @@ +--- +'@agentskit/memory': patch +--- + +Replace the Supabase vector adapter's arbitrary-SQL RPC with direct PostgREST mutations and a purpose-specific similarity-search function. diff --git a/apps/docs-next/content/docs/data/memory/supabase-vector.mdx b/apps/docs-next/content/docs/data/memory/supabase-vector.mdx index 4cf766c3..1f32ebd8 100644 --- a/apps/docs-next/content/docs/data/memory/supabase-vector.mdx +++ b/apps/docs-next/content/docs/data/memory/supabase-vector.mdx @@ -1,6 +1,6 @@ --- title: supabaseVectorStore -description: Supabase-hosted pgvector — wraps the pgvector adapter with a Supabase RPC runner. +description: Supabase-hosted pgvector with direct mutations and a purpose-specific similarity-search RPC. --- ```ts @@ -12,31 +12,71 @@ const store = supabaseVectorStore({ }) ``` -`@supabase/supabase-js` is an **optional peer dependency** loaded lazily. +`@supabase/supabase-js` is an **optional peer dependency** loaded lazily. Keep +the service-role key in server-only code. ## Server-side setup -Run once in the Supabase SQL editor: +Run this once in the Supabase SQL editor. Change both vector dimensions if your +embedding model does not produce 1536-dimensional vectors. ```sql -create extension if not exists vector; +create extension if not exists vector with schema extensions; -create table agentskit_vectors ( +create table public.agentskit_vectors ( id text primary key, - content text, - embedding vector(1536), - metadata jsonb + content text not null, + embedding extensions.vector(1536) not null, + metadata jsonb not null default '{}'::jsonb ); -create or replace function agentskit_execute_sql(sql text, params jsonb) - returns setof json language plpgsql security definer as $$ -begin - return query execute sql using params; -end; +create or replace function public.match_agentskit_vectors( + query_embedding extensions.vector(1536), + match_count integer default 10, + match_threshold double precision default 0, + filter jsonb default '{}'::jsonb +) +returns table ( + id text, + content text, + metadata jsonb, + similarity double precision +) +language sql +stable +security invoker +set search_path = '' +as $$ + select + vectors.id, + vectors.content, + vectors.metadata, + 1 - (vectors.embedding operator(extensions.<=>) query_embedding) as similarity + from public.agentskit_vectors as vectors + where vectors.metadata @> filter + and 1 - (vectors.embedding operator(extensions.<=>) query_embedding) > match_threshold + order by vectors.embedding operator(extensions.<=>) query_embedding + limit least(greatest(match_count, 1), 100); $$; + +revoke all on function public.match_agentskit_vectors( + extensions.vector, + integer, + double precision, + jsonb +) from public, anon; + +grant execute on function public.match_agentskit_vectors( + extensions.vector, + integer, + double precision, + jsonb +) to service_role; ``` -The wrapper expects the `agentskit_execute_sql` RPC to exist — it's how we channel parameterized SQL from the Supabase client to pgvector without bundling a separate `pg` driver. +The adapter writes through PostgREST `upsert`, deletes through +`delete().in(...)`, and calls only `match_agentskit_vectors` for search. It does +not expose an RPC that accepts arbitrary SQL. ## Config @@ -45,18 +85,43 @@ The wrapper expects the `agentskit_execute_sql` RPC to exist — it's how we cha | `url` | `string` | required | | `serviceRoleKey` | `string` | required (server-side only) | | `table` | `string` | `'agentskit_vectors'` | +| `matchFunction` | `string` | `'match_agentskit_vectors'` | | `topK` | `number` | `10` | +If you change `table`, provide a matching purpose-specific function and set +`matchFunction` to its name. + ## Surface -Implements `VectorMemory`: `store(docs)` / `search(embedding, options)` / `delete(ids)`. Inherits the `VectorFilter` contract — pass `options.filter` for metadata-scoped retrieval. +Implements `VectorMemory`: `store(docs)` / `search(embedding, options)` / +`delete(ids)`. The default SQL function accepts simple metadata equality +filters through JSON containment, for example `{ tenantId: 'acme' }`. To +support compound or comparison operators, provide a custom bounded RPC with +the same parameters and return columns. + +## Security -## Caveats +- Never expose `SUPABASE_SERVICE_ROLE_KEY` to a browser or mobile client. +- Keep the search function `security invoker`; it needs no definer privileges. +- Do not replace it with a function that accepts SQL text from the caller. +- Use a dedicated table and the narrowest database grants required by your + server-side role. -- Service-role key bypasses RLS — keep it server-side only. -- Cosine-distance search is the default; switch operators in the underlying RPC if you need a different metric. +## Cleanup + +Remove the integration objects if you no longer use them: + +```sql +drop function if exists public.match_agentskit_vectors( + extensions.vector, + integer, + double precision, + jsonb +); +drop table if exists public.agentskit_vectors; +``` ## Related -- [pgvector](./pgvector) — the adapter this wraps. +- [pgvector](./pgvector) — use this when you already have a safe SQL runner. - [Memory overview](./) diff --git a/packages/memory/src/vector/supabase.ts b/packages/memory/src/vector/supabase.ts index 4243f3de..8cdd8f2a 100644 --- a/packages/memory/src/vector/supabase.ts +++ b/packages/memory/src/vector/supabase.ts @@ -1,6 +1,5 @@ import { ErrorCodes, MemoryError } from '@agentskit/core' -import type { VectorMemory } from '@agentskit/core' -import { pgvector, type PgVectorRunner } from './pgvector' +import type { RetrievedDocument, VectorMemory, VectorSearchOptions } from '@agentskit/core' export interface SupabaseVectorStoreConfig { /** Supabase project URL, e.g. `https://xyz.supabase.co`. */ @@ -9,34 +8,42 @@ export interface SupabaseVectorStoreConfig { serviceRoleKey: string /** Table name. Default `agentskit_vectors`. */ table?: string + /** Purpose-specific similarity-search RPC. Default `match_agentskit_vectors`. */ + matchFunction?: string /** Default topK for search. Default 10. */ topK?: number } -interface SupabaseRpcResult { +interface SupabaseResult { data: T | null error: { message: string } | null } -interface SupabasePostgrestQuery { - rpc(fn: string, params?: Record): Promise> +interface SupabaseTableQuery { + upsert(rows: unknown[], options?: { onConflict?: string }): PromiseLike> + delete(): { + in(column: string, values: string[]): PromiseLike> + } } interface SupabaseClientLike { - from(table: string): SupabasePostgrestQuery & { - select(columns?: string): unknown - insert(rows: unknown[]): unknown - upsert(rows: unknown[], opts?: { onConflict?: string }): unknown - delete(): { in(column: string, values: unknown[]): unknown } - } - rpc(fn: string, params?: Record): Promise> + from(table: string): SupabaseTableQuery + rpc(fn: string, params?: Record): PromiseLike> } interface SupabaseModule { createClient(url: string, key: string): SupabaseClientLike } +interface SupabaseMatchRow { + id: string + content: string + metadata: Record | null + similarity: number +} + let cachedSdk: Promise | null = null + async function loadSdk(): Promise { if (!cachedSdk) { cachedSdk = (async () => { @@ -55,83 +62,78 @@ async function loadSdk(): Promise { return cachedSdk } -function buildRunner(client: SupabaseClientLike): PgVectorRunner { - // Supabase has no public raw-SQL API on `supabase-js`. We expose the same - // shape via a thin wrapper around an `execute_sql` RPC that the user - // creates server-side; this keeps the pgvector adapter wiring intact. - return { - async query(sql: string, params: unknown[]) { - const result = await client.rpc('agentskit_execute_sql', { sql, params }) - if (result.error) { - throw new MemoryError({ - code: ErrorCodes.AK_MEMORY_REMOTE_HTTP, - message: `supabase: ${result.error.message}`, - hint: 'Check the agentskit_execute_sql RPC + service role key permissions.', - }) - } - return { rows: (result.data ?? []) as T[] } - }, - } +function throwOnError(result: SupabaseResult, operation: string): void { + if (!result.error) return + throw new MemoryError({ + code: ErrorCodes.AK_MEMORY_REMOTE_HTTP, + message: `supabase ${operation}: ${result.error.message}`, + hint: 'Check the table, purpose-specific match RPC, and service-role key permissions.', + }) } /** - * Supabase-hosted pgvector. Wraps the existing `pgvector` adapter with a - * Supabase RPC runner so callers don't need to import a separate pg driver. - * - * Server-side setup (run once in Supabase SQL editor): - * create extension if not exists vector; - * create table agentskit_vectors ( - * id text primary key, content text, embedding vector(1536), - * metadata jsonb - * ); - * create or replace function agentskit_execute_sql(sql text, params jsonb) - * returns setof json language plpgsql security definer as $$ - * begin - * return query execute sql using params; - * end; - * $$; - * - * `@supabase/supabase-js` is an optional peer dependency loaded lazily. + * Supabase-hosted pgvector using direct PostgREST mutations and one + * purpose-specific similarity-search RPC. The service-role key stays + * server-side and `@supabase/supabase-js` is loaded lazily. */ export function supabaseVectorStore(config: SupabaseVectorStoreConfig): VectorMemory { - let runnerPromise: Promise | null = null - const getRunner = (): Promise => { - if (!runnerPromise) { - runnerPromise = (async () => { - const sdk = await loadSdk() - const client = sdk.createClient(config.url, config.serviceRoleKey) - return buildRunner(client) - })() - } - return runnerPromise - } + const table = config.table ?? 'agentskit_vectors' + const matchFunction = config.matchFunction ?? 'match_agentskit_vectors' + const defaultTopK = Math.max(1, Math.floor(config.topK ?? 10)) + let clientPromise: Promise | null = null - // Lazy delegate — we don't have a runner until first call. - let backend: VectorMemory | null = null - const getBackend = async (): Promise => { - if (!backend) { - const runner = await getRunner() - backend = pgvector({ - runner, - table: config.table, - topK: config.topK, - }) + const getClient = (): Promise => { + if (!clientPromise) { + clientPromise = loadSdk().then(sdk => sdk.createClient(config.url, config.serviceRoleKey)) } - return backend + return clientPromise } return { async store(docs) { - const b = await getBackend() - return b.store(docs) + if (docs.length === 0) return + const client = await getClient() + const result = await client.from(table).upsert( + docs.map(doc => ({ + id: doc.id, + content: doc.content, + embedding: doc.embedding, + metadata: doc.metadata ?? {}, + })), + { onConflict: 'id' }, + ) + throwOnError(result, 'store') }, - async search(embedding, options) { - const b = await getBackend() - return b.search(embedding, options) + + async search(embedding, options: VectorSearchOptions = {}): Promise { + const client = await getClient() + const topK = Math.max(1, Math.floor(options.topK ?? defaultTopK)) + const threshold = options.threshold ?? 0 + const result = await client.rpc(matchFunction, { + query_embedding: embedding, + match_count: topK, + match_threshold: threshold, + filter: options.filter ?? {}, + }) + throwOnError(result, 'search') + + return (result.data ?? []) + .map(row => ({ + id: row.id, + content: row.content, + metadata: row.metadata ?? undefined, + score: row.similarity, + })) + .filter(row => (row.score ?? 0) > threshold) + .sort((left, right) => (right.score ?? 0) - (left.score ?? 0)) + .slice(0, topK) }, + async delete(ids) { - const b = await getBackend() - return b.delete?.(ids) + if (ids.length === 0) return + const client = await getClient() + const result = await client.from(table).delete().in('id', ids) + throwOnError(result, 'delete') }, } } diff --git a/packages/memory/tests/supabase-full.test.ts b/packages/memory/tests/supabase-full.test.ts index 2a96e52a..eb5c77ad 100644 --- a/packages/memory/tests/supabase-full.test.ts +++ b/packages/memory/tests/supabase-full.test.ts @@ -1,96 +1,147 @@ -/** - * Full supabase vector store tests with injected fake SDK. - * Covers: store, search, delete, error path (RPC error). - */ -import { describe, it, expect, vi, afterEach } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' afterEach(() => { vi.resetModules() }) function makeFakeSupabaseClient() { - const rows: Array<{ id: string; content: string; metadata: Record | null; distance: number }> = [] + const upsert = vi.fn().mockResolvedValue({ data: null, error: null }) + const inFilter = vi.fn().mockResolvedValue({ data: null, error: null }) + const deleteRows = vi.fn(() => ({ in: inFilter })) + const from = vi.fn(() => ({ upsert, delete: deleteRows })) + const rpc = vi.fn().mockResolvedValue({ data: [], error: null }) + return { client: { from, rpc }, from, upsert, deleteRows, inFilter, rpc } +} - const rpc = vi.fn(async (_fn: string, params: { sql: string; params: unknown[] }) => { - const sql = (params.sql ?? '').toUpperCase() +describe('supabaseVectorStore', () => { + it('uses direct upsert and delete operations without sending SQL through RPC', async () => { + const fake = makeFakeSupabaseClient() + vi.doMock('@supabase/supabase-js', () => ({ createClient: vi.fn(() => fake.client) })) - if (sql.includes('INSERT INTO') || sql.includes('ON CONFLICT')) { - return { data: [], error: null } - } + const { supabaseVectorStore } = await import('../src/vector/supabase') + const store = supabaseVectorStore({ url: 'https://x.supabase.co', serviceRoleKey: 'k' }) - if (sql.includes('DELETE FROM')) { - return { data: [], error: null } - } + await store.store([{ id: 'doc-1', content: 'hello', embedding: [0.1, 0.2] }]) + await store.delete!(['doc-1']) - // SELECT (search) - return { data: rows, error: null } + expect(fake.from).toHaveBeenCalledTimes(2) + expect(fake.from).toHaveBeenNthCalledWith(1, 'agentskit_vectors') + expect(fake.upsert).toHaveBeenCalledWith( + [{ id: 'doc-1', content: 'hello', embedding: [0.1, 0.2], metadata: {} }], + { onConflict: 'id' }, + ) + expect(fake.deleteRows).toHaveBeenCalledOnce() + expect(fake.inFilter).toHaveBeenCalledWith('id', ['doc-1']) + expect(fake.rpc).not.toHaveBeenCalled() }) - return { rpc, rows } -} + it('uses the purpose-specific RPC and normalizes search results', async () => { + const fake = makeFakeSupabaseClient() + fake.rpc.mockResolvedValue({ + data: [ + { id: 'low', content: 'low', metadata: null, similarity: 0.4 }, + { id: 'high', content: 'high', metadata: { source: 'docs' }, similarity: 0.9 }, + { id: 'equal', content: 'equal', metadata: null, similarity: 0.5 }, + ], + error: null, + }) + vi.doMock('@supabase/supabase-js', () => ({ createClient: vi.fn(() => fake.client) })) + + const { supabaseVectorStore } = await import('../src/vector/supabase') + const store = supabaseVectorStore({ url: 'https://x.supabase.co', serviceRoleKey: 'k' }) + const results = await store.search([0.1, 0.2], { + topK: 2, + threshold: 0.5, + filter: { source: 'docs' }, + }) + + expect(fake.rpc).toHaveBeenCalledWith('match_agentskit_vectors', { + query_embedding: [0.1, 0.2], + match_count: 2, + match_threshold: 0.5, + filter: { source: 'docs' }, + }) + expect(results).toEqual([ + { id: 'high', content: 'high', metadata: { source: 'docs' }, score: 0.9 }, + ]) + }) + + it('supports custom table and RPC names', async () => { + const fake = makeFakeSupabaseClient() + vi.doMock('@supabase/supabase-js', () => ({ createClient: vi.fn(() => fake.client) })) + + const { supabaseVectorStore } = await import('../src/vector/supabase') + const store = supabaseVectorStore({ + url: 'https://x.supabase.co', + serviceRoleKey: 'k', + table: 'project_vectors', + matchFunction: 'match_project_vectors', + }) -describe('supabaseVectorStore (injected fake SDK)', () => { - it('store + search round-trip via pgvector runner', async () => { - const { rpc, rows } = makeFakeSupabaseClient() - // Prime the fake rows so search returns something - rows.push({ id: 'doc-1', content: 'hello supabase', metadata: null, distance: 0.1 }) + await store.store([{ id: 'a', content: 'x', embedding: [1] }]) + await store.search([1]) - const fakeClient = { rpc } - const fakeSdk = { createClient: vi.fn(() => fakeClient) } - vi.doMock('@supabase/supabase-js', () => fakeSdk) + expect(fake.from).toHaveBeenCalledWith('project_vectors') + expect(fake.rpc).toHaveBeenCalledWith('match_project_vectors', expect.any(Object)) + }) + + it('does not load the optional SDK for empty mutations', async () => { + const fake = makeFakeSupabaseClient() + const createClient = vi.fn(() => fake.client) + vi.doMock('@supabase/supabase-js', () => ({ createClient })) const { supabaseVectorStore } = await import('../src/vector/supabase') const store = supabaseVectorStore({ url: 'https://x.supabase.co', serviceRoleKey: 'k' }) - // store triggers an INSERT via RPC - await store.store([{ id: 'doc-1', content: 'hello supabase', embedding: [0.1, 0.2] }]) - expect(rpc).toHaveBeenCalled() + await store.store([]) + await store.delete!([]) - // search triggers a SELECT via RPC - const results = await store.search([0.1, 0.2]) - expect(results[0]).toMatchObject({ id: 'doc-1', content: 'hello supabase' }) - expect(results[0]!.score).toBeCloseTo(0.9, 5) + expect(createClient).not.toHaveBeenCalled() }) - it('delete delegates to pgvector runner DELETE', async () => { - const { rpc } = makeFakeSupabaseClient() - const fakeClient = { rpc } - const fakeSdk = { createClient: vi.fn(() => fakeClient) } - vi.doMock('@supabase/supabase-js', () => fakeSdk) + it.each([ + ['store', { data: null, error: { message: 'write denied' } }], + ['delete', { data: null, error: { message: 'delete denied' } }], + ])('propagates %s errors as MemoryError', async (operation, response) => { + const fake = makeFakeSupabaseClient() + if (operation === 'store') fake.upsert.mockResolvedValue(response) + else fake.inFilter.mockResolvedValue(response) + vi.doMock('@supabase/supabase-js', () => ({ createClient: vi.fn(() => fake.client) })) const { supabaseVectorStore } = await import('../src/vector/supabase') const store = supabaseVectorStore({ url: 'https://x.supabase.co', serviceRoleKey: 'k' }) - await store.delete!(['id1', 'id2']) - // Should have called RPC (DELETE query) - expect(rpc).toHaveBeenCalled() + const action = operation === 'store' + ? store.store([{ id: 'a', content: 'x', embedding: [1] }]) + : store.delete!(['a']) + + await expect(action).rejects.toThrow(/supabase (store|delete): .* denied/) }) - it('throws MemoryError when RPC returns error', async () => { - const fakeClient = { - rpc: vi.fn().mockResolvedValue({ data: null, error: { message: 'permission denied' } }), - } - const fakeSdk = { createClient: vi.fn(() => fakeClient) } - vi.doMock('@supabase/supabase-js', () => fakeSdk) + it('propagates search RPC errors as MemoryError', async () => { + const fake = makeFakeSupabaseClient() + fake.rpc.mockResolvedValue({ data: null, error: { message: 'function denied' } }) + vi.doMock('@supabase/supabase-js', () => ({ createClient: vi.fn(() => fake.client) })) const { supabaseVectorStore } = await import('../src/vector/supabase') const store = supabaseVectorStore({ url: 'https://x.supabase.co', serviceRoleKey: 'k' }) - await expect(store.store([{ id: 'a', content: 'x', embedding: [1] }])).rejects.toThrow( - /supabase: permission denied/, - ) + + await expect(store.search([1])).rejects.toThrow(/supabase search: function denied/) }) - it('createClient is called with url + serviceRoleKey', async () => { - const { rpc } = makeFakeSupabaseClient() - const fakeClient = { rpc } - const fakeSdk = { createClient: vi.fn(() => fakeClient) } - vi.doMock('@supabase/supabase-js', () => fakeSdk) + it('creates the client lazily with the configured credentials', async () => { + const fake = makeFakeSupabaseClient() + const createClient = vi.fn(() => fake.client) + vi.doMock('@supabase/supabase-js', () => ({ createClient })) const { supabaseVectorStore } = await import('../src/vector/supabase') const store = supabaseVectorStore({ - url: 'https://proj.supabase.co', - serviceRoleKey: 'my-secret-key', + url: 'https://project.supabase.co', + serviceRoleKey: 'server-only-key', }) + + expect(createClient).not.toHaveBeenCalled() await store.search([1, 2]) - expect(fakeSdk.createClient).toHaveBeenCalledWith('https://proj.supabase.co', 'my-secret-key') + expect(createClient).toHaveBeenCalledOnce() + expect(createClient).toHaveBeenCalledWith('https://project.supabase.co', 'server-only-key') }) }) diff --git a/packages/memory/tests/supabase.test.ts b/packages/memory/tests/supabase.test.ts index 083c0667..41c16eac 100644 --- a/packages/memory/tests/supabase.test.ts +++ b/packages/memory/tests/supabase.test.ts @@ -4,6 +4,8 @@ import { supabaseVectorStore } from '../src/vector/supabase' describe('supabaseVectorStore', () => { it('throws a clear hint when @supabase/supabase-js is missing', async () => { const store = supabaseVectorStore({ url: 'https://x.supabase.co', serviceRoleKey: 'k' }) - await expect(store.store([])).rejects.toThrow(/@supabase\/supabase-js/) + await expect(store.store([{ id: 'a', content: 'x', embedding: [1] }])).rejects.toThrow( + /@supabase\/supabase-js/, + ) }) })