From 648541607a3be61ed5c9e5dceb87837f1db86624 Mon Sep 17 00:00:00 2001 From: Whit Waldo Date: Tue, 20 Oct 2026 12:39:49 -0500 Subject: [PATCH] Adding support for a test harness for the vector store building block Signed-off-by: Whit Waldo --- README.md | 93 ++++++ src/Constants.ts | 1 + src/DaprContainer.ts | 55 ++- src/MeilisearchContainer.test.ts | 27 ++ src/MeilisearchContainer.ts | 26 +- src/VectorHarness.test.ts | 555 +++++++++++++++++++++++++++++++ src/VectorHarness.ts | 282 ++++++++++++++++ src/VectorTypes.ts | 80 +++++ src/index.ts | 2 + 9 files changed, 1109 insertions(+), 12 deletions(-) create mode 100644 src/VectorHarness.test.ts create mode 100644 src/VectorHarness.ts create mode 100644 src/VectorTypes.ts diff --git a/README.md b/README.md index 7d2d397..d614e28 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,99 @@ Optional `prefix`, `maxSize`, and `maxTotalSize` settings are passed to the in-m The store needs no external storage service; data is local to the sidecar and is lost when it stops. See `src/BinaryStoreHarness.test.ts` for configuration and integration examples. +## Dapr Vector Testing + +`VectorHarness` or `.withVector()` on `DaprContainer` provisions a `vector.meilisearch` component using +the same authenticated `MeilisearchContainer` as Search. The alpha Vector API was merged for Dapr 1.19; +the default `1.18.4` runtime does not support it. Set `DAPR_RUNTIME_VERSION` or `daprRuntimeImage` to an +image containing both the Vector API and the Meilisearch component. Not every 1.19 prerelease contains them. + +```typescript +import { VectorHarness } from "@dapr/testcontainer-node"; + +// Set DAPR_RUNTIME_VERSION to a supporting RC/development tag before running. +await using harness = await new VectorHarness().start(); +await harness.createCollection("books", 3, { metric: "DISTANCE_METRIC_COSINE" }); + +const result = await harness.upsertVectors( + "books", + [ + { + id: "guide", + values: [1, 0, 0], + payload: Buffer.from("Dapr guide"), + metadata: { category: "guide", year: 2026 }, + }, + ], + { + options: { + mode: "INDEXING_MODE_WAIT_FOR_COMPLETION", + waitTimeout: "10s", + onWaitTimeout: "INDEXING_WAIT_TIMEOUT_ACTION_FAIL_REQUEST", + }, + } +); +if (result.failedItems.length) { + throw new Error(JSON.stringify(result.failedItems)); +} + +const nearest = await harness.queryVectors("books", { + vector: { values: [1, 0, 0] }, + filter: { category: { $eq: "guide" } }, + topK: 5, + includePayload: true, + scoreThreshold: 0.9, +}); +console.log(nearest.matches.map((match) => [match.record.id, match.score])); +``` + +The helpers implement all nine operations from the +[Vector proto](https://github.com/dapr/dapr/blob/master/dapr/proto/runtime/v1/vector.proto) over +`v1.0-alpha1` HTTP, without requiring Vector bindings in the Node SDK: collection creation/retrieval/listing/deletion, +vector upsert/retrieval/deletion, nearest-neighbor queries, and batch queries. Embedding generation is out +of scope: supply precomputed dense vectors matching the collection's dimensions. + +Use either `vector: { values: [...] }` or `byId: "guide"` in a query. Meilisearch's by-ID similarity +query excludes the source record from its results. `getVectors` returns found records in +request order, omitting missing IDs; pass `includeValues: true` to retrieve embeddings. Queries omit +values and payloads by default; opt in with `includeValues` and `includePayload`. Payloads are opaque +`Buffer` bytes, base64-encoded on the wire, while record metadata is structured JSON addressed directly +by portable filters. Request/component metadata is a separate string-valued map. `recordCount` remains +a protobuf JSON string to preserve uint64 precision. + +Meilisearch supports cosine similarity only (also selected by the unspecified metric), with higher scores +indicating closer matches; other metrics are rejected by the provider. `scoreThreshold` is inclusive. +`batchQueryVectors` preserves request order and returns a `response` or canonical RPC `error` per query; +check each result rather than assuming that an HTTP success means every query succeeded. + +Upserts and deletes default to return-on-acceptance (`INDEX_ACK_QUEUED`), not immediate visibility. +Use the explicit wait options above for deterministic assertions and check upsert `failedItems`. +HTTP failures throw with the status and response body. Wait timeouts must be positive and shorter +than applicable request/resiliency deadlines. + +Customize provisioning with `vectorStoreName`, `meilisearchContainer`, `apiKey`, or `timeout` (a Go +duration). Alternatively, set `meilisearchHost` to an external URL reachable **from the sidecar** and +provide its `apiKey`; do not combine this with `meilisearchContainer`. Managed providers use +`getmeili/meilisearch:v1.37.0` with a random master key and analytics disabled. The harness stops managed +containers and its own network; caller-supplied networks and external providers remain running. +`getStartedDaprContainer().getMeilisearchContainer()` exposes the managed provider endpoint and key. + +`.withSearch().withVector()` (in either order) shares one managed Meilisearch container and API key. +Conflicting explicit containers or keys are rejected. External and managed providers may also be +combined. Search indexes and Vector collections share Meilisearch's index namespace, so use distinct +names when using both building blocks on one provider. + +The Docker integration suite is opt-in with an image known to support Vector: + +```powershell +$env:DAPR_VECTOR_RUNTIME_IMAGE = "daprio/daprd:edge" # Or a supporting RC/release image +npm test -- --runInBand --runTestsByPath src\VectorHarness.test.ts src\MeilisearchContainer.test.ts +``` + +`edge` is a moving development tag. `DAPR_VECTOR_RUNTIME_IMAGE` selects the sidecar for these tests only; +without it, Vector integration tests are skipped but unit tests still run. An explicitly selected +unsupported image fails rather than silently skipping. + ## Dapr Search Testing `SearchHarness` or `.withSearch()` on `DaprContainer` provisions a `search.meilisearch` component and an diff --git a/src/Constants.ts b/src/Constants.ts index eab8e98..347678e 100644 --- a/src/Constants.ts +++ b/src/Constants.ts @@ -70,6 +70,7 @@ export const DaprComponentNames = { StateManagementComponentName: "statestore", BinaryStoreComponentName: "binarystore", SearchComponentName: "search", + VectorComponentName: "vector", PubSubComponentName: "pubsub", ConversationComponentName: "conversation", CryptographyComponentName: "cryptography", diff --git a/src/DaprContainer.ts b/src/DaprContainer.ts index 8eed752..77f22b9 100644 --- a/src/DaprContainer.ts +++ b/src/DaprContainer.ts @@ -123,6 +123,10 @@ export type SearchOptions = { timeout?: string; }; +export type VectorOptions = Omit & { + vectorStoreName?: string; +}; + export type ActorOptions = { stateStoreName?: string; redisContainer?: RedisContainer; @@ -173,6 +177,7 @@ export class DaprContainer extends GenericContainer { private ollamaContainer?: OllamaContainer; private meilisearchContainer?: MeilisearchContainer; private searchEnabled = false; + private vectorEnabled = false; private shouldReusePlacement = false; private shouldReuseScheduler = false; private shouldReuseRedis = false; @@ -943,28 +948,60 @@ export class DaprContainer extends GenericContainer { if (this.searchEnabled) { throw new Error("Search has already been configured on this DaprContainer."); } + this.withMeilisearchComponent("search", options.searchStoreName ?? DaprComponentNames.SearchComponentName, options); + this.searchEnabled = true; + return this; + } + + /** Requires a runtime image containing the alpha Vector API and vector.meilisearch component. */ + withVector(options: VectorOptions = {}): this { + if (this.vectorEnabled) { + throw new Error("Vector has already been configured on this DaprContainer."); + } + this.withMeilisearchComponent("vector", options.vectorStoreName ?? DaprComponentNames.VectorComponentName, options); + this.vectorEnabled = true; + return this; + } + + private withMeilisearchComponent( + kind: "search" | "vector", + name: string, + options: Omit + ): void { if (options.meilisearchContainer && options.meilisearchHost) { throw new Error("Specify either meilisearchContainer or meilisearchHost, not both."); } - const name = options.searchStoreName ?? DaprComponentNames.SearchComponentName; if (this.components.some((component) => component.name === name)) { throw new Error(`A component named "${name}" has already been registered`); } const container = options.meilisearchHost ? undefined - : (options.meilisearchContainer ?? new MeilisearchContainer()); - if (container && options.apiKey !== undefined) { - container.withApiKey(options.apiKey); + : (options.meilisearchContainer ?? this.meilisearchContainer ?? new MeilisearchContainer()); + if (container && this.meilisearchContainer) { + if (container !== this.meilisearchContainer) { + throw new Error("Search and Vector must share the same managed Meilisearch container."); + } + if (options.apiKey !== undefined && options.apiKey !== container.getApiKey()) { + throw new Error("Search and Vector must use the same API key for their shared Meilisearch container."); + } } - const component = MeilisearchContainer.createSearchComponent({ + const componentOptions = { name, host: options.meilisearchHost ?? `http://meilisearch:${container?.getPort() ?? MEILISEARCH_DEFAULT_PORT}`, apiKey: options.apiKey ?? container?.getApiKey(), timeout: options.timeout, - }); - this.meilisearchContainer = container; - this.searchEnabled = true; - return this.withComponent(component); + }; + const component = + kind === "search" + ? MeilisearchContainer.createSearchComponent(componentOptions) + : MeilisearchContainer.createVectorComponent(componentOptions); + if (container) { + if (options.apiKey !== undefined) { + container.withApiKey(options.apiKey); + } + this.meilisearchContainer = container; + } + this.withComponent(component); } getMeilisearchContainer(): MeilisearchContainer | undefined { diff --git a/src/MeilisearchContainer.test.ts b/src/MeilisearchContainer.test.ts index 9d0bd6c..5517668 100644 --- a/src/MeilisearchContainer.test.ts +++ b/src/MeilisearchContainer.test.ts @@ -50,6 +50,33 @@ describe("MeilisearchContainer", () => { ]); }); + it("creates vector components using the same provider credentials and metadata", () => { + const container = new MeilisearchContainer().withApiKey("test-master-key-1234"); + const component = container.createVectorComponent(); + expect(component).toMatchObject({ name: "vector", type: "vector.meilisearch", version: "v1" }); + expect(component.getMetadata()).toEqual(container.createSearchComponent().getMetadata()); + expect( + container + .createVectorComponent({ + name: "embeddings", + host: "https://custom:7700", + apiKey: "override-key", + timeout: "20s", + }) + .getMetadata() + ).toEqual([ + { name: "host", value: "https://custom:7700" }, + { name: "apiKey", value: "override-key" }, + { name: "timeout", value: "20s" }, + ]); + expect(MeilisearchContainer.createVectorComponent().getMetadata()).toEqual([ + { name: "host", value: "http://localhost:7700" }, + { name: "apiKey", value: "" }, + ]); + expect(() => MeilisearchContainer.createVectorComponent({ name: "../bad" })).toThrow("Vector store name"); + expect(() => MeilisearchContainer.createVectorComponent({ host: "redis://localhost" })).toThrow("HTTP or HTTPS"); + }); + it("starts an authenticated Meilisearch server", async () => { await using started = await new MeilisearchContainer().start(); expect((await fetch(`${started.getHttpEndpoint()}/health`)).status).toBe(200); diff --git a/src/MeilisearchContainer.ts b/src/MeilisearchContainer.ts index 18a234e..168a2db 100644 --- a/src/MeilisearchContainer.ts +++ b/src/MeilisearchContainer.ts @@ -66,9 +66,29 @@ export class MeilisearchContainer extends GenericContainer { } public static createSearchComponent(options: MeilisearchStoreOptions = {}): Component { - const name = options.name ?? DaprComponentNames.SearchComponentName; + return MeilisearchContainer.createComponent("search", options); + } + + public createVectorComponent(options: MeilisearchStoreOptions = {}): Component { + return MeilisearchContainer.createVectorComponent({ + ...options, + host: options.host ?? `http://meilisearch:${this.getPort()}`, + apiKey: options.apiKey ?? this.apiKey, + }); + } + + public static createVectorComponent(options: MeilisearchStoreOptions = {}): Component { + return MeilisearchContainer.createComponent("vector", options); + } + + private static createComponent(kind: "search" | "vector", options: MeilisearchStoreOptions): Component { + const name = + options.name ?? + (kind === "search" ? DaprComponentNames.SearchComponentName : DaprComponentNames.VectorComponentName); if (!name.trim() || /[/\\]/.test(name) || name === "." || name === "..") { - throw new Error("Search store name must be non-empty and must not contain path separators or dot segments."); + throw new Error( + `${kind === "search" ? "Search" : "Vector"} store name must be non-empty and must not contain path separators or dot segments.` + ); } const host = options.host ?? `http://localhost:${MEILISEARCH_DEFAULT_PORT}`; const url = new URL(host); @@ -82,7 +102,7 @@ export class MeilisearchContainer extends GenericContainer { if (options.timeout !== undefined) { metadata.push({ name: "timeout", value: options.timeout }); } - return new Component(name, "search.meilisearch", "v1", metadata); + return new Component(name, `${kind}.meilisearch`, "v1", metadata); } public override async start(): Promise { diff --git a/src/VectorHarness.test.ts b/src/VectorHarness.test.ts new file mode 100644 index 0000000..a2eaf3c --- /dev/null +++ b/src/VectorHarness.test.ts @@ -0,0 +1,555 @@ +/* +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { CommunicationProtocolEnum } from "@dapr/dapr"; +import { GenericContainer, Network } from "testcontainers"; +import { + Component, + DaprContainer, + IndexingOptionsAlpha1, + MeilisearchContainer, + VectorHarness, + VectorQuery, +} from "./index"; + +const waitForCompletion: IndexingOptionsAlpha1 = { + mode: "INDEXING_MODE_WAIT_FOR_COMPLETION", + waitTimeout: "10s", + onWaitTimeout: "INDEXING_WAIT_TIMEOUT_ACTION_FAIL_REQUEST", +}; + +describe("VectorHarness", () => { + afterEach(() => jest.restoreAllMocks()); + + it("registers a managed vector component by default", () => { + const harness = new VectorHarness(); + const dapr = harness.getDaprContainer(); + expect(harness.getVectorStoreName()).toBe("vector"); + expect(dapr.getAppName()).toBe("vector-app"); + expect(dapr.getMeilisearchContainer()).toBeInstanceOf(MeilisearchContainer); + expect(dapr.getComponents()[0]).toMatchObject({ name: "vector", type: "vector.meilisearch", version: "v1" }); + expect(dapr.getComponents()[0].getMetadata()).toEqual([ + { name: "host", value: "http://meilisearch:7700" }, + { name: "apiKey", value: dapr.getMeilisearchContainer()!.getApiKey() }, + ]); + }); + + it("forwards custom application, provider and component settings", () => { + const meilisearchContainer = new MeilisearchContainer(); + const harness = new VectorHarness({ + appId: "books-app", + appPort: 8080, + appChannelAddress: "host.testcontainers.internal", + daprLogLevel: "debug", + daprApiLoggingEnabled: true, + daprRuntimeImage: "daprio/daprd:edge", + vectorStoreName: "books", + meilisearchContainer, + apiKey: "test-master-key-1234", + timeout: "20s", + }); + const dapr = harness.getDaprContainer(); + expect(dapr.getAppName()).toBe("books-app"); + expect(dapr.getAppPort()).toBe(8080); + expect(dapr.getAppChannelAddress()).toBe("host.testcontainers.internal"); + expect(dapr.getMeilisearchContainer()).toBe(meilisearchContainer); + expect(meilisearchContainer.getApiKey()).toBe("test-master-key-1234"); + expect(harness.getVectorStoreName()).toBe("books"); + expect(dapr.getComponents()[0].getMetadata()).toContainEqual({ name: "timeout", value: "20s" }); + }); + + it("supports external providers and rejects duplicate or ambiguous registrations", () => { + const dapr = new DaprContainer(); + expect(dapr.withVector({ meilisearchHost: "http://external:7700", apiKey: "external-key" })).toBe(dapr); + expect(dapr.getMeilisearchContainer()).toBeUndefined(); + expect(dapr.getComponents()[0].getMetadata()).toEqual([ + { name: "host", value: "http://external:7700" }, + { name: "apiKey", value: "external-key" }, + ]); + expect(() => dapr.withVector()).toThrow("already been configured"); + expect(() => + new DaprContainer().withComponent(new Component("vector", "vector.in-memory", "v1", [])).withVector() + ).toThrow('A component named "vector" has already been registered'); + expect( + () => + new VectorHarness({ meilisearchHost: "http://external:7700", meilisearchContainer: new MeilisearchContainer() }) + ).toThrow("not both"); + }); + + it.each(["search", "vector"] as const)("shares one managed provider when %s is configured first", (first) => { + const dapr = new DaprContainer(); + const provider = new MeilisearchContainer(); + const options = { meilisearchContainer: provider, apiKey: "test-master-key-1234" }; + if (first === "search") { + dapr.withSearch(options).withVector(); + } else { + dapr.withVector(options).withSearch(); + } + expect(dapr.getMeilisearchContainer()).toBe(provider); + expect( + dapr + .getComponents() + .map((component) => component.type) + .sort() + ).toEqual(["search.meilisearch", "vector.meilisearch"]); + for (const component of dapr.getComponents()) { + expect(component.getMetadata()).toContainEqual({ name: "apiKey", value: provider.getApiKey() }); + } + }); + + it.each(["search", "vector"] as const)( + "rejects conflicting shared settings after %s without corrupting configuration", + (first) => { + const dapr = new DaprContainer(); + if (first === "search") { + dapr.withSearch(); + } else { + dapr.withVector(); + } + const provider = dapr.getMeilisearchContainer()!; + const configure = first === "search" ? dapr.withVector.bind(dapr) : dapr.withSearch.bind(dapr); + expect(() => configure({ meilisearchContainer: new MeilisearchContainer() })).toThrow("same managed"); + expect(() => configure({ apiKey: "a-different-master-key" })).toThrow("same API key"); + expect(dapr.getComponents()).toHaveLength(1); + configure({ meilisearchContainer: provider, apiKey: provider.getApiKey() }); + expect(dapr.getComponents()).toHaveLength(2); + } + ); + + it.each([ + ["search", true], + ["search", false], + ["vector", true], + ["vector", false], + ] as const)("preserves managed provisioning with %s first and external-first=%s", (first, externalFirst) => { + const dapr = new DaprContainer(); + const external = { meilisearchHost: "http://external:7700", apiKey: "external-key" }; + const a = externalFirst ? external : {}; + const b = externalFirst ? {} : external; + if (first === "search") { + dapr.withSearch(a).withVector(b); + } else { + dapr.withVector(a).withSearch(b); + } + expect(dapr.getMeilisearchContainer()).toBeDefined(); + expect( + dapr + .getComponents() + .filter((component) => + component.getMetadata().some((entry) => entry.name === "host" && entry.value === "http://external:7700") + ) + ).toHaveLength(1); + }); + + it.each(["", " ", ".", "..", "../store", "store\\name"])("rejects unsafe store name %j", (vectorStoreName) => { + expect(() => new VectorHarness({ vectorStoreName })).toThrow("Vector store name must be"); + }); + + it.each(["", "not-a-url", "redis://localhost:7700"])("rejects invalid provider host %j", (meilisearchHost) => { + expect(() => new VectorHarness({ meilisearchHost })).toThrow(); + }); + + it("guards access before start and safely disposes an unstarted harness", async () => { + const harness = new VectorHarness(); + for (const get of [ + () => harness.getStartedDaprContainer(), + () => harness.getHost(), + () => harness.getHttpPort(), + () => harness.getGrpcPort(), + () => harness.getHttpEndpoint(), + () => harness.getGrpcEndpoint(), + () => harness.createDaprClient(), + ]) { + expect(get).toThrow("VectorHarness has not been started"); + } + await expect(harness.createCollection("books", 3)).rejects.toThrow("has not been started"); + await expect(harness.listCollections()).rejects.toThrow("has not been started"); + await harness.stop(); + await harness[Symbol.asyncDispose](); + }); + + it("maps collection operations, encodes metadata and preserves uint64 counts", async () => { + const harness = new VectorHarness({ vectorStoreName: "my store" }); + jest.spyOn(harness, "getHttpEndpoint").mockReturnValue("http://localhost:3500"); + const fetchMock = jest.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 204 })); + const metadata = { "special&key": "a b&c" }; + const base = "http://localhost:3500/v1.0-alpha1/vector/my%20store/collections"; + await harness.createCollection("books 100%", 3, { metric: "DISTANCE_METRIC_COSINE", metadata }); + expect(fetchMock).toHaveBeenLastCalledWith(`${base}/books%20100%25`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ metric: "DISTANCE_METRIC_COSINE", metadata, dimensions: 3 }), + }); + fetchMock.mockResolvedValueOnce( + Response.json({ + collection: "books", + recordCount: "9007199254740993", + dimensions: 3, + metric: "DISTANCE_METRIC_COSINE", + properties: {}, + }) + ); + expect((await harness.getCollection("books", metadata)).recordCount).toBe("9007199254740993"); + expect(fetchMock).toHaveBeenLastCalledWith(`${base}/books?metadata.special%26key=a+b%26c`, { method: "GET" }); + fetchMock.mockResolvedValueOnce(Response.json({ collections: ["books"] })); + expect(await harness.listCollections({ tenant: "a/b" })).toEqual({ collections: ["books"] }); + expect(fetchMock).toHaveBeenLastCalledWith(`${base}?metadata.tenant=a%2Fb`, { method: "GET" }); + await harness.deleteCollection("books", metadata); + expect(fetchMock).toHaveBeenLastCalledWith(`${base}/books?metadata.special%26key=a+b%26c`, { method: "DELETE" }); + }); + + it("encodes opaque payloads and preserves acknowledgements, item errors and write options", async () => { + const harness = new VectorHarness(); + jest.spyOn(harness, "getHttpEndpoint").mockReturnValue("http://localhost:3500"); + const result = { ack: "INDEX_ACK_QUEUED", failedItems: [{ id: "bad", error: { code: 3, message: "dimensions" } }] }; + const fetchMock = jest.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(result)); + const records = [ + { id: "one", values: [1, 0, 0], payload: Buffer.from([0, 128, 255]), metadata: { year: 2026, tags: ["a"] } }, + { id: "two", values: [0, 1, 0] }, + { id: "three", values: [0, 0, 1], payload: Buffer.alloc(0) }, + ]; + expect(await harness.upsertVectors("books", records)).toEqual(result); + expect(fetchMock).toHaveBeenLastCalledWith( + "http://localhost:3500/v1.0-alpha1/vector/vector/collections/books/vectors", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + records: [{ ...records[0], payload: "AID/" }, records[1], { ...records[2], payload: "" }], + }), + }) + ); + const options = { metadata: { tenant: "test" }, options: waitForCompletion }; + fetchMock.mockResolvedValueOnce(Response.json({ ack: "INDEX_ACK_COMPLETED", failedItems: [] })); + expect(await harness.upsertVectors("books", [], options)).toEqual({ ack: "INDEX_ACK_COMPLETED", failedItems: [] }); + expect(fetchMock.mock.calls[1][1]!.body).toBe(JSON.stringify({ ...options, records: [] })); + fetchMock.mockResolvedValueOnce(Response.json({ ack: "INDEX_ACK_COMPLETED" })); + expect(await harness.deleteVectors("books", ["one"], options)).toEqual({ ack: "INDEX_ACK_COMPLETED" }); + expect(fetchMock).toHaveBeenLastCalledWith( + "http://localhost:3500/v1.0-alpha1/vector/vector/collections/books/vectors/delete", + expect.objectContaining({ method: "POST", body: JSON.stringify({ ...options, ids: ["one"] }) }) + ); + }); + + it("decodes retrieved records, including absent and empty payloads, in response order", async () => { + const harness = new VectorHarness(); + jest.spyOn(harness, "getHttpEndpoint").mockReturnValue("http://localhost:3500"); + const records = [ + { id: "two", values: [], metadata: { nested: { year: 2026 } } }, + { id: "one", values: [1, 0], payload: "AID/" }, + { id: "empty", values: [], payload: "" }, + ]; + const fetchMock = jest.spyOn(globalThis, "fetch").mockResolvedValueOnce(Response.json({ records })); + expect(await harness.getVectors("books", ["two", "missing", "one", "empty"], { includeValues: true })).toEqual({ + records: [ + records[0], + { ...records[1], payload: Buffer.from([0, 128, 255]) }, + { ...records[2], payload: Buffer.alloc(0) }, + ], + }); + expect(fetchMock).toHaveBeenLastCalledWith( + "http://localhost:3500/v1.0-alpha1/vector/vector/collections/books/vectors/get", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ includeValues: true, ids: ["two", "missing", "one", "empty"] }), + }) + ); + }); + + it("maps vector and by-ID queries and decodes mixed batch results without hiding errors", async () => { + const harness = new VectorHarness(); + jest.spyOn(harness, "getHttpEndpoint").mockReturnValue("http://localhost:3500"); + const record = { id: "one", values: [1, 0], payload: "AID/", metadata: { active: true } }; + const response = { matches: [{ record, score: 0.95 }], metric: "DISTANCE_METRIC_COSINE" }; + const decoded = { + ...response, + matches: [{ record: { ...record, payload: Buffer.from([0, 128, 255]) }, score: 0.95 }], + }; + const fetchMock = jest.spyOn(globalThis, "fetch").mockResolvedValueOnce(Response.json(response)); + const query: VectorQuery = { + vector: { values: [1, 0] }, + topK: 2, + filter: { active: { $eq: true } }, + includeValues: true, + includePayload: true, + metric: "DISTANCE_METRIC_COSINE", + scoreThreshold: 0.8, + }; + const metadata = { tenant: "test" }; + expect(await harness.queryVectors("books", query, metadata)).toEqual(decoded); + expect(fetchMock).toHaveBeenLastCalledWith( + "http://localhost:3500/v1.0-alpha1/vector/vector/collections/books/query", + expect.objectContaining({ method: "POST", body: JSON.stringify({ query, metadata }) }) + ); + const error = { code: 5, message: "not found", details: [{ "@type": "example", reason: "missing" }] }; + const empty = { matches: [], metric: "DISTANCE_METRIC_COSINE" }; + fetchMock.mockResolvedValueOnce(Response.json({ results: [{ response }, { error }, { response: empty }] })); + const queries: VectorQuery[] = [query, { byId: "missing" }, { byId: "one", scoreThreshold: 1 }]; + expect(await harness.batchQueryVectors("books", queries, metadata)).toEqual({ + results: [{ response: decoded }, { error }, { response: empty }], + }); + expect(fetchMock).toHaveBeenLastCalledWith( + "http://localhost:3500/v1.0-alpha1/vector/vector/collections/books/batch-query", + expect.objectContaining({ method: "POST", body: JSON.stringify({ queries, metadata }) }) + ); + fetchMock.mockResolvedValueOnce(Response.json(empty)); + expect(await harness.queryVectors("books", { byId: "one" })).toEqual(empty); + expect(fetchMock.mock.calls[2][1]!.body).toBe(JSON.stringify({ query: { byId: "one" } })); + }); + + it.each(["", " ", ".", "..", "../books", "books\\data"])("rejects unsafe collection %j", async (collection) => { + const harness = new VectorHarness(); + const fetchMock = jest.spyOn(globalThis, "fetch"); + await expect(harness.getCollection(collection)).rejects.toThrow("Vector collection must be"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([400, 404, 409, 500])("surfaces HTTP status %i and its error body", async (status) => { + const harness = new VectorHarness(); + jest.spyOn(harness, "getHttpEndpoint").mockReturnValue("http://localhost:3500"); + jest.spyOn(globalThis, "fetch").mockResolvedValue(new Response("runtime error", { status })); + await expect(harness.queryVectors("books", { byId: "one" })).rejects.toThrow( + `Vector POST request failed (${status}): runtime error` + ); + }); + + it("propagates transport and malformed JSON errors", async () => { + const harness = new VectorHarness(); + jest.spyOn(harness, "getHttpEndpoint").mockReturnValue("http://localhost:3500"); + const fetchMock = jest.spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("connection failed")); + await expect(harness.listCollections()).rejects.toThrow("connection failed"); + fetchMock.mockResolvedValueOnce(new Response("{")); + await expect(harness.listCollections()).rejects.toThrow(); + }); +}); + +// An explicitly selected image must support both the Vector API and vector.meilisearch. +const vectorRuntimeImage = process.env.DAPR_VECTOR_RUNTIME_IMAGE?.trim(); +const itVector = vectorRuntimeImage ? it : it.skip; + +describe("Vector integration (requires DAPR_VECTOR_RUNTIME_IMAGE)", () => { + afterEach(() => jest.restoreAllMocks()); + + itVector( + "exercises vector operations and shares one provider with Search", + async () => { + const harness = new VectorHarness({ daprRuntimeImage: vectorRuntimeImage, vectorStoreName: "bookstore" }); + harness.getDaprContainer().withSearch(); + const startNetwork = Network.prototype.start; + let stopNetwork: jest.SpyInstance | undefined; + jest.spyOn(Network.prototype, "start").mockImplementation(async function (this: Network) { + const network = await startNetwork.call(this); + stopNetwork = jest.spyOn(network, "stop"); + return network; + }); + let stopProvider: jest.SpyInstance | undefined; + try { + await harness.start(); + expect(await harness.start()).toBe(harness); + const started = harness.getStartedDaprContainer(); + expect(started.getContainers()).toHaveLength(3); + stopProvider = jest.spyOn(started.getMeilisearchContainer()!, "stop"); + expect(harness.getHost()).toBeDefined(); + expect(harness.getHttpPort()).toBeGreaterThan(0); + expect(harness.getGrpcPort()).toBeGreaterThan(0); + expect(harness.getGrpcEndpoint()).toContain(harness.getGrpcPort().toString()); + const httpClient = harness.createDaprClient(); + const grpcClient = harness.createDaprClient({ communicationProtocol: CommunicationProtocolEnum.GRPC }); + await httpClient.start(); + await grpcClient.start(); + await harness.createCollection("books", 3); + expect((await harness.listCollections()).collections).toContain("books"); + expect(await harness.getCollection("books")).toMatchObject({ + collection: "books", + recordCount: "0", + dimensions: 3, + metric: "DISTANCE_METRIC_COSINE", + }); + await expect(harness.createCollection("books", 3)).rejects.toThrow("(409)"); + await expect( + harness.createCollection("unsupported", 3, { metric: "DISTANCE_METRIC_EUCLIDEAN" }) + ).rejects.toThrow("(400)"); + const payload = Buffer.from([0, 128, 255]); + const records = [ + { id: "one", values: [1, 0, 0], payload, metadata: { category: "guide", year: 2026 } }, + { id: "two", values: [0, 1, 0], metadata: { category: "reference", year: 2020 } }, + ]; + const indexed = await harness.upsertVectors("books", [...records, { id: "invalid", values: [1, 0] }], { + options: waitForCompletion, + }); + expect(indexed.ack).toBe("INDEX_ACK_COMPLETED"); + expect(indexed.failedItems).toEqual([ + expect.objectContaining({ id: "invalid", error: expect.objectContaining({ code: 3 }) }), + ]); + expect((await harness.getCollection("books")).recordCount).toBe("2"); + const got = await harness.getVectors("books", ["two", "missing", "one"], { includeValues: true }); + expect(got.records.map((record) => record.id)).toEqual(["two", "one"]); + expect(got.records[1]).toMatchObject(records[0]); + expect((await harness.getVectors("books", ["one"])).records[0].values).toEqual([]); + const result = await harness.queryVectors("books", { + vector: { values: [1, 0, 0] }, + topK: 2, + includeValues: true, + includePayload: true, + scoreThreshold: 0.9, + }); + expect(result.metric).toBe("DISTANCE_METRIC_COSINE"); + expect(result.matches.map((match) => match.record.id)).toEqual(["one"]); + expect(result.matches[0].score).toBeCloseTo(1, 2); + expect(result.matches[0].record).toMatchObject(records[0]); + const filtered = await harness.queryVectors("books", { + vector: { values: [1, 0, 0] }, + topK: 2, + filter: { year: { $lt: 2022 } }, + }); + expect(filtered.matches.map((match) => match.record.id)).toEqual(["two"]); + expect(filtered.matches[0].record.values).toEqual([]); + expect(filtered.matches[0].record.payload).toBeUndefined(); + expect((await harness.queryVectors("books", { byId: "one", topK: 1 })).matches[0].record.id).toBe("two"); + const batch = await harness.batchQueryVectors("books", [ + { byId: "two", topK: 1, includePayload: true }, + { byId: "missing" }, + { vector: { values: [0, 1, 0] }, topK: 1 }, + ]); + expect(batch.results).toHaveLength(3); + expect(batch.results[0].response?.matches[0].record.payload).toEqual(payload); + expect(batch.results[1].error).toMatchObject({ code: 5 }); + expect(batch.results[2].response?.matches[0].record.id).toBe("two"); + expect( + await harness.upsertVectors("books", [{ ...records[0], values: [0, 0, 1] }], { options: waitForCompletion }) + ).toEqual({ ack: "INDEX_ACK_COMPLETED", failedItems: [] }); + expect((await harness.getVectors("books", ["one"], { includeValues: true })).records[0].values).toEqual([ + 0, 0, 1, + ]); + expect(await harness.deleteVectors("books", ["one", "missing"], { options: waitForCompletion })).toEqual({ + ack: "INDEX_ACK_COMPLETED", + }); + expect((await harness.getVectors("books", ["one"])).records).toEqual([]); + const queued = await harness.upsertVectors("books", [{ id: "queued", values: [1, 0, 0] }]); + expect(queued).toEqual({ ack: "INDEX_ACK_QUEUED", failedItems: [] }); + const deadline = Date.now() + 10_000; + while (!(await harness.getVectors("books", ["queued"])).records.length) { + if (Date.now() >= deadline) { + throw new Error("Queued vector was not visible within 10 seconds."); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + const searchBase = `${harness.getHttpEndpoint()}/v1.0-alpha1/search/search/indexes`; + expect( + ( + await fetch(`${searchBase}/documents`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }) + ).ok + ).toBe(true); + expect(await (await fetch(searchBase)).json()).toMatchObject({ + indexes: expect.arrayContaining(["documents"]), + }); + await harness.deleteCollection("books"); + expect((await harness.listCollections()).collections).not.toContain("books"); + await expect(harness.getCollection("books")).rejects.toThrow("(404)"); + } finally { + await harness[Symbol.asyncDispose](); + } + expect(stopProvider).toHaveBeenCalledTimes(1); + expect(stopNetwork).toHaveBeenCalledTimes(1); + expect(() => harness.getStartedDaprContainer()).toThrow("has not been started"); + await harness.stop(); + }, + 240_000 + ); + + itVector( + "leaves caller-owned networks and external providers running", + async () => { + await using network = await new Network().start(); + const stopNetwork = jest.spyOn(network, "stop"); + await using provider = await new MeilisearchContainer() + .withNetwork(network) + .withNetworkAliases("external-vector") + .start(); + const harness = new VectorHarness({ + daprRuntimeImage: vectorRuntimeImage, + network, + meilisearchHost: "http://external-vector:7700", + apiKey: provider.getApiKey(), + }); + try { + await harness.start(); + expect(harness.getStartedDaprContainer().getMeilisearchContainer()).toBeUndefined(); + await harness.createCollection("external", 3); + } finally { + await harness.stop(); + } + expect(stopNetwork).not.toHaveBeenCalled(); + expect((await fetch(`${provider.getHttpEndpoint()}/health`)).status).toBe(200); + await using started = await new DaprContainer(vectorRuntimeImage) + .withNetwork(network) + .withVector({ + meilisearchHost: "http://external-vector:7700", + apiKey: provider.getApiKey(), + }) + .start(); + const response = await fetch(`${started.getHttpEndpoint()}/v1.0-alpha1/vector/vector/collections`); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ collections: ["external"] }); + }, + 240_000 + ); + + itVector( + "cleans up after failed sidecar startup and permits retry", + async () => { + const harness = new VectorHarness({ daprRuntimeImage: vectorRuntimeImage }); + const startContainer = GenericContainer.prototype.start; + const startProvider = MeilisearchContainer.prototype.start; + const startNetwork = Network.prototype.start; + let stopProvider: jest.SpyInstance | undefined; + let stopNetwork: jest.SpyInstance | undefined; + jest.spyOn(Network.prototype, "start").mockImplementation(async function (this: Network) { + const network = await startNetwork.call(this); + stopNetwork = jest.spyOn(network, "stop"); + return network; + }); + jest.spyOn(MeilisearchContainer.prototype, "start").mockImplementation(async function ( + this: MeilisearchContainer + ) { + const provider = await startProvider.call(this); + stopProvider = jest.spyOn(provider, "stop"); + return provider; + }); + const startMock = jest.spyOn(GenericContainer.prototype, "start").mockImplementation(async function ( + this: GenericContainer + ) { + if (this === harness.getDaprContainer()) { + throw new Error("sidecar startup failed"); + } + return startContainer.call(this); + }); + try { + await expect(harness.start()).rejects.toThrow("sidecar startup failed"); + expect(stopProvider).toHaveBeenCalledTimes(1); + expect(stopNetwork).toHaveBeenCalledTimes(1); + expect(() => harness.getStartedDaprContainer()).toThrow("has not been started"); + startMock.mockRestore(); + await harness.start(); + await harness.createCollection("retry", 3); + expect((await harness.listCollections()).collections).toContain("retry"); + } finally { + await harness.stop(); + } + }, + 240_000 + ); +}); diff --git a/src/VectorHarness.ts b/src/VectorHarness.ts new file mode 100644 index 0000000..4ece6e9 --- /dev/null +++ b/src/VectorHarness.ts @@ -0,0 +1,282 @@ +/* +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { CommunicationProtocolEnum, DaprClient, DaprClientOptions } from "@dapr/dapr"; +import { Network, StartedNetwork } from "testcontainers"; +import { DaprComponentNames } from "./Constants"; +import { DaprContainer, StartedDaprContainer, VectorOptions } from "./DaprContainer"; +import { + BatchQueryResultAlpha1, + BatchQueryVectorsResponseAlpha1, + CreateCollectionOptions, + DeleteVectorsResponseAlpha1, + GetCollectionResponseAlpha1, + GetVectorsResponseAlpha1, + ListCollectionsResponseAlpha1, + QueryVectorsResponseAlpha1, + UpsertVectorsResponseAlpha1, + VectorMetadata, + VectorQuery, + VectorRecord, + VectorWriteOptions, +} from "./VectorTypes"; + +export type VectorHarnessOptions = VectorOptions & { + appId?: string; + appPort?: number; + appChannelAddress?: string; + daprLogLevel?: string; + daprApiLoggingEnabled?: boolean; + daprRuntimeImage?: string; + network?: StartedNetwork; +}; + +type WireRecord = Omit & { payload?: string }; +type WireQueryResponse = Omit & { + matches: { record: WireRecord; score: number }[]; +}; +type WireBatchResult = + | { response: WireQueryResponse; error?: never } + | { response?: never; error: NonNullable }; + +/** Exercises Dapr's alpha Vector API using pre-embedded dense vectors and Meilisearch. */ +export class VectorHarness { + private network?: StartedNetwork; + private ownsNetwork = false; + private readonly daprContainer: DaprContainer; + private startedDaprContainer?: StartedDaprContainer; + private daprClients: DaprClient[] = []; + + constructor(private readonly options: VectorHarnessOptions = {}) { + this.daprContainer = new DaprContainer(options.daprRuntimeImage) + .withAppName(options.appId ?? "vector-app") + .withDaprLogLevel(options.daprLogLevel ?? "info") + .withDaprApiLoggingEnabled(options.daprApiLoggingEnabled ?? false) + .withVector(options); + if (options.appPort !== undefined) { + this.daprContainer.withAppPort(options.appPort); + } + if (options.appChannelAddress) { + this.daprContainer.withAppChannelAddress(options.appChannelAddress); + } + } + + public getDaprContainer(): DaprContainer { + return this.daprContainer; + } + + public getVectorStoreName(): string { + return this.options.vectorStoreName ?? DaprComponentNames.VectorComponentName; + } + + public getStartedDaprContainer(): StartedDaprContainer { + if (!this.startedDaprContainer) { + throw new Error("VectorHarness has not been started. Call start() first."); + } + return this.startedDaprContainer; + } + + public async start(): Promise { + if (this.startedDaprContainer) { + return this; + } + this.network = this.options.network ?? (await new Network().start()); + this.ownsNetwork = this.options.network === undefined; + this.daprContainer.withNetwork(this.network); + try { + this.startedDaprContainer = await this.daprContainer.start(); + } catch (error) { + await this.stop(); + throw error; + } + return this; + } + + public async stop(): Promise { + try { + try { + await Promise.all(this.daprClients.map((client) => client.stop())); + } finally { + this.daprClients = []; + if (this.startedDaprContainer) { + await this.startedDaprContainer.stop(); + this.startedDaprContainer = undefined; + } + } + } finally { + if (this.ownsNetwork && this.network) { + await this.network.stop(); + this.network = undefined; + } + } + } + + public getHost(): string { + return this.getStartedDaprContainer().getHost(); + } + + public getHttpPort(): number { + return this.getStartedDaprContainer().getHttpPort(); + } + + public getGrpcPort(): number { + return this.getStartedDaprContainer().getGrpcPort(); + } + + public getHttpEndpoint(): string { + return this.getStartedDaprContainer().getHttpEndpoint(); + } + + public getGrpcEndpoint(): string { + return this.getStartedDaprContainer().getGrpcEndpoint(); + } + + public createDaprClient(clientOptions?: Partial): DaprClient { + const started = this.getStartedDaprContainer(); + const protocol = clientOptions?.communicationProtocol ?? CommunicationProtocolEnum.HTTP; + const client = new DaprClient({ + daprHost: started.getHost(), + daprPort: (protocol === CommunicationProtocolEnum.GRPC + ? started.getGrpcPort() + : started.getHttpPort() + ).toString(), + communicationProtocol: protocol, + ...clientOptions, + }); + this.daprClients.push(client); + return client; + } + + public async createCollection( + collection: string, + dimensions: number, + options: CreateCollectionOptions = {} + ): Promise { + await this.request("POST", this.collectionPath(collection), { ...options, dimensions }); + } + + public async getCollection(collection: string, metadata?: VectorMetadata): Promise { + const response = await this.request("GET", this.collectionPath(collection), undefined, metadata); + return response.json(); + } + + public async listCollections(metadata?: VectorMetadata): Promise { + const response = await this.request("GET", "/collections", undefined, metadata); + return response.json(); + } + + public async deleteCollection(collection: string, metadata?: VectorMetadata): Promise { + await this.request("DELETE", this.collectionPath(collection), undefined, metadata); + } + + public async upsertVectors( + collection: string, + records: VectorRecord[], + options: VectorWriteOptions = {} + ): Promise { + const response = await this.request("POST", `${this.collectionPath(collection)}/vectors`, { + ...options, + records: records.map((record) => ({ ...record, payload: record.payload?.toString("base64") })), + }); + return response.json(); + } + + public async getVectors( + collection: string, + ids: string[], + options: { includeValues?: boolean; metadata?: VectorMetadata } = {} + ): Promise { + const response = await this.request("POST", `${this.collectionPath(collection)}/vectors/get`, { ...options, ids }); + const result: { records: WireRecord[] } = await response.json(); + return { ...result, records: result.records.map(VectorHarness.decodeRecord) }; + } + + public async deleteVectors( + collection: string, + ids: string[], + options: VectorWriteOptions = {} + ): Promise { + const response = await this.request("POST", `${this.collectionPath(collection)}/vectors/delete`, { + ...options, + ids, + }); + return response.json(); + } + + public async queryVectors( + collection: string, + query: VectorQuery, + metadata?: VectorMetadata + ): Promise { + const response = await this.request("POST", `${this.collectionPath(collection)}/query`, { query, metadata }); + return VectorHarness.decodeQueryResponse(await response.json()); + } + + public async batchQueryVectors( + collection: string, + queries: VectorQuery[], + metadata?: VectorMetadata + ): Promise { + const response = await this.request("POST", `${this.collectionPath(collection)}/batch-query`, { + queries, + metadata, + }); + const result: { results: WireBatchResult[] } = await response.json(); + return { + ...result, + results: result.results.map((item) => + item.response !== undefined ? { ...item, response: VectorHarness.decodeQueryResponse(item.response) } : item + ), + }; + } + + public async [Symbol.asyncDispose](): Promise { + await this.stop(); + } + + private static decodeRecord(record: WireRecord): VectorRecord { + const { payload, ...rest } = record; + return { ...rest, ...(payload === undefined ? {} : { payload: Buffer.from(payload, "base64") }) }; + } + + private static decodeQueryResponse(result: WireQueryResponse): QueryVectorsResponseAlpha1 { + return { + ...result, + matches: result.matches.map((match) => ({ ...match, record: VectorHarness.decodeRecord(match.record) })), + }; + } + + private collectionPath(collection: string): string { + if (!collection.trim() || /[/\\]/.test(collection) || collection === "." || collection === "..") { + throw new Error("Vector collection must be non-empty and must not contain path separators or dot segments."); + } + return `/collections/${encodeURIComponent(collection)}`; + } + + private async request(method: string, path: string, body?: object, metadata?: VectorMetadata): Promise { + const url = new URL( + `${this.getHttpEndpoint()}/v1.0-alpha1/vector/${encodeURIComponent(this.getVectorStoreName())}${path}` + ); + for (const [key, value] of Object.entries(metadata ?? {})) { + url.searchParams.set(`metadata.${key}`, value); + } + const response = await fetch(url.toString(), { + method, + ...(body === undefined ? {} : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }), + }); + if (!response.ok) { + throw new Error(`Vector ${method} request failed (${response.status}): ${await response.text()}`); + } + return response; + } +} diff --git a/src/VectorTypes.ts b/src/VectorTypes.ts new file mode 100644 index 0000000..585b402 --- /dev/null +++ b/src/VectorTypes.ts @@ -0,0 +1,80 @@ +/* +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { FailedItem, IndexAck, IndexingOptionsAlpha1, SearchMetadata, SearchStruct } from "./SearchTypes"; + +export type VectorMetadata = SearchMetadata; +export type VectorStruct = SearchStruct; + +export type DistanceMetric = + | "DISTANCE_METRIC_UNSPECIFIED" + | "DISTANCE_METRIC_COSINE" + | "DISTANCE_METRIC_DOT_PRODUCT" + | "DISTANCE_METRIC_EUCLIDEAN"; + +export type VectorRecord = { + id: string; + values: number[]; + /** Opaque bytes; the harness handles base64 encoding on the wire. */ + payload?: Buffer; + /** Structured, filterable attributes, unlike request/component metadata. */ + metadata?: VectorStruct; +}; + +export type VectorMatch = { record: VectorRecord; score: number }; + +export type CreateCollectionOptions = { + metric?: DistanceMetric; + metadata?: VectorMetadata; +}; + +export type GetCollectionResponseAlpha1 = { + collection: string; + /** Protobuf uint64 values remain strings to avoid loss of precision. */ + recordCount: string; + properties: VectorMetadata; + dimensions: number; + metric: DistanceMetric; +}; + +export type ListCollectionsResponseAlpha1 = { collections: string[] }; +export type UpsertVectorsResponseAlpha1 = { failedItems: FailedItem[]; ack: IndexAck }; +export type DeleteVectorsResponseAlpha1 = { ack: IndexAck }; +export type GetVectorsResponseAlpha1 = { records: VectorRecord[] }; + +export type VectorWriteOptions = { + metadata?: VectorMetadata; + options?: IndexingOptionsAlpha1; +}; + +export type VectorQuery = ( + { vector: Pick; byId?: never } | { vector?: never; byId: string } +) & { + topK?: number; + filter?: VectorStruct; + includeValues?: boolean; + includePayload?: boolean; + metric?: DistanceMetric; + /** Inclusive cutoff in the effective metric's units, not a normalized score. */ + scoreThreshold?: number; +}; + +export type QueryVectorsResponseAlpha1 = { + matches: VectorMatch[]; + metric: DistanceMetric; +}; + +export type BatchQueryResultAlpha1 = + { response: QueryVectorsResponseAlpha1; error?: never } | { response?: never; error: FailedItem["error"] }; + +export type BatchQueryVectorsResponseAlpha1 = { results: BatchQueryResultAlpha1[] }; diff --git a/src/index.ts b/src/index.ts index 3aed939..8830dee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,4 +39,6 @@ export * from "./SecretStore"; export * from "./SecretStoreHarness"; export * from "./StateManagementHarness"; export * from "./Subscription"; +export * from "./VectorHarness"; +export * from "./VectorTypes"; export * from "./WorkflowHarness";