From ca62ae82c8d0d93be4e294bfaecdd9f711da2dfc Mon Sep 17 00:00:00 2001 From: Whit Waldo Date: Tue, 20 Oct 2026 11:15:09 -0500 Subject: [PATCH] Adding support for the search building block harness using a Meilisearch container Signed-off-by: Whit Waldo --- README.md | 77 +++++ src/Constants.ts | 1 + src/DaprContainer.ts | 66 ++++- src/MeilisearchContainer.test.ts | 63 +++++ src/MeilisearchContainer.ts | 108 +++++++ src/SearchHarness.test.ts | 471 +++++++++++++++++++++++++++++++ src/SearchHarness.ts | 242 ++++++++++++++++ src/SearchTypes.ts | 99 +++++++ src/index.ts | 3 + 9 files changed, 1123 insertions(+), 7 deletions(-) create mode 100644 src/MeilisearchContainer.test.ts create mode 100644 src/MeilisearchContainer.ts create mode 100644 src/SearchHarness.test.ts create mode 100644 src/SearchHarness.ts create mode 100644 src/SearchTypes.ts diff --git a/README.md b/README.md index ae7acca..7d2d397 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,83 @@ 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 Search Testing + +`SearchHarness` or `.withSearch()` on `DaprContainer` provisions a `search.meilisearch` component and an +authenticated Meilisearch container. Search is an upcoming alpha building block: the default `1.18.4` +runtime does not support it. Select an RC or development image that includes **both** the Search API and +the Meilisearch component using `daprRuntimeImage` or `DAPR_RUNTIME_VERSION`. No unreleased RC tag is assumed. + +```typescript +import { SearchHarness } from "@dapr/testcontainer-node"; + +// Set DAPR_RUNTIME_VERSION to a supporting RC/development tag before running. +await using harness = await new SearchHarness().start(); +await harness.createIndex("books", { + searchableAttributes: "title", + filterableAttributes: "category", + sortableAttributes: "price", +}); + +const result = await harness.indexDocuments( + "books", + [{ id: "one", content: Buffer.from(JSON.stringify({ title: "Dapr guide", category: "guide", price: 10 })) }], + { + 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 page = await harness.search("books", { + text: "Dapr", + filter: { category: { $eq: "guide" } }, + includeContent: true, + topK: 10, +}); +console.log(page.hits.map((hit) => JSON.parse(hit.document.content.toString("utf8")))); +``` + +The helpers follow the [Search proto](https://github.com/dapr/dapr/blob/master/dapr/proto/runtime/v1/search.proto) +through the `v1.0-alpha1` HTTP API; the Node SDK does not yet expose these operations. +They cover index creation/listing/retrieval/deletion, document indexing/retrieval/deletion, and search +(portable filters, native queries, sorting, projection, highlighting, and continuation tokens). +Document content is a `Buffer` containing UTF-8 JSON object bytes, automatically base64-encoded on the wire. +Content is omitted by default for reads; pass `includeContent: true` to retrieve it. Found documents are +returned in request order, with missing IDs omitted. Document metadata is opaque and not searchable. +Counts (`documentCount`, `totalHits`) remain protobuf JSON strings to preserve uint64 precision. + +Writes, including document deletion, default to return-on-acceptance. `INDEX_ACK_QUEUED` does not mean +the data is visible yet. Use explicit wait-for-completion options for deterministic assertions, and check +`failedItems` even when the request succeeds. Wait timeouts must be positive and shorter than any +applicable request/resiliency deadline. HTTP failures throw with the status and response body. + +Use `searchStoreName`, `meilisearchContainer: new MeilisearchContainer(customImage)`, `apiKey`, or the +component's `timeout` (a Go duration) to customize provisioning. The default image is +`getmeili/meilisearch:v1.37.0`, with a randomly generated master key and analytics disabled. +Alternatively, supply `meilisearchHost` (a URL reachable **from the sidecar**) and `apiKey` to use an +external provider without starting one. Do not combine `meilisearchHost` and `meilisearchContainer`. +The harness stops its managed containers and any network it creates, but leaves caller-supplied networks +and external providers running. `getStartedDaprContainer().getMeilisearchContainer()` exposes the managed +provider's host endpoint and API key for backend-specific assertions. + +The Search integration suite is explicitly opt-in until a supporting RC is available. In PowerShell: + +```powershell +$env:DAPR_SEARCH_RUNTIME_IMAGE = "daprio/daprd:edge" # Or a supporting RC image when available +npm test -- --runInBand --runTestsByPath src\SearchHarness.test.ts src\MeilisearchContainer.test.ts +``` + +`edge` is a moving development tag, not a pinned release. `DAPR_SEARCH_RUNTIME_IMAGE` selects the sidecar +image for these tests only. Without it, Search integration +tests are skipped; unit tests and the standalone Meilisearch container test still run. An explicitly +selected unsupported image fails rather than silently skipping the integration checks. + ## Dapr Workflow Testing You can use `WorkflowHarness` or `.withWorkflow()` on `DaprContainer` to test Dapr Workflows with an automated Redis actor state store, placement service, and scheduler service: diff --git a/src/Constants.ts b/src/Constants.ts index e00dda9..eab8e98 100644 --- a/src/Constants.ts +++ b/src/Constants.ts @@ -69,6 +69,7 @@ export function getDaprSchedulerImage(version: string = getDaprVersion()): strin export const DaprComponentNames = { StateManagementComponentName: "statestore", BinaryStoreComponentName: "binarystore", + SearchComponentName: "search", PubSubComponentName: "pubsub", ConversationComponentName: "conversation", CryptographyComponentName: "cryptography", diff --git a/src/DaprContainer.ts b/src/DaprContainer.ts index f185b34..8eed752 100644 --- a/src/DaprContainer.ts +++ b/src/DaprContainer.ts @@ -41,6 +41,7 @@ import { DaprSchedulerContainer } from "./DaprSchedulerContainer"; import { DaprTelemetryCollector } from "./DaprTelemetryCollector"; import { HttpEndpoint } from "./HttpEndpoint"; import { MetadataEntry } from "./MetadataEntry"; +import { MEILISEARCH_DEFAULT_PORT, MeilisearchContainer, StartedMeilisearchContainer } from "./MeilisearchContainer"; import { OLLAMA_DEFAULT_MODEL, OLLAMA_DEFAULT_PORT, OllamaContainer, StartedOllamaContainer } from "./OllamaContainer"; import { RABBITMQ_DEFAULT_PASSWORD, @@ -114,6 +115,14 @@ export type BinaryStoreOptions = { maxTotalSize?: string; }; +export type SearchOptions = { + searchStoreName?: string; + meilisearchContainer?: MeilisearchContainer; + meilisearchHost?: string; + apiKey?: string; + timeout?: string; +}; + export type ActorOptions = { stateStoreName?: string; redisContainer?: RedisContainer; @@ -162,6 +171,8 @@ export class DaprContainer extends GenericContainer { private schedulerContainer?: DaprSchedulerContainer; private redisContainer?: RedisContainer; private ollamaContainer?: OllamaContainer; + private meilisearchContainer?: MeilisearchContainer; + private searchEnabled = false; private shouldReusePlacement = false; private shouldReuseScheduler = false; private shouldReuseRedis = false; @@ -246,24 +257,21 @@ export class DaprContainer extends GenericContainer { throw new Error("ollamaPort requires ollamaHost or ollamaEndpoint."); } if (!this.placementContainer) { - const container = new DaprPlacementContainer(this.placementImage) - .withNetwork(this.startedNetwork) - .withNetworkAliases(this.placementService); + const container = new DaprPlacementContainer(this.placementImage).withNetworkAliases(this.placementService); if (this.shouldReusePlacement) { container.withReuse().withAutoRemove(false); } this.placementContainer = container; } if (!this.schedulerContainer) { - const container = new DaprSchedulerContainer(this.schedulerImage) - .withNetwork(this.startedNetwork) - .withNetworkAliases(this.schedulerService); + const container = new DaprSchedulerContainer(this.schedulerImage).withNetworkAliases(this.schedulerService); if (this.shouldReuseScheduler) { container.withReuse().withAutoRemove(false); } this.schedulerContainer = container; } - this.schedulerContainer.withBroadcastHost(this.schedulerService); + this.placementContainer.withNetwork(this.startedNetwork); + this.schedulerContainer.withNetwork(this.startedNetwork).withBroadcastHost(this.schedulerService); const startedContainers: StartedTestContainer[] = []; const startContainer = async (container: GenericContainer): Promise => { @@ -343,6 +351,11 @@ export class DaprContainer extends GenericContainer { } } + if (this.meilisearchContainer) { + this.meilisearchContainer.withNetwork(this.startedNetwork).withNetworkAliases("meilisearch"); + startTasks.push(startContainer(this.meilisearchContainer)); + } + this.telemetryConfigurationLocked = true; try { const containers = await Promise.all(startTasks); @@ -925,6 +938,39 @@ export class DaprContainer extends GenericContainer { return this; } + /** Requires a runtime image containing the alpha Search API and search.meilisearch component. */ + withSearch(options: SearchOptions = {}): this { + if (this.searchEnabled) { + throw new Error("Search has already been configured on this DaprContainer."); + } + 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); + } + const component = MeilisearchContainer.createSearchComponent({ + 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); + } + + getMeilisearchContainer(): MeilisearchContainer | undefined { + return this.meilisearchContainer; + } + /** * Adds an in-memory binary store. Requires a Dapr runtime supporting the Binary Store API (1.19+). */ @@ -1034,6 +1080,12 @@ export class StartedDaprContainer extends AbstractStartedContainer { return this.containers.slice(); } + getMeilisearchContainer(): StartedMeilisearchContainer | undefined { + return this.containers.find((container): container is StartedMeilisearchContainer => { + return container instanceof StartedMeilisearchContainer; + }); + } + getOllamaContainer(): StartedOllamaContainer | undefined { return this.containers.find((container): container is StartedOllamaContainer => { return container instanceof StartedOllamaContainer; diff --git a/src/MeilisearchContainer.test.ts b/src/MeilisearchContainer.test.ts new file mode 100644 index 0000000..9d0bd6c --- /dev/null +++ b/src/MeilisearchContainer.test.ts @@ -0,0 +1,63 @@ +/* +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 { Component } from "./Component"; +import { MEILISEARCH_DEFAULT_PORT, MeilisearchContainer } from "./MeilisearchContainer"; + +describe("MeilisearchContainer", () => { + it("generates an isolated master key and configures the search component", () => { + const container = new MeilisearchContainer(); + expect(container.getPort()).toBe(MEILISEARCH_DEFAULT_PORT); + expect(container.getApiKey().length).toBeGreaterThanOrEqual(16); + expect(container.getApiKey()).not.toBe(new MeilisearchContainer().getApiKey()); + const component = container.createSearchComponent(); + expect(component).toBeInstanceOf(Component); + expect(component.name).toBe("search"); + expect(component.type).toBe("search.meilisearch"); + expect(component.version).toBe("v1"); + expect(component.getMetadata()).toEqual([ + { name: "host", value: "http://meilisearch:7700" }, + { name: "apiKey", value: container.getApiKey() }, + ]); + }); + + it("supports a custom image, key, and component metadata", () => { + const container = new MeilisearchContainer("getmeili/meilisearch:custom").withApiKey("test-master-key-1234"); + expect( + container.createSearchComponent({ name: "books", host: "http://custom:7700", timeout: "10s" }).getMetadata() + ).toEqual([ + { name: "host", value: "http://custom:7700" }, + { name: "apiKey", value: "test-master-key-1234" }, + { name: "timeout", value: "10s" }, + ]); + expect(() => container.withApiKey("short")).toThrow("at least 16"); + }); + + it("supports externally managed stores without a key", () => { + expect(MeilisearchContainer.createSearchComponent().getMetadata()).toEqual([ + { name: "host", value: "http://localhost:7700" }, + { name: "apiKey", value: "" }, + ]); + }); + + it("starts an authenticated Meilisearch server", async () => { + await using started = await new MeilisearchContainer().start(); + expect((await fetch(`${started.getHttpEndpoint()}/health`)).status).toBe(200); + const response = await fetch(`${started.getHttpEndpoint()}/indexes`, { + headers: { Authorization: `Bearer ${started.getApiKey()}` }, + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ results: [] }); + expect((await fetch(`${started.getHttpEndpoint()}/indexes`)).status).toBe(401); + }, 180_000); +}); diff --git a/src/MeilisearchContainer.ts b/src/MeilisearchContainer.ts new file mode 100644 index 0000000..18a234e --- /dev/null +++ b/src/MeilisearchContainer.ts @@ -0,0 +1,108 @@ +/* +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 { randomBytes } from "node:crypto"; +import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers"; +import { Component, MetadataEntry } from "./Component"; +import { DaprComponentNames } from "./Constants"; + +export const MEILISEARCH_DEFAULT_IMAGE = "getmeili/meilisearch:v1.37.0"; +export const MEILISEARCH_DEFAULT_PORT = 7700; + +export type MeilisearchStoreOptions = { + name?: string; + host?: string; + apiKey?: string; + timeout?: string; +}; + +export class MeilisearchContainer extends GenericContainer { + private apiKey = randomBytes(24).toString("hex"); + + constructor(image: string = MEILISEARCH_DEFAULT_IMAGE) { + super(image); + this.withEnvironment({ + MEILI_ENV: "production", + MEILI_MASTER_KEY: this.apiKey, + MEILI_NO_ANALYTICS: "true", + }) + .withExposedPorts(MEILISEARCH_DEFAULT_PORT) + .withWaitStrategy(Wait.forHttp("/health", MEILISEARCH_DEFAULT_PORT).forStatusCode(200)) + .withStartupTimeout(120_000); + } + + public withApiKey(apiKey: string): this { + if (apiKey.length < 16) { + throw new Error("Meilisearch master key must contain at least 16 characters."); + } + this.apiKey = apiKey; + return this.withEnvironment({ MEILI_MASTER_KEY: apiKey }); + } + + public getApiKey(): string { + return this.apiKey; + } + + public getPort(): number { + return MEILISEARCH_DEFAULT_PORT; + } + + public createSearchComponent(options: MeilisearchStoreOptions = {}): Component { + return MeilisearchContainer.createSearchComponent({ + ...options, + host: options.host ?? `http://meilisearch:${this.getPort()}`, + apiKey: options.apiKey ?? this.apiKey, + }); + } + + public static createSearchComponent(options: MeilisearchStoreOptions = {}): Component { + const name = options.name ?? DaprComponentNames.SearchComponentName; + 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."); + } + const host = options.host ?? `http://localhost:${MEILISEARCH_DEFAULT_PORT}`; + const url = new URL(host); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Meilisearch host must be an HTTP or HTTPS URL."); + } + const metadata: MetadataEntry[] = [ + { name: "host", value: host }, + { name: "apiKey", value: options.apiKey ?? "" }, + ]; + if (options.timeout !== undefined) { + metadata.push({ name: "timeout", value: options.timeout }); + } + return new Component(name, "search.meilisearch", "v1", metadata); + } + + public override async start(): Promise { + return new StartedMeilisearchContainer(await super.start(), this.apiKey); + } +} + +export class StartedMeilisearchContainer extends AbstractStartedContainer { + constructor( + startedTestContainer: StartedTestContainer, + private readonly apiKey: string + ) { + super(startedTestContainer); + } + + public getHttpEndpoint(): string { + return `http://${this.getHost()}:${this.getMappedPort(MEILISEARCH_DEFAULT_PORT)}`; + } + + public getApiKey(): string { + return this.apiKey; + } +} diff --git a/src/SearchHarness.test.ts b/src/SearchHarness.test.ts new file mode 100644 index 0000000..9237fb3 --- /dev/null +++ b/src/SearchHarness.test.ts @@ -0,0 +1,471 @@ +/* +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, + SearchHarness, + SearchQuery, +} from "./index"; + +const waitForCompletion: IndexingOptionsAlpha1 = { + mode: "INDEXING_MODE_WAIT_FOR_COMPLETION", + waitTimeout: "10s", + onWaitTimeout: "INDEXING_WAIT_TIMEOUT_ACTION_FAIL_REQUEST", +}; + +describe("SearchHarness", () => { + afterEach(() => jest.restoreAllMocks()); + + it("registers a managed Meilisearch component by default", () => { + const harness = new SearchHarness(); + const dapr = harness.getDaprContainer(); + expect(harness.getSearchStoreName()).toBe("search"); + expect(dapr.getAppName()).toBe("search-app"); + expect(dapr.getMeilisearchContainer()).toBeInstanceOf(MeilisearchContainer); + expect(dapr.getComponents()[0]).toMatchObject({ name: "search", type: "search.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, container and component settings", () => { + const meilisearchContainer = new MeilisearchContainer(); + const harness = new SearchHarness({ + appId: "books-app", + appPort: 8080, + appChannelAddress: "host.testcontainers.internal", + daprLogLevel: "debug", + daprApiLoggingEnabled: true, + daprRuntimeImage: "daprio/daprd:edge", + searchStoreName: "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.getSearchStoreName()).toBe("books"); + expect(dapr.getComponents()[0].getMetadata()).toContainEqual({ name: "timeout", value: "20s" }); + }); + + it("supports direct registration and an external provider without creating a container", () => { + const dapr = new DaprContainer(); + expect(dapr.withSearch({ 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.withSearch()).toThrow("already been configured"); + expect(() => + new DaprContainer().withComponent(new Component("search", "search.in-memory", "v1", [])).withSearch() + ).toThrow('A component named "search" has already been registered'); + expect( + () => + new SearchHarness({ meilisearchHost: "http://external:7700", meilisearchContainer: new MeilisearchContainer() }) + ).toThrow("not both"); + }); + + it.each(["", " ", ".", "..", "../store", "store\\name"])("rejects unsafe store name %j", (searchStoreName) => { + expect(() => new SearchHarness({ searchStoreName })).toThrow("Search store name must be"); + }); + + it.each(["", "not-a-url", "redis://localhost:7700"])("rejects invalid Meilisearch host %j", (meilisearchHost) => { + expect(() => new SearchHarness({ meilisearchHost })).toThrow(); + }); + + it("guards access before start and safely stops an unstarted harness", async () => { + const harness = new SearchHarness(); + for (const get of [ + () => harness.getStartedDaprContainer(), + () => harness.getHost(), + () => harness.getHttpPort(), + () => harness.getGrpcPort(), + () => harness.getHttpEndpoint(), + () => harness.getGrpcEndpoint(), + () => harness.createDaprClient(), + ]) { + expect(get).toThrow("SearchHarness has not been started"); + } + await expect(harness.createIndex("books")).rejects.toThrow("has not been started"); + await expect(harness.listIndexes()).rejects.toThrow("has not been started"); + await expect(harness.search("books")).rejects.toThrow("has not been started"); + await harness.stop(); + await harness[Symbol.asyncDispose](); + }); + + it("maps index operations to the alpha routes and correctly encodes metadata", async () => { + const harness = new SearchHarness({ searchStoreName: "my store" }); + jest.spyOn(harness, "getHttpEndpoint").mockReturnValue("http://localhost:3500"); + const fetchMock = jest.spyOn(globalThis, "fetch").mockResolvedValue(new Response("{}")); + const metadata = { filterableAttributes: "category,price", "special&key": "a b&c" }; + const base = "http://localhost:3500/v1.0-alpha1/search/my%20store/indexes"; + await harness.createIndex("books 100%", metadata); + expect(fetchMock).toHaveBeenLastCalledWith(`${base}/books%20100%25`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ metadata }), + }); + fetchMock.mockResolvedValueOnce( + Response.json({ index: "books", documentCount: "9007199254740993", properties: {} }) + ); + expect((await harness.getIndex("books", metadata)).documentCount).toBe("9007199254740993"); + expect(fetchMock).toHaveBeenLastCalledWith( + `${base}/books?metadata.filterableAttributes=category%2Cprice&metadata.special%26key=a+b%26c`, + { method: "GET" } + ); + fetchMock.mockResolvedValueOnce(Response.json({ indexes: ["books"] })); + expect(await harness.listIndexes({ tenant: "a/b" })).toEqual({ indexes: ["books"] }); + expect(fetchMock).toHaveBeenLastCalledWith(`${base}?metadata.tenant=a%2Fb`, { method: "GET" }); + await harness.deleteIndex("books", { tenant: "a/b" }); + expect(fetchMock).toHaveBeenLastCalledWith(`${base}/books?metadata.tenant=a%2Fb`, { method: "DELETE" }); + }); + + it("encodes document bytes and preserves acknowledgements and per-item errors", async () => { + const harness = new SearchHarness(); + jest.spyOn(harness, "getHttpEndpoint").mockReturnValue("http://localhost:3500"); + const failedItems = [{ id: "invalid", error: { code: 3, message: "content must be an object", details: [] } }]; + const result = { ack: "INDEX_ACK_QUEUED", failedItems }; + const fetchMock = jest.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(result)); + const document = { id: "one", content: Buffer.from('{"title":"caf\\u00e9"}'), metadata: { source: "test" } }; + expect(await harness.indexDocuments("books", [document])).toEqual(result); + expect(fetchMock).toHaveBeenLastCalledWith( + "http://localhost:3500/v1.0-alpha1/search/search/indexes/books/documents", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ documents: [{ ...document, content: document.content.toString("base64") }] }), + }) + ); + fetchMock.mockResolvedValueOnce(Response.json({ ack: "INDEX_ACK_COMPLETED", failedItems: [] })); + await harness.indexDocuments("books", [document], { metadata: { tenant: "test" }, options: waitForCompletion }); + expect(JSON.parse(fetchMock.mock.calls[1][1]!.body as string)).toMatchObject({ + metadata: { tenant: "test" }, + options: waitForCompletion, + }); + fetchMock.mockResolvedValueOnce(Response.json({ ack: "INDEX_ACK_COMPLETED" })); + expect(await harness.deleteDocuments("books", ["one"], { options: waitForCompletion })).toEqual({ + ack: "INDEX_ACK_COMPLETED", + }); + expect(fetchMock).toHaveBeenLastCalledWith( + "http://localhost:3500/v1.0-alpha1/search/search/indexes/books/documents/delete", + expect.objectContaining({ method: "POST", body: JSON.stringify({ options: waitForCompletion, ids: ["one"] }) }) + ); + }); + + it("decodes document content without losing metadata, hit scores or pagination fields", async () => { + const harness = new SearchHarness(); + jest.spyOn(harness, "getHttpEndpoint").mockReturnValue("http://localhost:3500"); + const content = Buffer.from('{"title":"alpha"}'); + const document = { id: "one", content: content.toString("base64"), metadata: { source: "test" } }; + const fetchMock = jest.spyOn(globalThis, "fetch").mockResolvedValueOnce(Response.json({ documents: [document] })); + expect(await harness.getDocuments("books", ["missing", "one"], { includeContent: true })).toEqual({ + documents: [{ ...document, content }], + }); + expect(fetchMock).toHaveBeenLastCalledWith( + "http://localhost:3500/v1.0-alpha1/search/search/indexes/books/documents/get", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ includeContent: true, ids: ["missing", "one"] }), + }) + ); + const result = { + hits: [{ document, score: 0.95, highlights: { title: "alpha" } }], + totalHits: "9007199254740993", + totalHitsRelation: "TOTAL_HITS_RELATION_ESTIMATE", + continuationToken: "opaque-token", + }; + fetchMock.mockResolvedValueOnce(Response.json(result)); + const query: SearchQuery = { + text: "alpha", + filter: { $and: [{ category: { $in: ["guide"] } }, { price: { $gte: 10 } }] }, + topK: 1, + continuationToken: "previous-token", + includeContent: true, + searchFields: ["title"], + returnFields: ["title"], + highlightFields: ["title"], + sort: [{ field: "price", order: "SORT_ORDER_DESC" }], + }; + expect(await harness.search("books", query, { tenant: "test" })).toEqual({ + ...result, + hits: [{ ...result.hits[0], document: { ...document, content } }], + }); + expect(fetchMock).toHaveBeenLastCalledWith( + "http://localhost:3500/v1.0-alpha1/search/search/indexes/books/query", + expect.objectContaining({ body: JSON.stringify({ query, metadata: { tenant: "test" } }) }) + ); + }); + + it("preserves native queries, omitted queries, empty content and absent optional totals", async () => { + const harness = new SearchHarness(); + jest.spyOn(harness, "getHttpEndpoint").mockReturnValue("http://localhost:3500"); + const result = { + hits: [{ document: { id: "one", content: "", metadata: {} }, score: 0, highlights: {} }], + totalHitsRelation: "TOTAL_HITS_RELATION_UNSPECIFIED", + }; + const fetchMock = jest.spyOn(globalThis, "fetch").mockImplementation(async () => Response.json(result)); + const response = await harness.search("books"); + expect(response.hits[0].document.content).toEqual(Buffer.alloc(0)); + expect(response).not.toHaveProperty("totalHits"); + expect(response).not.toHaveProperty("continuationToken"); + expect(fetchMock.mock.calls[0][1]!.body).toBe("{}"); + await harness.search("books", { native: { q: "alpha", matchingStrategy: "all" } }); + expect(fetchMock.mock.calls[1][1]!.body).toBe( + JSON.stringify({ query: { native: { q: "alpha", matchingStrategy: "all" } } }) + ); + }); + + it.each(["", " ", ".", "..", "../books", "books\\data"])( + "rejects unsafe index %j before sending requests", + async (index) => { + const harness = new SearchHarness(); + const fetchMock = jest.spyOn(globalThis, "fetch"); + await expect(harness.getIndex(index)).rejects.toThrow("Search index must be"); + expect(fetchMock).not.toHaveBeenCalled(); + } + ); + + it.each([400, 404, 409, 500])("surfaces runtime status %i and its error body", async (status) => { + const harness = new SearchHarness(); + jest.spyOn(harness, "getHttpEndpoint").mockReturnValue("http://localhost:3500"); + jest.spyOn(globalThis, "fetch").mockResolvedValue(new Response("runtime error", { status })); + await expect(harness.search("books")).rejects.toThrow(`Search POST request failed (${status}): runtime error`); + }); + + it("propagates transport errors and malformed JSON", async () => { + const harness = new SearchHarness(); + jest.spyOn(harness, "getHttpEndpoint").mockReturnValue("http://localhost:3500"); + const fetchMock = jest.spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("connection failed")); + await expect(harness.listIndexes()).rejects.toThrow("connection failed"); + fetchMock.mockResolvedValueOnce(new Response("{")); + await expect(harness.listIndexes()).rejects.toThrow(); + }); +}); + +// Opt in with an image known to contain Search; no unreleased RC number is assumed. +const searchRuntimeImage = process.env.DAPR_SEARCH_RUNTIME_IMAGE?.trim(); +const itSearch = searchRuntimeImage ? it : it.skip; + +describe("Search integration (requires DAPR_SEARCH_RUNTIME_IMAGE)", () => { + afterEach(() => jest.restoreAllMocks()); + + itSearch( + "cleans up its provider and network when sidecar startup fails, then permits retry", + async () => { + const harness = new SearchHarness({ daprRuntimeImage: searchRuntimeImage }); + 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.createIndex("retry"); + expect((await harness.listIndexes()).indexes).toContain("retry"); + } finally { + await harness.stop(); + } + }, + 240_000 + ); + + itSearch( + "exercises the search contract against a managed Meilisearch container", + async () => { + const harness = new SearchHarness({ daprRuntimeImage: searchRuntimeImage, searchStoreName: "bookstore" }); + let stopNetwork: jest.SpyInstance | undefined; + const startNetwork = Network.prototype.start; + jest.spyOn(Network.prototype, "start").mockImplementation(async function (this: Network) { + const network = await startNetwork.call(this); + stopNetwork = jest.spyOn(network, "stop"); + return network; + }); + try { + await harness.start(); + expect(await harness.start()).toBe(harness); + const started = harness.getStartedDaprContainer(); + expect(started.getMeilisearchContainer()).toBeDefined(); + expect(started.getContainers()).toHaveLength(3); + expect(harness.getHost()).toBeDefined(); + expect(harness.getHttpPort()).toBeGreaterThan(0); + expect(harness.getGrpcPort()).toBeGreaterThan(0); + expect(harness.getGrpcEndpoint()).toContain(harness.getGrpcPort().toString()); + await harness.createDaprClient().start(); + await harness.createDaprClient({ communicationProtocol: CommunicationProtocolEnum.GRPC }).start(); + await harness.createIndex("books", { + searchableAttributes: "title", + filterableAttributes: "category,price", + sortableAttributes: "price", + }); + expect((await harness.listIndexes()).indexes).toContain("books"); + expect(await harness.getIndex("books")).toMatchObject({ index: "books", documentCount: "0" }); + await expect(harness.createIndex("books")).rejects.toThrow("(409)"); + const documents = [ + { + id: "one", + content: Buffer.from('{"title":"alpha guide","category":"guide","price":10}'), + metadata: { source: "opaque-marker" }, + }, + { id: "two", content: Buffer.from('{"title":"beta guide","category":"guide","price":20}') }, + { id: "three", content: Buffer.from('{"title":"alpha reference","category":"reference","price":30}') }, + ]; + const indexed = await harness.indexDocuments( + "books", + [...documents, { id: "invalid", content: Buffer.from('["not","an","object"]') }], + { 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.getIndex("books")).documentCount).toBe("3"); + const got = await harness.getDocuments("books", ["three", "missing", "one", "invalid"], { + includeContent: true, + }); + expect(got.documents.map((document) => document.id)).toEqual(["three", "one"]); + expect(JSON.parse(got.documents[1].content.toString())).toEqual(JSON.parse(documents[0].content.toString())); + expect(got.documents[1].metadata).toEqual({ source: "opaque-marker" }); + expect((await harness.getDocuments("books", ["one"])).documents[0].content).toEqual(Buffer.alloc(0)); + expect((await harness.search("books", { text: "opaque-marker" })).hits).toEqual([]); + const filtered = await harness.search("books", { + text: "alpha", + filter: { category: { $eq: "guide" } }, + includeContent: true, + returnFields: ["title"], + searchFields: ["title"], + highlightFields: ["title"], + }); + expect(filtered.hits.map((hit) => hit.document.id)).toEqual(["one"]); + expect(JSON.parse(filtered.hits[0].document.content.toString())).toEqual({ title: "alpha guide" }); + expect(filtered.hits[0].highlights.title).toContain("alpha"); + const query: SearchQuery = { topK: 2, sort: [{ field: "price", order: "SORT_ORDER_DESC" }] }; + const page1 = await harness.search("books", query); + expect(page1.hits.map((hit) => hit.document.id)).toEqual(["three", "two"]); + expect(page1.totalHits).toBe("3"); + expect(page1.totalHitsRelation).not.toBe("TOTAL_HITS_RELATION_UNSPECIFIED"); + expect(page1.continuationToken).toBeTruthy(); + const page2 = await harness.search("books", { ...query, continuationToken: page1.continuationToken }); + expect(page2.hits.map((hit) => hit.document.id)).toEqual(["one"]); + expect(page2.continuationToken).toBeUndefined(); + expect((await harness.search("books", { native: { q: "beta" } })).hits.map((hit) => hit.document.id)).toEqual([ + "two", + ]); + const updated = await harness.indexDocuments( + "books", + [{ id: "one", content: Buffer.from('{"title":"updated","category":"guide","price":15}') }], + { options: waitForCompletion } + ); + expect(updated).toEqual({ ack: "INDEX_ACK_COMPLETED", failedItems: [] }); + expect((await harness.search("books", { text: "updated" })).hits.map((hit) => hit.document.id)).toEqual([ + "one", + ]); + expect(await harness.deleteDocuments("books", ["one", "missing"], { options: waitForCompletion })).toEqual({ + ack: "INDEX_ACK_COMPLETED", + }); + expect((await harness.getDocuments("books", ["one"])).documents).toEqual([]); + const queued = await harness.indexDocuments("books", [ + { id: "queued", content: Buffer.from('{"title":"queued"}') }, + ]); + expect(queued.ack).toBe("INDEX_ACK_QUEUED"); + expect(queued.failedItems).toEqual([]); + const deadline = Date.now() + 10_000; + while ((await harness.getDocuments("books", ["queued"])).documents.length === 0) { + if (Date.now() >= deadline) { + throw new Error("Queued document was not visible within 10 seconds."); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + await harness.deleteIndex("books"); + expect((await harness.listIndexes()).indexes).not.toContain("books"); + await expect(harness.getIndex("books")).rejects.toThrow("(404)"); + } finally { + await harness[Symbol.asyncDispose](); + } + expect(stopNetwork).toHaveBeenCalledTimes(1); + expect(() => harness.getStartedDaprContainer()).toThrow("has not been started"); + await harness.stop(); + }, + 240_000 + ); + + itSearch( + "leaves a caller-owned network and external Meilisearch running", + async () => { + await using network = await new Network().start(); + const stopNetwork = jest.spyOn(network, "stop"); + await using meilisearch = await new MeilisearchContainer() + .withNetwork(network) + .withNetworkAliases("external-search") + .start(); + const harness = new SearchHarness({ + daprRuntimeImage: searchRuntimeImage, + network, + meilisearchHost: "http://external-search:7700", + apiKey: meilisearch.getApiKey(), + }); + try { + await harness.start(); + expect(harness.getStartedDaprContainer().getMeilisearchContainer()).toBeUndefined(); + await harness.createIndex("external"); + } finally { + await harness.stop(); + } + expect(stopNetwork).not.toHaveBeenCalled(); + expect((await fetch(`${meilisearch.getHttpEndpoint()}/health`)).status).toBe(200); + const dapr = new DaprContainer(searchRuntimeImage).withNetwork(network).withSearch({ + meilisearchHost: "http://external-search:7700", + apiKey: meilisearch.getApiKey(), + }); + await using started = await dapr.start(); + const response = await fetch(`${started.getHttpEndpoint()}/v1.0-alpha1/search/search/indexes`); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ indexes: ["external"] }); + }, + 240_000 + ); +}); diff --git a/src/SearchHarness.ts b/src/SearchHarness.ts new file mode 100644 index 0000000..88dc881 --- /dev/null +++ b/src/SearchHarness.ts @@ -0,0 +1,242 @@ +/* +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, SearchOptions, StartedDaprContainer } from "./DaprContainer"; +import { + DeleteDocumentsResponseAlpha1, + GetDocumentsResponseAlpha1, + GetIndexResponseAlpha1, + IndexDocumentsResponseAlpha1, + ListIndexesResponseAlpha1, + SearchDocument, + SearchMetadata, + SearchQuery, + SearchResponseAlpha1, + SearchWriteOptions, +} from "./SearchTypes"; + +export type SearchHarnessOptions = SearchOptions & { + appId?: string; + appPort?: number; + appChannelAddress?: string; + daprLogLevel?: string; + daprApiLoggingEnabled?: boolean; + daprRuntimeImage?: string; + network?: StartedNetwork; +}; + +type WireDocument = Omit & { content: string }; + +/** Exercises Dapr's alpha Search API against Meilisearch, without requiring SDK search bindings. */ +export class SearchHarness { + private network?: StartedNetwork; + private ownsNetwork = false; + private readonly daprContainer: DaprContainer; + private startedDaprContainer?: StartedDaprContainer; + private daprClients: DaprClient[] = []; + + constructor(private readonly options: SearchHarnessOptions = {}) { + this.daprContainer = new DaprContainer(options.daprRuntimeImage) + .withAppName(options.appId ?? "search-app") + .withDaprLogLevel(options.daprLogLevel ?? "info") + .withDaprApiLoggingEnabled(options.daprApiLoggingEnabled ?? false) + .withSearch(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 getSearchStoreName(): string { + return this.options.searchStoreName ?? DaprComponentNames.SearchComponentName; + } + + public getStartedDaprContainer(): StartedDaprContainer { + if (!this.startedDaprContainer) { + throw new Error("SearchHarness 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 createIndex(index: string, metadata?: SearchMetadata): Promise { + await this.request("POST", this.indexPath(index), { metadata }); + } + + public async getIndex(index: string, metadata?: SearchMetadata): Promise { + const response = await this.request("GET", this.indexPath(index), undefined, metadata); + return response.json(); + } + + public async listIndexes(metadata?: SearchMetadata): Promise { + const response = await this.request("GET", "/indexes", undefined, metadata); + return response.json(); + } + + public async deleteIndex(index: string, metadata?: SearchMetadata): Promise { + await this.request("DELETE", this.indexPath(index), undefined, metadata); + } + + public async indexDocuments( + index: string, + documents: SearchDocument[], + options: SearchWriteOptions = {} + ): Promise { + const response = await this.request("POST", `${this.indexPath(index)}/documents`, { + ...options, + documents: documents.map((document) => ({ ...document, content: document.content.toString("base64") })), + }); + return response.json(); + } + + public async getDocuments( + index: string, + ids: string[], + options: { includeContent?: boolean; metadata?: SearchMetadata } = {} + ): Promise { + const response = await this.request("POST", `${this.indexPath(index)}/documents/get`, { ...options, ids }); + const result: { documents: WireDocument[] } = await response.json(); + return { ...result, documents: result.documents.map(SearchHarness.decodeDocument) }; + } + + public async deleteDocuments( + index: string, + ids: string[], + options: SearchWriteOptions = {} + ): Promise { + const response = await this.request("POST", `${this.indexPath(index)}/documents/delete`, { ...options, ids }); + return response.json(); + } + + public async search(index: string, query?: SearchQuery, metadata?: SearchMetadata): Promise { + const response = await this.request("POST", `${this.indexPath(index)}/query`, { query, metadata }); + const result: Omit & { + hits: (Omit & { document: WireDocument })[]; + } = await response.json(); + return { + ...result, + hits: result.hits.map((hit) => ({ ...hit, document: SearchHarness.decodeDocument(hit.document) })), + }; + } + + public async [Symbol.asyncDispose](): Promise { + await this.stop(); + } + + private static decodeDocument(document: WireDocument): SearchDocument { + return { ...document, content: Buffer.from(document.content, "base64") }; + } + + private indexPath(index: string): string { + if (!index.trim() || /[/\\]/.test(index) || index === "." || index === "..") { + throw new Error("Search index must be non-empty and must not contain path separators or dot segments."); + } + return `/indexes/${encodeURIComponent(index)}`; + } + + private async request(method: string, path: string, body?: object, metadata?: SearchMetadata): Promise { + const url = new URL( + `${this.getHttpEndpoint()}/v1.0-alpha1/search/${encodeURIComponent(this.getSearchStoreName())}${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(`Search ${method} request failed (${response.status}): ${await response.text()}`); + } + return response; + } +} diff --git a/src/SearchTypes.ts b/src/SearchTypes.ts new file mode 100644 index 0000000..47cd075 --- /dev/null +++ b/src/SearchTypes.ts @@ -0,0 +1,99 @@ +/* +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. +*/ + +/** JSON-compatible values used by the proto's google.protobuf.Struct fields. */ +export type SearchValue = string | number | boolean | null | SearchValue[] | { [key: string]: SearchValue }; +export type SearchStruct = { [key: string]: SearchValue }; +export type SearchMetadata = Record; + +export type SearchDocument = { + id: string; + /** UTF-8 JSON object bytes; the harness handles base64 encoding on the wire. */ + content: Buffer; + metadata?: SearchMetadata; +}; + +export type SearchHit = { + document: SearchDocument; + score: number; + highlights: Record; +}; + +export type IndexAck = "INDEX_ACK_QUEUED" | "INDEX_ACK_COMPLETED"; +export type SortOrder = "SORT_ORDER_UNSPECIFIED" | "SORT_ORDER_ASC" | "SORT_ORDER_DESC"; +export type TotalHitsRelation = + | "TOTAL_HITS_RELATION_UNSPECIFIED" + | "TOTAL_HITS_RELATION_EXACT" + | "TOTAL_HITS_RELATION_LOWER_BOUND" + | "TOTAL_HITS_RELATION_ESTIMATE"; + +export type IndexingOptionsAlpha1 = + | { + mode?: "INDEXING_MODE_UNSPECIFIED" | "INDEXING_MODE_RETURN_ON_ACCEPTANCE"; + waitTimeout?: never; + onWaitTimeout?: never; + } + | { + mode: "INDEXING_MODE_WAIT_FOR_COMPLETION"; + /** Positive protobuf JSON duration, e.g. "10s"; shorter than the request/resiliency deadline. */ + waitTimeout: string; + onWaitTimeout: "INDEXING_WAIT_TIMEOUT_ACTION_CONTINUE_ASYNC" | "INDEXING_WAIT_TIMEOUT_ACTION_FAIL_REQUEST"; + }; + +export type FailedItem = { + id: string; + error: { + code: number; + message: string; + details?: SearchStruct[]; + }; +}; + +export type IndexDocumentsResponseAlpha1 = { + failedItems: FailedItem[]; + ack: IndexAck; +}; + +export type DeleteDocumentsResponseAlpha1 = { ack: IndexAck }; +export type GetDocumentsResponseAlpha1 = { documents: SearchDocument[] }; +export type ListIndexesResponseAlpha1 = { indexes: string[] }; +export type GetIndexResponseAlpha1 = { + index: string; + /** Protobuf uint64 values remain strings to avoid loss of precision. */ + documentCount: string; + properties: SearchMetadata; +}; + +export type SearchQuery = ({ text?: string; native?: never } | { text?: never; native: SearchStruct }) & { + filter?: SearchStruct; + topK?: number; + continuationToken?: string; + returnFields?: string[]; + includeContent?: boolean; + searchFields?: string[]; + sort?: { field: string; order: SortOrder }[]; + highlightFields?: string[]; +}; + +export type SearchResponseAlpha1 = { + hits: SearchHit[]; + /** Protobuf uint64 values remain strings to avoid loss of precision. */ + totalHits?: string; + continuationToken?: string; + totalHitsRelation: TotalHitsRelation; +}; + +export type SearchWriteOptions = { + metadata?: SearchMetadata; + options?: IndexingOptionsAlpha1; +}; diff --git a/src/index.ts b/src/index.ts index 2f167e9..3aed939 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,10 +28,13 @@ export * from "./HttpEndpoint"; export * from "./LocalStorageCryptographyContainer"; export * from "./JobsHarness"; export * from "./MetadataEntry"; +export * from "./MeilisearchContainer"; export * from "./OllamaContainer"; export * from "./PubSubHarness"; export * from "./RabbitMQContainer"; export * from "./RedisContainer"; +export * from "./SearchHarness"; +export * from "./SearchTypes"; export * from "./SecretStore"; export * from "./SecretStoreHarness"; export * from "./StateManagementHarness";