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
12,668 changes: 10,374 additions & 2,294 deletions bun.lock

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@
"pg": "^8.21.0",
"playwright": "^1.60.0",
"@electric-sql/pglite": "^0.5.2",
"@supabase/supabase-js": "^2.108.1",
"@electric-sql/pglite-pgvector": "^0.0.3",
"@supabase/supabase-js": "^2.107.0",
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
"@sqliteai/sqlite-vector": "^1.0.0",
"better-sqlite3": "^12.10.0"
Expand Down Expand Up @@ -135,4 +136,4 @@
],
"{packages,providers,examples}/*/src/**/*.json": "prettier --write"
}
}
}
1 change: 1 addition & 0 deletions packages/test/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@aws-sdk/client-sqs": "^3.1068.0",
"@cloudflare/workers-types": "^4.20260612.1",
"@electric-sql/pglite": "catalog:",
"@electric-sql/pglite-pgvector": "catalog:",
"@modelcontextprotocol/sdk": "catalog:",
"@sqliteai/sqlite-vector": "catalog:",
"@supabase/supabase-js": "catalog:",
Expand Down
40 changes: 40 additions & 0 deletions packages/test/src/test/helpers/SupabaseMockClient.pgvector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/

import { afterAll, describe, expect, it } from "vitest";
import { createSupabaseMockClient } from "./SupabaseMockClient";

describe("SupabaseMockClient pgvector + match_* RPC", () => {
const client = createSupabaseMockClient();

afterAll(async () => {
await client.close();
});

it("supports the pgvector extension and routes arbitrary RPC functions positionally", async () => {
const setup = await client.rpc("exec_sql", {
query: `
create extension if not exists vector;
create table v(id text primary key, vec vector(3));
insert into v(id, vec) values ('a', '[1,0,0]'), ('b', '[0,1,0]');
create function match_v(q vector(3), n int)
returns table(id text, score float)
language sql stable
as $$
select v.id, (1 - (v.vec <=> q)) as score
from v
order by v.vec <=> q
limit n
$$;
`,
});
expect(setup.error).toBeNull();

const { data, error } = await client.rpc("match_v", { q: "[1,0,0]", n: 5 });
expect(error).toBeNull();
expect(data?.[0]?.id).toBe("a");
});
});
56 changes: 34 additions & 22 deletions packages/test/src/test/helpers/SupabaseMockClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { PGlite } from "@electric-sql/pglite";
import { vector } from "@electric-sql/pglite-pgvector";
import type { SupabaseClient } from "@supabase/supabase-js";
import { setLogger } from "@workglow/util";

Expand Down Expand Up @@ -167,7 +168,7 @@ function translatePostgrestFilter(filter: string): string {
* This provides a real PostgreSQL database for testing without needing a Supabase instance.
*/
export function createSupabaseMockClient(): IClosableSupabaseClient {
const pglite = new PGlite();
const pglite = new PGlite({ extensions: { vector } });
const logger = getTestingLogger();
setLogger(logger);

Expand Down Expand Up @@ -222,6 +223,26 @@ export function createSupabaseMockClient(): IClosableSupabaseClient {
// Return rows for queries with RETURNING clause, otherwise null
return { data: result.rows.length > 0 ? result.rows : null, error: null };
} catch (error: any) {
// `pglite.query` runs a single prepared statement. Migration files
// (and other setup SQL) bundle many statements; route those through
// `pglite.exec`, which supports multi-statement scripts.
if (error.message?.includes("cannot insert multiple commands")) {
try {
const results = await pglite.exec(params.query);
const last = results[results.length - 1];
return { data: last && last.rows.length > 0 ? last.rows : null, error: null };
} catch (execError: any) {
if (
execError.message?.includes("already exists") ||
execError.code === "42P07" ||
execError.code === "42710" ||
execError.code === "42P06"
) {
return { data: null, error: null };
}
return { data: null, error: execError };
}
}
// Ignore "already exists" errors for tables, types, and indexes
if (
error.message?.includes("already exists") ||
Expand All @@ -242,29 +263,20 @@ export function createSupabaseMockClient(): IClosableSupabaseClient {
}
}

// Handle calling arbitrary PostgreSQL functions
// Handle calling arbitrary PostgreSQL functions positionally. PostgREST
// resolves named arguments, but the mock maps the params object's values
// to `$1, $2, ...` in insertion order — so callers MUST pass rpc params
// in the SQL function's parameter order.
const values = params ? Object.values(params) : [];
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
try {
// Build the function call with parameters
const paramNames = params ? Object.keys(params) : [];
const paramValues = params ? Object.values(params) : [];

// Create parameterized placeholders
const placeholders = paramNames.map((_, i) => `$${i + 1}`).join(", ");

// Build the SELECT query to call the function
const query =
paramNames.length > 0
? `SELECT * FROM ${functionName}(${placeholders})`
: `SELECT * FROM ${functionName}()`;

const result = await pglite.query(query, paramValues);
const result = await pglite.query(
`SELECT * FROM "${functionName.replace(/"/g, '""')}"(${placeholders})`,
values
);
return { data: result.rows, error: null };
} catch (error: any) {
// If function doesn't exist, return appropriate error
if (error.message?.includes("does not exist")) {
return { data: null, error: { message: error.message, code: "42883" } };
}
return { data: null, error };
} catch (e: any) {
return { data: null, error: { message: e?.message ?? String(e) } };
}
},

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/
import { createClient } from "@supabase/supabase-js";
import { StorageValidationError } from "@workglow/storage";
import { SupabaseVectorStorage } from "@workglow/supabase/storage";
import type { DataPortSchemaObject } from "@workglow/util/schema";
import { TypedArraySchema } from "@workglow/util/schema";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
createSupabaseMockClient,
type IClosableSupabaseClient,
} from "../helpers/SupabaseMockClient";

// A plain vector table + its `match_<table>` RPC. The base storage knows
// nothing of scoping — the RPC takes only the metadata `p_filter`.
const SETUP_SQL = `
create extension if not exists vector;
create table vec_items (
id text primary key,
vector vector(4) not null,
metadata jsonb not null default '{}'::jsonb
);
create or replace function match_vec_items(
query_embedding vector(4),
match_count int,
score_threshold float,
p_filter jsonb
)
returns table (id text, vector vector(4), metadata jsonb, score float)
language sql stable
as $$
select c.id, c.vector, c.metadata,
(1 - (c.vector <=> query_embedding)) as score
from vec_items c
where c.metadata @> p_filter
and (score_threshold is null or (1 - (c.vector <=> query_embedding)) >= score_threshold)
order by c.vector <=> query_embedding
limit match_count
$$;
`;

const VecSchema = {
type: "object",
properties: {
id: { type: "string" },
vector: TypedArraySchema(),
metadata: { type: "object", format: "metadata", additionalProperties: true },
},
required: ["id", "vector", "metadata"],
additionalProperties: false,
} as const satisfies DataPortSchemaObject;
const VecPK = ["id"] as const;

function mk(set: Record<number, number>): Float32Array {
const arr = new Float32Array(4);
for (const [i, v] of Object.entries(set)) arr[Number(i)] = v;
return arr;
}

describe("SupabaseVectorStorage", () => {
it("binds dimensions and table name at construction", () => {
const store = new SupabaseVectorStorage(
createClient("http://localhost", "test-key"),
"vec_items",
VecSchema,
VecPK,
[],
4
);
expect(store.getVectorDimensions()).toBe(4);
expect(store.tableName).toBe("vec_items");
});

describe("put + similaritySearch against PGlite-backed mock", () => {
let client: IClosableSupabaseClient;

beforeAll(async () => {
client = createSupabaseMockClient();
const setup = await client.rpc("exec_sql", { query: SETUP_SQL });
expect(setup.error).toBeNull();
});

afterAll(async () => {
await client.close();
});

const newStore = () =>
new SupabaseVectorStorage(
client as unknown as ReturnType<typeof createClient>,
"vec_items",
VecSchema,
VecPK,
[],
4
);

it("persists vectors and ranks by cosine similarity", async () => {
const store = newStore();
await store.put({ id: "near", vector: mk({ 0: 1 }), metadata: { kind: "x" } } as never);
await store.put({ id: "far", vector: mk({ 1: 1 }), metadata: { kind: "y" } } as never);

const results = await store.similaritySearch(mk({ 0: 1 }), { topK: 10 });
expect(results.length).toBe(2);
expect((results[0] as { id: string }).id).toBe("near");
expect(results[0].score).toBeGreaterThan(results[1].score);
expect((results[0] as { vector: unknown }).vector).toBeInstanceOf(Float32Array);
expect((results[0] as { vector: Float32Array }).vector[0]).toBeCloseTo(1, 5);
});

it("filters by metadata containment", async () => {
const store = newStore();
const results = await store.similaritySearch(mk({ 0: 1 }), {
topK: 10,
filter: { kind: "x" } as never,
});
expect(results.length).toBe(1);
expect((results[0] as { id: string }).id).toBe("near");
});

it("defaults a missing metadata column to {}", async () => {
const store = newStore();
await store.put({ id: "no-meta", vector: mk({ 2: 1 }) } as never);
const stored = await store.get({ id: "no-meta" } as never);
expect((stored as { metadata: unknown }).metadata).toEqual({});
});

it("rejects unsafe metadata filter keys", async () => {
const store = newStore();
await expect(
store.similaritySearch(mk({ 0: 1 }), { filter: { "bad-key": "x" } as never })
).rejects.toBeInstanceOf(StorageValidationError);
});
});
});
Loading
Loading