From 108f608a8281995d6fe43f4b30c8371c3fc95ce5 Mon Sep 17 00:00:00 2001 From: akshitkrnagpal Date: Mon, 22 Jun 2026 02:10:15 +0400 Subject: [PATCH] Add search helper operations --- .changeset/search-helpers.md | 7 +++ README.md | 4 +- packages/cli/src/operation-docs.test.ts | 3 + packages/cli/src/operation-docs.ts | 23 +++++++ .../core/src/operations/documents.test.ts | 41 ++++++++++++ packages/core/src/operations/documents.ts | 17 +++++ packages/core/src/operations/index.test.ts | 3 + packages/core/src/operations/search.test.ts | 62 ++++++++++++++++++ packages/core/src/operations/search.ts | 63 +++++++++++++++++++ packages/mcp/src/read-only.test.ts | 6 ++ packages/mcp/src/read-only.ts | 3 + 11 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 .changeset/search-helpers.md create mode 100644 packages/core/src/operations/documents.test.ts create mode 100644 packages/core/src/operations/search.test.ts diff --git a/.changeset/search-helpers.md b/.changeset/search-helpers.md new file mode 100644 index 0000000..3fb6a1e --- /dev/null +++ b/.changeset/search-helpers.md @@ -0,0 +1,7 @@ +--- +"@typesensekit/core": patch +"@typesensekit/cli": patch +"@typesensekit/mcp": patch +--- + +Add document batch retrieval, facet exploration, and search suggestion helper operations. diff --git a/README.md b/README.md index 13cce95..9dc3eff 100644 --- a/README.md +++ b/README.md @@ -108,8 +108,8 @@ TypesenseKit covers the common Typesense administration and search surfaces, plu | Area | Operations | | --- | --- | | Collections | list, get, create, update, delete, schema changes | -| Documents | index, upsert, get, update, delete, import, export, search | -| Search | search, multi-search | +| Documents | index, upsert, get, get many, update, delete, import, export, search | +| Search | search, multi-search, facet exploration, suggestions | | Configuration | aliases, synonyms, overrides, stopwords, presets | | Access | API keys | | Analytics | rules and events | diff --git a/packages/cli/src/operation-docs.test.ts b/packages/cli/src/operation-docs.test.ts index 6471898..9d44cf7 100644 --- a/packages/cli/src/operation-docs.test.ts +++ b/packages/cli/src/operation-docs.test.ts @@ -43,6 +43,9 @@ describe("operation docs", () => { expect(renderOperationExamples("presets.create")).toContain( `tsk presets.create --input '{"name":"Semantic","value":{"query_by":"title_embedding"}}' --json`, ); + expect(renderOperationExamples("search.facets")).toContain( + `tsk search.facets --input '{"collection":"products","facetBy":["brand","category"],"filterBy":"in_stock:=true","maxFacetValues":20}' --json`, + ); expect(renderOperationExamples("api.call")).toContain( `tsk api.call --input '{"method":"get","path":"/collections"}' --json`, ); diff --git a/packages/cli/src/operation-docs.ts b/packages/cli/src/operation-docs.ts index fcbb08d..6d2130a 100644 --- a/packages/cli/src/operation-docs.ts +++ b/packages/cli/src/operation-docs.ts @@ -62,6 +62,12 @@ const EXAMPLES: Record = { document: { id: "sku-1", title: "Lounge chair" }, }, ], + "documents.get_many": [ + { + collection: "products", + ids: ["sku-1", "sku-2"], + }, + ], "documents.search": [ { collection: "production__products", @@ -78,6 +84,23 @@ const EXAMPLES: Record = { collections: ["products"], }, ], + "search.facets": [ + { + collection: "products", + facetBy: ["brand", "category"], + filterBy: "in_stock:=true", + maxFacetValues: 20, + }, + ], + "search.suggestions": [ + { + collection: "products", + q: "lou", + queryBy: "title,brand", + includeFields: ["title", "brand"], + limit: 5, + }, + ], "presets.create": [ { name: "Semantic", diff --git a/packages/core/src/operations/documents.test.ts b/packages/core/src/operations/documents.test.ts new file mode 100644 index 0000000..ae54bb7 --- /dev/null +++ b/packages/core/src/operations/documents.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TypesenseClient } from "../client.js"; +import { documentOperations } from "./documents.js"; + +function getOperation(name: string) { + const operation = documentOperations.find( + (candidate) => candidate.name === name, + ); + if (!operation) throw new Error(`${name} not found`); + return operation; +} + +describe("document helper operations", () => { + it("retrieves multiple documents by id", async () => { + const operation = getOperation("documents.get_many"); + const get = vi + .fn() + .mockResolvedValueOnce({ id: "sku-1" }) + .mockResolvedValueOnce({ id: "sku-2" }); + const client = { apiCall: { get } } as unknown as TypesenseClient; + + await expect( + operation.execute( + client, + operation.input.parse({ + collection: "products", + ids: ["sku-1", "sku-2"], + }), + ), + ).resolves.toEqual([{ id: "sku-1" }, { id: "sku-2" }]); + + expect(get).toHaveBeenNthCalledWith( + 1, + "/collections/products/documents/sku-1", + ); + expect(get).toHaveBeenNthCalledWith( + 2, + "/collections/products/documents/sku-2", + ); + }); +}); diff --git a/packages/core/src/operations/documents.ts b/packages/core/src/operations/documents.ts index 385e420..3b5bbf0 100644 --- a/packages/core/src/operations/documents.ts +++ b/packages/core/src/operations/documents.ts @@ -3,6 +3,7 @@ import { api, collectionPath, enc } from "./http.js"; import type { Operation } from "./types.js"; const documentSchema = z.record(z.unknown()); +const idsSchema = z.array(z.string().min(1)).min(1); const searchParams = z.record( z.union([ z.string(), @@ -47,6 +48,22 @@ export const documentOperations = [ `${collectionPath(input.collection)}/documents/${enc(input.id)}`, ), }, + { + name: "documents.get_many", + summary: "Get multiple documents by id", + category: "documents", + input: z.object({ collection: z.string(), ids: idsSchema }), + execute: async (client, input) => { + const request = api(client); + return Promise.all( + input.ids.map((id: string) => + request.get( + `${collectionPath(input.collection)}/documents/${enc(id)}`, + ), + ), + ); + }, + }, { name: "documents.update", summary: "Update a document by id", diff --git a/packages/core/src/operations/index.test.ts b/packages/core/src/operations/index.test.ts index 0efc4cc..1a848ab 100644 --- a/packages/core/src/operations/index.test.ts +++ b/packages/core/src/operations/index.test.ts @@ -9,7 +9,10 @@ describe("operation registry", () => { expect.arrayContaining([ "collections.create", "documents.search", + "documents.get_many", "multi_search", + "search.facets", + "search.suggestions", "keys.create", "analytics.events.create", "conversations.models.create", diff --git a/packages/core/src/operations/search.test.ts b/packages/core/src/operations/search.test.ts new file mode 100644 index 0000000..4f524a5 --- /dev/null +++ b/packages/core/src/operations/search.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TypesenseClient } from "../client.js"; +import { searchOperations } from "./search.js"; + +function getOperation(name: string) { + const operation = searchOperations.find( + (candidate) => candidate.name === name, + ); + if (!operation) throw new Error(`${name} not found`); + return operation; +} + +describe("search helper operations", () => { + it("runs facet exploration with focused search parameters", async () => { + const operation = getOperation("search.facets"); + const get = vi.fn().mockResolvedValue({ facet_counts: [] }); + const client = { apiCall: { get } } as unknown as TypesenseClient; + + await operation.execute( + client, + operation.input.parse({ + collection: "products", + facetBy: ["brand", "category"], + filterBy: "in_stock:=true", + maxFacetValues: 20, + }), + ); + + expect(get).toHaveBeenCalledWith("/collections/products/documents/search", { + q: "*", + filter_by: "in_stock:=true", + facet_by: "brand,category", + max_facet_values: 20, + per_page: 0, + }); + }); + + it("runs prefix suggestions with result limiting and included fields", async () => { + const operation = getOperation("search.suggestions"); + const get = vi.fn().mockResolvedValue({ hits: [] }); + const client = { apiCall: { get } } as unknown as TypesenseClient; + + await operation.execute( + client, + operation.input.parse({ + collection: "products", + q: "lou", + queryBy: "title,brand", + includeFields: ["title", "brand"], + limit: 8, + }), + ); + + expect(get).toHaveBeenCalledWith("/collections/products/documents/search", { + q: "lou", + query_by: "title,brand", + include_fields: "title,brand", + per_page: 8, + prefix: true, + }); + }); +}); diff --git a/packages/core/src/operations/search.ts b/packages/core/src/operations/search.ts index 77e2ab2..99d5621 100644 --- a/packages/core/src/operations/search.ts +++ b/packages/core/src/operations/search.ts @@ -3,6 +3,13 @@ import { api, collectionPath } from "./http.js"; import type { Operation } from "./types.js"; const searchParams = z.record(z.unknown()); +const facetBySchema = z.union([z.string().min(1), z.array(z.string().min(1))]); + +function withoutUndefined(params: Record) { + return Object.fromEntries( + Object.entries(params).filter(([, value]) => value !== undefined), + ); +} export const searchOperations = [ { @@ -31,4 +38,60 @@ export const searchOperations = [ input.commonParams, ), }, + { + name: "search.facets", + summary: "Explore facet counts for a collection", + category: "search", + input: z.object({ + collection: z.string(), + facetBy: facetBySchema, + q: z.string().optional().default("*"), + queryBy: z.string().optional(), + filterBy: z.string().optional(), + maxFacetValues: z.number().int().positive().optional(), + perPage: z.number().int().nonnegative().optional().default(0), + }), + execute: async (client, input) => + api(client).get( + `${collectionPath(input.collection)}/documents/search`, + withoutUndefined({ + q: input.q, + query_by: input.queryBy, + filter_by: input.filterBy, + facet_by: Array.isArray(input.facetBy) + ? input.facetBy.join(",") + : input.facetBy, + max_facet_values: input.maxFacetValues, + per_page: input.perPage, + }), + ), + }, + { + name: "search.suggestions", + summary: "Fetch prefix search suggestions from a collection", + category: "search", + input: z.object({ + collection: z.string(), + q: z.string(), + queryBy: z.string(), + filterBy: z.string().optional(), + includeFields: z.union([z.string(), z.array(z.string())]).optional(), + limit: z.number().int().positive().max(50).optional().default(5), + prefix: z.boolean().optional().default(true), + }), + execute: async (client, input) => + api(client).get( + `${collectionPath(input.collection)}/documents/search`, + withoutUndefined({ + q: input.q, + query_by: input.queryBy, + filter_by: input.filterBy, + include_fields: Array.isArray(input.includeFields) + ? input.includeFields.join(",") + : input.includeFields, + per_page: input.limit, + prefix: input.prefix, + }), + ), + }, ] satisfies Operation[]; diff --git a/packages/mcp/src/read-only.test.ts b/packages/mcp/src/read-only.test.ts index 6cdb824..ce130c2 100644 --- a/packages/mcp/src/read-only.test.ts +++ b/packages/mcp/src/read-only.test.ts @@ -3,7 +3,10 @@ import { filterMcpOperations, readOnlyFromEnv } from "./read-only.js"; const operations = [ { name: "search" }, + { name: "search.facets" }, + { name: "search.suggestions" }, { name: "documents.get" }, + { name: "documents.get_many" }, { name: "collections.retrieve" }, { name: "documents.index" }, { name: "collections.delete" }, @@ -15,7 +18,10 @@ describe("MCP read-only operation filtering", () => { it("keeps read-only tools and hides write, secret, and raw API tools", () => { expect(filterMcpOperations(operations, true).map((op) => op.name)).toEqual([ "search", + "search.facets", + "search.suggestions", "documents.get", + "documents.get_many", "collections.retrieve", ]); }); diff --git a/packages/mcp/src/read-only.ts b/packages/mcp/src/read-only.ts index d53ad5b..a19227f 100644 --- a/packages/mcp/src/read-only.ts +++ b/packages/mcp/src/read-only.ts @@ -13,6 +13,7 @@ export const READ_ONLY_OPERATION_NAMES = new Set([ "debug", "documents.export", "documents.get", + "documents.get_many", "documents.search", "health", "metrics", @@ -22,6 +23,8 @@ export const READ_ONLY_OPERATION_NAMES = new Set([ "presets.list", "presets.retrieve", "search", + "search.facets", + "search.suggestions", "stats", "stopwords.list", "stopwords.retrieve",