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
93 changes: 93 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export const DaprComponentNames = {
StateManagementComponentName: "statestore",
BinaryStoreComponentName: "binarystore",
SearchComponentName: "search",
VectorComponentName: "vector",
PubSubComponentName: "pubsub",
ConversationComponentName: "conversation",
CryptographyComponentName: "cryptography",
Expand Down
55 changes: 46 additions & 9 deletions src/DaprContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ export type SearchOptions = {
timeout?: string;
};

export type VectorOptions = Omit<SearchOptions, "searchStoreName"> & {
vectorStoreName?: string;
};

export type ActorOptions = {
stateStoreName?: string;
redisContainer?: RedisContainer;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<SearchOptions, "searchStoreName">
): 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 {
Expand Down
27 changes: 27 additions & 0 deletions src/MeilisearchContainer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
26 changes: 23 additions & 3 deletions src/MeilisearchContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<StartedMeilisearchContainer> {
Expand Down
Loading
Loading