feat(vector-stores): add S3 Vectors provider to TypeScript OSS SDK (#5822)

Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
This commit is contained in:
Rod Boev
2026-07-06 20:08:13 +05:30
committed by GitHub
co-authored by kartik-mem0
parent 580d390e4d
commit 6dc4606dcf
11 changed files with 2082 additions and 8 deletions
+1
View File
@@ -28,4 +28,5 @@ export * from "./vector_stores/langchain";
export * from "./vector_stores/vectorize";
export * from "./vector_stores/azure_ai_search";
export * from "./vector_stores/pgvector";
export * from "./vector_stores/s3_vectors";
export * from "./utils/factory";
+4
View File
@@ -38,6 +38,7 @@ import { LangchainEmbedder } from "../embeddings/langchain";
import { LangchainVectorStore } from "../vector_stores/langchain";
import { AzureAISearch } from "../vector_stores/azure_ai_search";
import { PGVector } from "../vector_stores/pgvector";
import { S3Vectors } from "../vector_stores/s3_vectors";
export class EmbedderFactory {
static create(provider: string, config: EmbeddingConfig): Embedder {
@@ -116,6 +117,9 @@ export class VectorStoreFactory {
return new AzureAISearch(config as any);
case "pgvector":
return new PGVector(config as any);
case "s3-vectors":
case "s3_vectors":
return new S3Vectors(config as any);
default:
throw new Error(`Unsupported vector store provider: ${provider}`);
}
@@ -0,0 +1,841 @@
import {
CreateIndexCommand,
CreateVectorBucketCommand,
DeleteIndexCommand,
DeleteVectorsCommand,
GetIndexCommand,
GetVectorBucketCommand,
GetVectorsCommand,
ListVectorsCommand,
PutVectorsCommand,
QueryVectorsCommand,
S3VectorsClient,
type DistanceMetric,
type GetOutputVector,
type ListOutputVector,
type QueryOutputVector,
type S3VectorsClientConfig,
type VectorData,
} from "@aws-sdk/client-s3vectors";
import { VectorStore } from "./base";
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
const MIGRATION_INDEX_NAME = "memory_migrations";
const MIGRATION_VECTOR_KEY = "mem0-user";
const DEFAULT_PAGE_SIZE = 500;
const ALWAYS_FALSE_FILTER_KEY = "$alwaysFalse";
type S3Filter = Record<string, any>;
interface S3VectorsConfig extends VectorStoreConfig {
vectorBucketName: string;
collectionName: string;
embeddingModelDims?: number;
dimension?: number;
distanceMetric?: DistanceMetric | "cosine" | "euclidean";
region?: string;
regionName?: string;
client?: S3VectorsClientLike;
clientConfig?: S3VectorsClientConfig;
}
interface S3VectorsClientLike {
send(command: any): Promise<any>;
}
export class S3Vectors implements VectorStore {
private readonly client: S3VectorsClientLike;
private readonly vectorBucketName: string;
private readonly collectionName: string;
private readonly dimension: number;
private readonly distanceMetric: DistanceMetric | "cosine" | "euclidean";
private _initPromise?: Promise<void>;
private cachedUserId?: string;
constructor(config: S3VectorsConfig) {
if (!config.vectorBucketName) {
throw new Error("vectorBucketName is required");
}
if (!config.collectionName) {
throw new Error("collectionName is required");
}
const dimension = config.embeddingModelDims ?? config.dimension;
if (!dimension || dimension < 1) {
throw new Error("embeddingModelDims or dimension is required");
}
this.vectorBucketName = config.vectorBucketName;
this.collectionName = config.collectionName;
this.dimension = dimension;
this.distanceMetric = config.distanceMetric || "cosine";
this.client =
config.client ||
new S3VectorsClient({
...(config.clientConfig || {}),
...(config.region || config.regionName
? { region: config.region || config.regionName }
: {}),
});
void this.initialize().catch(console.error);
}
async initialize(): Promise<void> {
if (!this._initPromise) {
this._initPromise = this._doInitialize();
}
return this._initPromise;
}
private async _doInitialize(): Promise<void> {
await this.ensureBucketExists();
await this.ensureIndexExists(
this.collectionName,
this.dimension,
this.distanceMetric,
);
}
async insert(
vectors: number[][],
ids: string[],
payloads: Record<string, any>[],
): Promise<void> {
await this.initialize();
this.assertBatchDimensions(vectors, "Insert");
await this.client.send(
new PutVectorsCommand({
vectorBucketName: this.vectorBucketName,
indexName: this.collectionName,
vectors: vectors.map((vector, index) => ({
key: ids[index],
data: this.toVectorData(vector),
metadata: payloads[index] || {},
})),
}),
);
}
async keywordSearch(): Promise<null> {
return null;
}
async search(
query: number[],
topK: number = 5,
filters?: SearchFilters,
): Promise<VectorStoreResult[]> {
await this.initialize();
this.assertVectorDimension(query, "Query");
const filter = this.convertFilters(filters);
if (filter && this.isAlwaysFalseFilter(filter)) {
return [];
}
const response = await this.client.send(
new QueryVectorsCommand({
vectorBucketName: this.vectorBucketName,
indexName: this.collectionName,
queryVector: this.toVectorData(query),
topK,
filter,
returnMetadata: true,
returnDistance: true,
}),
);
const responseDistanceMetric =
response.distanceMetric || this.distanceMetric;
return (response.vectors || [])
.map((vector: QueryOutputVector) =>
this.normalizeQueryVector(vector, responseDistanceMetric),
)
.slice(0, topK);
}
async get(vectorId: string): Promise<VectorStoreResult | null> {
await this.initialize();
try {
const response = await this.client.send(
new GetVectorsCommand({
vectorBucketName: this.vectorBucketName,
indexName: this.collectionName,
keys: [vectorId],
returnMetadata: true,
}),
);
const vector = response.vectors?.[0];
if (!vector) {
return null;
}
return this.normalizeStoredVector(vector);
} catch (error) {
if (this.isNotFound(error)) {
return null;
}
throw error;
}
}
async update(
vectorId: string,
vector: number[],
payload: Record<string, any>,
): Promise<void> {
await this.initialize();
let nextVector = vector;
let nextPayload = payload || {};
if (vector.length === 0 || !payload) {
const existing = await this.fetchStoredVector(vectorId);
if (!existing) {
throw new Error(`Vector with ID ${vectorId} not found`);
}
nextVector = vector.length > 0 ? vector : existing.vector;
nextPayload = payload || existing.payload;
}
this.assertVectorDimension(nextVector, "Vector");
await this.client.send(
new PutVectorsCommand({
vectorBucketName: this.vectorBucketName,
indexName: this.collectionName,
vectors: [
{
key: vectorId,
data: this.toVectorData(nextVector),
metadata: nextPayload,
},
],
}),
);
}
async delete(vectorId: string): Promise<void> {
await this.initialize();
await this.client.send(
new DeleteVectorsCommand({
vectorBucketName: this.vectorBucketName,
indexName: this.collectionName,
keys: [vectorId],
}),
);
}
async deleteCol(): Promise<void> {
await this.initialize();
try {
await this.client.send(
new DeleteIndexCommand({
vectorBucketName: this.vectorBucketName,
indexName: this.collectionName,
}),
);
} catch (error) {
if (this.isNotFound(error)) {
return;
}
throw error;
}
}
async list(
filters?: SearchFilters,
topK: number = 100,
): Promise<[VectorStoreResult[], number]> {
await this.initialize();
const filter = this.convertFilters(filters);
const results: VectorStoreResult[] = [];
let nextToken: string | undefined;
// Stop paginating once we have topK matches. Both callers of list()
// discard the count, so scanning the whole index just to total every
// match is wasted round-trips (O(index size) on getAll/deleteAll).
// Return the page length like qdrant does.
do {
const response = await this.client.send(
new ListVectorsCommand({
vectorBucketName: this.vectorBucketName,
indexName: this.collectionName,
maxResults: DEFAULT_PAGE_SIZE,
nextToken,
returnMetadata: true,
}),
);
for (const vector of response.vectors || []) {
if (results.length >= topK) {
break;
}
const normalized = this.normalizeListedVector(vector);
if (filter && !this.matchesFilter(normalized.payload, filter)) {
continue;
}
results.push(normalized);
}
nextToken = response.nextToken;
} while (nextToken && results.length < topK);
return [results, results.length];
}
async getUserId(): Promise<string> {
await this.initialize();
if (this.cachedUserId) {
return this.cachedUserId;
}
await this.ensureMigrationIndex();
const response = await this.client.send(
new GetVectorsCommand({
vectorBucketName: this.vectorBucketName,
indexName: MIGRATION_INDEX_NAME,
keys: [MIGRATION_VECTOR_KEY],
returnMetadata: true,
}),
);
const metadata = this.normalizeMetadata(response.vectors?.[0]?.metadata);
const userId = metadata.user_id;
if (typeof userId === "string" && userId.length > 0) {
this.cachedUserId = userId;
return userId;
}
const randomUserId =
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
await this.setUserId(randomUserId);
return randomUserId;
}
async setUserId(userId: string): Promise<void> {
await this.initialize();
await this.ensureMigrationIndex();
await this.client.send(
new PutVectorsCommand({
vectorBucketName: this.vectorBucketName,
indexName: MIGRATION_INDEX_NAME,
vectors: [
{
key: MIGRATION_VECTOR_KEY,
data: this.toVectorData([0]),
metadata: { user_id: userId },
},
],
}),
);
this.cachedUserId = userId;
}
private async ensureBucketExists(): Promise<void> {
try {
await this.client.send(
new GetVectorBucketCommand({
vectorBucketName: this.vectorBucketName,
}),
);
} catch (error) {
if (!this.isNotFound(error)) {
throw error;
}
try {
await this.client.send(
new CreateVectorBucketCommand({
vectorBucketName: this.vectorBucketName,
}),
);
} catch (createError) {
if (!this.isConflict(createError)) {
throw createError;
}
}
}
}
private async ensureIndexExists(
indexName: string,
dimension: number,
distanceMetric: DistanceMetric | "cosine" | "euclidean",
): Promise<void> {
try {
await this.client.send(
new GetIndexCommand({
vectorBucketName: this.vectorBucketName,
indexName,
}),
);
} catch (error) {
if (!this.isNotFound(error)) {
throw error;
}
try {
await this.client.send(
new CreateIndexCommand({
vectorBucketName: this.vectorBucketName,
indexName,
dataType: "float32",
dimension,
distanceMetric,
}),
);
} catch (createError) {
if (!this.isConflict(createError)) {
throw createError;
}
}
}
}
private async ensureMigrationIndex(): Promise<void> {
await this.ensureIndexExists(MIGRATION_INDEX_NAME, 1, "euclidean");
}
private async fetchStoredVector(
vectorId: string,
): Promise<{ vector: number[]; payload: Record<string, any> } | null> {
const response = await this.client.send(
new GetVectorsCommand({
vectorBucketName: this.vectorBucketName,
indexName: this.collectionName,
keys: [vectorId],
returnData: true,
returnMetadata: true,
}),
);
const vector = response.vectors?.[0];
const data = this.fromVectorData(vector?.data);
if (!vector || !data) {
return null;
}
return {
vector: data,
payload: this.normalizeMetadata(vector.metadata),
};
}
private convertFilters(filters?: SearchFilters): S3Filter | undefined {
if (!filters || Object.keys(filters).length === 0) {
return undefined;
}
const normalized = this.convertFilterNode(filters);
return this.isNoOpFilter(normalized) ? undefined : normalized;
}
private convertFilterNode(node: Record<string, any>): S3Filter {
const clauses: S3Filter[] = [];
for (const [key, value] of Object.entries(node)) {
if (key === "$and" || key === "$or") {
if (!Array.isArray(value) || value.length === 0) {
throw new Error(`${key} filter requires a non-empty list`);
}
const normalizedEntries = value.map((entry) =>
this.convertFilterNode(entry),
);
const entries = normalizedEntries.filter(
(entry) => !this.isNoOpFilter(entry),
);
if (key === "$and") {
if (entries.some((entry) => this.isAlwaysFalseFilter(entry))) {
return { [ALWAYS_FALSE_FILTER_KEY]: true };
}
if (entries.length === 0) {
continue;
}
} else {
if (normalizedEntries.some((entry) => this.isNoOpFilter(entry))) {
continue;
}
const viableEntries = entries.filter(
(entry) => !this.isAlwaysFalseFilter(entry),
);
if (viableEntries.length === 0) {
return { [ALWAYS_FALSE_FILTER_KEY]: true };
}
clauses.push(
viableEntries.length === 1
? viableEntries[0]
: { [key]: viableEntries },
);
continue;
}
clauses.push(entries.length === 1 ? entries[0] : { [key]: entries });
continue;
}
if (key === "$not") {
if (!Array.isArray(value) || value.length === 0) {
throw new Error("$not filter requires a non-empty list");
}
const normalizedEntries = value.map((entry) =>
this.convertFilterNode(entry),
);
if (normalizedEntries.some((entry) => this.isNoOpFilter(entry))) {
return { [ALWAYS_FALSE_FILTER_KEY]: true };
}
const negated = normalizedEntries.filter(
(entry) => !this.isAlwaysFalseFilter(entry),
);
if (negated.length === 0) {
continue;
}
const negatedFilters = negated.map((entry) => this.negateFilter(entry));
clauses.push(
negatedFilters.length === 1
? negatedFilters[0]
: { $and: negatedFilters },
);
continue;
}
const fieldFilter = this.convertFieldFilter(key, value);
if (this.isAlwaysFalseFilter(fieldFilter)) {
return fieldFilter;
}
if (this.isNoOpFilter(fieldFilter)) {
continue;
}
clauses.push(fieldFilter);
}
if (clauses.some((entry) => this.isAlwaysFalseFilter(entry))) {
return { [ALWAYS_FALSE_FILTER_KEY]: true };
}
if (clauses.length === 1) {
return clauses[0];
}
if (clauses.length === 0) {
return {};
}
return { $and: clauses };
}
private convertFieldFilter(key: string, value: any): S3Filter {
if (value === "*") {
return { [key]: { $exists: true } };
}
if (Array.isArray(value)) {
if (value.length === 0) {
return { [ALWAYS_FALSE_FILTER_KEY]: true };
}
return { [key]: { $in: value } };
}
if (typeof value !== "object" || value === null) {
return { [key]: { $eq: value } };
}
const operators: Record<string, any> = {};
for (const [operator, operand] of Object.entries(value)) {
switch (operator) {
case "eq":
operators.$eq = operand;
break;
case "ne":
operators.$ne = operand;
break;
case "gt":
operators.$gt = operand;
break;
case "gte":
operators.$gte = operand;
break;
case "lt":
operators.$lt = operand;
break;
case "lte":
operators.$lte = operand;
break;
case "in":
if (!Array.isArray(operand) || operand.length === 0) {
return { [ALWAYS_FALSE_FILTER_KEY]: true };
}
operators.$in = operand;
break;
case "nin":
if (!Array.isArray(operand) || operand.length === 0) {
break;
}
operators.$nin = operand;
break;
case "contains":
case "icontains":
case "startsWith":
throw new Error(
`S3 Vectors does not support '${operator}' metadata filters.`,
);
default:
throw new Error(
`Unsupported S3 Vectors filter operator: ${operator}`,
);
}
}
if (Object.keys(operators).length === 0) {
return {};
}
return { [key]: operators };
}
private negateFilter(filter: S3Filter): S3Filter {
if (Array.isArray(filter.$and)) {
return {
$or: filter.$and.map((entry: S3Filter) => this.negateFilter(entry)),
};
}
if (Array.isArray(filter.$or)) {
return {
$and: filter.$or.map((entry: S3Filter) => this.negateFilter(entry)),
};
}
const entries = Object.entries(filter);
if (entries.length !== 1) {
throw new Error("S3 Vectors cannot negate this filter shape.");
}
const [key, rawValue] = entries[0];
if (key.startsWith("$")) {
throw new Error("S3 Vectors cannot negate this filter shape.");
}
const value =
typeof rawValue === "object" && rawValue !== null
? rawValue
: { $eq: rawValue };
const negated: Record<string, any> = {};
for (const [operator, operand] of Object.entries(value)) {
switch (operator) {
case "$eq":
negated.$ne = operand;
break;
case "$ne":
negated.$eq = operand;
break;
case "$gt":
negated.$lte = operand;
break;
case "$gte":
negated.$lt = operand;
break;
case "$lt":
negated.$gte = operand;
break;
case "$lte":
negated.$gt = operand;
break;
case "$in":
negated.$nin = operand;
break;
case "$nin":
negated.$in = operand;
break;
case "$exists":
negated.$exists = !operand;
break;
default:
throw new Error("S3 Vectors cannot negate this filter shape.");
}
}
return { [key]: negated };
}
private matchesFilter(
metadata: Record<string, any>,
filter: S3Filter,
): boolean {
if (this.isAlwaysFalseFilter(filter)) {
return false;
}
if (Array.isArray(filter.$and)) {
return filter.$and.every((entry: S3Filter) =>
this.matchesFilter(metadata, entry),
);
}
if (Array.isArray(filter.$or)) {
return filter.$or.some((entry: S3Filter) =>
this.matchesFilter(metadata, entry),
);
}
return Object.entries(filter).every(([key, rawValue]) =>
this.matchesFieldFilter(metadata[key], rawValue),
);
}
private matchesFieldFilter(metadataValue: any, rawValue: any): boolean {
const value =
typeof rawValue === "object" && rawValue !== null
? rawValue
: { $eq: rawValue };
return Object.entries(value).every(([operator, operand]) =>
this.evaluateOperator(metadataValue, operator, operand),
);
}
private evaluateOperator(
metadataValue: any,
operator: string,
operand: any,
): boolean {
switch (operator) {
case "$eq":
if (Array.isArray(metadataValue)) {
return metadataValue.some((entry) => entry === operand);
}
return metadataValue === operand;
case "$ne":
if (Array.isArray(metadataValue)) {
return !metadataValue.some((entry) => entry === operand);
}
return metadataValue !== operand;
case "$gt":
return typeof metadataValue === "number" && metadataValue > operand;
case "$gte":
return typeof metadataValue === "number" && metadataValue >= operand;
case "$lt":
return typeof metadataValue === "number" && metadataValue < operand;
case "$lte":
return typeof metadataValue === "number" && metadataValue <= operand;
case "$in":
if (!Array.isArray(operand) || operand.length === 0) {
return false;
}
if (Array.isArray(metadataValue)) {
return metadataValue.some((entry) => operand.includes(entry));
}
return operand.includes(metadataValue);
case "$nin":
if (!Array.isArray(operand) || operand.length === 0) {
return true;
}
if (Array.isArray(metadataValue)) {
return metadataValue.every((entry) => !operand.includes(entry));
}
return !operand.includes(metadataValue);
case "$exists":
return operand
? metadataValue !== undefined
: metadataValue === undefined;
default:
throw new Error(`Unsupported S3 Vectors filter operator: ${operator}`);
}
}
private toVectorData(vector: number[]): VectorData {
return { float32: vector };
}
private fromVectorData(data?: VectorData): number[] | undefined {
if (!data || !("float32" in data) || !Array.isArray(data.float32)) {
return undefined;
}
return data.float32;
}
private normalizeMetadata(metadata: any): Record<string, any> {
if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) {
return { ...(metadata as Record<string, any>) };
}
return {};
}
private normalizeQueryVector(
vector: QueryOutputVector,
distanceMetric: DistanceMetric | "cosine" | "euclidean",
): VectorStoreResult {
return {
id: String(vector.key),
payload: this.normalizeMetadata(vector.metadata),
score: this.normalizeScore(vector.distance, distanceMetric),
};
}
private normalizeStoredVector(
vector: GetOutputVector | ListOutputVector,
): VectorStoreResult {
return {
id: String(vector.key),
payload: this.normalizeMetadata(vector.metadata),
};
}
private normalizeListedVector(vector: ListOutputVector): VectorStoreResult {
return this.normalizeStoredVector(vector);
}
private normalizeScore(
distance?: number,
distanceMetric: DistanceMetric | "cosine" | "euclidean" = this
.distanceMetric,
): number | undefined {
if (distance === undefined || distance === null) {
return undefined;
}
if (!Number.isFinite(distance)) {
return undefined;
}
if (distanceMetric === "euclidean") {
return 1 / (1 + distance);
}
return Math.max(0, Math.min(1, 1 - distance));
}
private assertVectorDimension(vector: number[], context: string): void {
if (vector.length !== this.dimension) {
throw new Error(
`${context} dimension mismatch. Expected ${this.dimension}, got ${vector.length}`,
);
}
}
private assertBatchDimensions(vectors: number[][], context: string): void {
for (const vector of vectors) {
this.assertVectorDimension(vector, context);
}
}
private isNotFound(error: any): boolean {
return error?.name === "NotFoundException";
}
private isConflict(error: any): boolean {
return error?.name === "ConflictException";
}
private isAlwaysFalseFilter(filter: S3Filter): boolean {
return filter[ALWAYS_FALSE_FILTER_KEY] === true;
}
private isNoOpFilter(filter: S3Filter): boolean {
return Object.keys(filter).length === 0;
}
}
@@ -138,6 +138,11 @@ jest.mock("../src/vector_stores/pgvector", () => ({
.fn()
.mockImplementation((config) => ({ type: "pgvector", config })),
}));
jest.mock("../src/vector_stores/s3_vectors", () => ({
S3Vectors: jest
.fn()
.mockImplementation((config) => ({ type: "s3-vectors", config })),
}));
jest.mock("../src/storage/SupabaseHistoryManager", () => ({
SupabaseHistoryManager: jest
.fn()
@@ -260,6 +265,8 @@ describe("VectorStoreFactory", () => {
["vectorize"],
["azure-ai-search"],
["pgvector"],
["s3-vectors"],
["s3_vectors"],
])("creates vector store for provider '%s'", (provider) => {
expect(() =>
VectorStoreFactory.create(provider, dummyVSConfig),
+3 -4
View File
@@ -14,7 +14,6 @@ describe("tsup.config.ts externals", () => {
beforeAll(() => {
const pkgPath = path.resolve(__dirname, "../../../package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
// Filter out @types/* packages — they are type-only and not bundled at runtime
peerDeps = Object.keys(pkg.peerDependencies || {}).filter(
(dep) => !dep.startsWith("@types/"),
);
@@ -22,16 +21,16 @@ describe("tsup.config.ts externals", () => {
const tsupConfigPath = path.resolve(__dirname, "../../../tsup.config.ts");
const tsupContent = fs.readFileSync(tsupConfigPath, "utf-8");
// Extract strings from the external array (supports double, single, and backtick quotes)
const externalMatch = tsupContent.match(
/const external\s*=\s*\[([\s\S]*?)\];/,
);
if (!externalMatch) {
throw new Error("Could not find external array in tsup.config.ts");
}
const matches = externalMatch[1].match(/["'`]([^"'`]+)["'`]/g);
externalDeps = (matches || []).map((m) => m.replace(/["'`]/g, ""));
externalDeps = (matches || []).map((value) => value.replace(/["'`]/g, ""));
});
it("should have every peerDependency in the external array", () => {
@@ -720,7 +720,358 @@ describe("AzureAISearch – backward compat with mocked client", () => {
});
// ───────────────────────────────────────────────────────────────────────────
// 6. Vectorize — mock Cloudflare client, test idempotent init
// 6. S3 Vectors — mock AWS client, test interface + init
// ───────────────────────────────────────────────────────────────────────────
describe("S3 Vectors – backward compat with mocked client", () => {
function createMockS3VectorsClient(options?: {
queryDistance?: number;
queryDistanceMetric?: "cosine" | "euclidean";
}) {
const buckets = new Set<string>();
const indexes = new Map<
string,
{
dimension: number;
distanceMetric: string;
}
>();
const vectors = new Map<
string,
{
key: string;
data: { float32: number[] };
metadata: Record<string, any>;
}
>();
const vectorMapKey = (indexName: string, key: string) =>
`${indexName}:${key}`;
return {
send: jest.fn().mockImplementation(async (command: any) => {
const name = command.constructor.name;
const input = command.input;
switch (name) {
case "GetVectorBucketCommand":
if (!buckets.has(input.vectorBucketName)) {
const error: any = new Error("Bucket not found");
error.name = "NotFoundException";
throw error;
}
return {};
case "CreateVectorBucketCommand":
if (buckets.has(input.vectorBucketName)) {
const error: any = new Error("Bucket exists");
error.name = "ConflictException";
throw error;
}
buckets.add(input.vectorBucketName);
return {};
case "GetIndexCommand":
if (!indexes.has(input.indexName)) {
const error: any = new Error("Index not found");
error.name = "NotFoundException";
throw error;
}
return {};
case "CreateIndexCommand":
if (indexes.has(input.indexName)) {
const error: any = new Error("Index exists");
error.name = "ConflictException";
throw error;
}
indexes.set(input.indexName, {
dimension: input.dimension,
distanceMetric: input.distanceMetric,
});
return {};
case "PutVectorsCommand":
for (const vector of input.vectors || []) {
vectors.set(vectorMapKey(input.indexName, vector.key), {
key: vector.key,
data: vector.data,
metadata: vector.metadata || {},
});
}
return {};
case "QueryVectorsCommand":
return {
distanceMetric: options?.queryDistanceMetric ?? "cosine",
vectors: [
{
key: "doc-1",
metadata: { user_id: "u1", topic: "alpha" },
distance: options?.queryDistance ?? 0.25,
},
],
};
case "GetVectorsCommand":
return {
vectors: (input.keys || [])
.map((key: string) =>
vectors.get(vectorMapKey(input.indexName, key)),
)
.filter(Boolean),
};
case "DeleteVectorsCommand":
for (const key of input.keys || []) {
vectors.delete(vectorMapKey(input.indexName, key));
}
return {};
case "DeleteIndexCommand":
indexes.delete(input.indexName);
for (const key of Array.from(vectors.keys())) {
if (key.startsWith(`${input.indexName}:`)) {
vectors.delete(key);
}
}
return {};
case "ListVectorsCommand":
return {
vectors: Array.from(vectors.values())
.filter((entry) =>
vectorMapKey(input.indexName, entry.key).startsWith(
`${input.indexName}:`,
),
)
.map((entry) => ({
key: entry.key,
metadata: entry.metadata,
})),
};
default:
throw new Error(`Unexpected S3Vectors command: ${name}`);
}
}),
};
}
function findCommandInput(
client: { send: jest.Mock },
commandName: string,
): Record<string, any> | undefined {
const match = client.send.mock.calls.find(
([command]) => command.constructor.name === commandName,
);
return match?.[0]?.input;
}
it("implements full VectorStore interface", () => {
const { S3Vectors } = require("../src/vector_stores/s3_vectors");
const store = new S3Vectors({
client: createMockS3VectorsClient(),
vectorBucketName: "test-bucket",
collectionName: "test-index",
embeddingModelDims: 3,
});
expect(typeof store.insert).toBe("function");
expect(typeof store.search).toBe("function");
expect(typeof store.get).toBe("function");
expect(typeof store.update).toBe("function");
expect(typeof store.delete).toBe("function");
expect(typeof store.deleteCol).toBe("function");
expect(typeof store.list).toBe("function");
expect(typeof store.getUserId).toBe("function");
expect(typeof store.setUserId).toBe("function");
expect(typeof store.initialize).toBe("function");
});
it("initialize() is idempotent (same promise returned)", async () => {
const { S3Vectors } = require("../src/vector_stores/s3_vectors");
const mockClient = createMockS3VectorsClient();
const store = new S3Vectors({
client: mockClient,
vectorBucketName: "test-bucket",
collectionName: "test-index",
embeddingModelDims: 3,
});
const p1 = store.initialize();
const p2 = store.initialize();
const p3 = store.initialize();
await Promise.all([p1, p2, p3]);
expect(
mockClient.send.mock.calls.filter(
([command]) => command.constructor.name === "CreateVectorBucketCommand",
),
).toHaveLength(1);
expect(
mockClient.send.mock.calls.filter(
([command]) => command.constructor.name === "CreateIndexCommand",
),
).toHaveLength(1);
expect(
mockClient.send.mock.calls.filter(
([command]) => command.constructor.name === "GetVectorBucketCommand",
),
).toHaveLength(1);
expect(
mockClient.send.mock.calls.filter(
([command]) => command.constructor.name === "GetIndexCommand",
),
).toHaveLength(1);
});
it("shapes S3 write requests and normalizes search results", async () => {
const { S3Vectors } = require("../src/vector_stores/s3_vectors");
const mockClient = createMockS3VectorsClient();
const store = new S3Vectors({
client: mockClient,
vectorBucketName: "test-bucket",
collectionName: "test-index",
embeddingModelDims: 3,
});
await store.initialize();
await store.insert(
[[0.1, 0.2, 0.3]],
["doc-1"],
[{ user_id: "u1", topic: "alpha" }],
);
const putInput = findCommandInput(mockClient, "PutVectorsCommand");
expect(putInput).toMatchObject({
vectorBucketName: "test-bucket",
indexName: "test-index",
vectors: [
{
key: "doc-1",
data: { float32: [0.1, 0.2, 0.3] },
metadata: { user_id: "u1", topic: "alpha" },
},
],
});
const results = await store.search([0.1, 0.2, 0.3], 5, { user_id: "u1" });
const queryInput = findCommandInput(mockClient, "QueryVectorsCommand");
expect(queryInput).toMatchObject({
vectorBucketName: "test-bucket",
indexName: "test-index",
queryVector: { float32: [0.1, 0.2, 0.3] },
topK: 5,
filter: { user_id: { $eq: "u1" } },
returnMetadata: true,
returnDistance: true,
});
expect(results).toEqual([
{
id: "doc-1",
payload: { user_id: "u1", topic: "alpha" },
score: 0.75,
},
]);
});
it("normalizes euclidean distances without collapsing scores to zero", async () => {
const { S3Vectors } = require("../src/vector_stores/s3_vectors");
const store = new S3Vectors({
client: createMockS3VectorsClient({
queryDistance: 1.5,
queryDistanceMetric: "euclidean",
}),
vectorBucketName: "test-bucket",
collectionName: "test-index",
embeddingModelDims: 3,
distanceMetric: "cosine",
});
const [result] = await store.search([0.1, 0.2, 0.3], 1);
expect(result).toEqual({
id: "doc-1",
payload: { user_id: "u1", topic: "alpha" },
score: 0.4,
});
});
it("normalizes empty in and nin operands before search hits AWS", async () => {
const { S3Vectors } = require("../src/vector_stores/s3_vectors");
const mockClient = createMockS3VectorsClient();
const store = new S3Vectors({
client: mockClient,
vectorBucketName: "test-bucket",
collectionName: "test-index",
embeddingModelDims: 3,
});
const baselineQueryCalls = mockClient.send.mock.calls.filter(
([command]) => command.constructor.name === "QueryVectorsCommand",
).length;
const emptyInResults = await store.search([0.1, 0.2, 0.3], 5, {
topic: { in: [] },
});
expect(emptyInResults).toEqual([]);
expect(
mockClient.send.mock.calls.filter(
([command]) => command.constructor.name === "QueryVectorsCommand",
),
).toHaveLength(baselineQueryCalls);
await store.search([0.1, 0.2, 0.3], 5, {
topic: { nin: [] },
});
const queryInput = findCommandInput(mockClient, "QueryVectorsCommand");
expect(queryInput).toMatchObject({
vectorBucketName: "test-bucket",
indexName: "test-index",
queryVector: { float32: [0.1, 0.2, 0.3] },
topK: 5,
returnMetadata: true,
returnDistance: true,
});
expect(queryInput.filter).toBeUndefined();
});
it("applies client-side list filters, including empty $nin operands", async () => {
const { S3Vectors } = require("../src/vector_stores/s3_vectors");
const mockClient = createMockS3VectorsClient();
const store = new S3Vectors({
client: mockClient,
vectorBucketName: "test-bucket",
collectionName: "test-index",
embeddingModelDims: 3,
});
await store.initialize();
await store.insert(
[
[0.1, 0.2, 0.3],
[0.3, 0.2, 0.1],
],
["doc-1", "doc-2"],
[
{ user_id: "u1", topic: "alpha", tags: ["keep"] },
{ user_id: "u2", topic: "beta", tags: ["skip"] },
],
);
const [allRows, allCount] = await store.list({ topic: { nin: [] } }, 10);
expect(allCount).toBe(2);
expect(allRows.map((row) => row.id)).toEqual(["doc-1", "doc-2"]);
const [filteredRows, filteredCount] = await store.list(
{
$and: [{ topic: { nin: ["beta"] } }, { tags: { in: ["keep"] } }],
},
10,
);
expect(filteredCount).toBe(1);
expect(filteredRows).toEqual([
{
id: "doc-1",
payload: { user_id: "u1", topic: "alpha", tags: ["keep"] },
},
]);
});
});
// ───────────────────────────────────────────────────────────────────────────
// 7. Vectorize — mock Cloudflare client, test idempotent init
// ───────────────────────────────────────────────────────────────────────────
describe("Vectorize – backward compat with mocked client", () => {
let VectorizeDB: any;