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
7 changes: 7 additions & 0 deletions .changeset/search-helpers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@typesensekit/core": patch
"@typesensekit/cli": patch
"@typesensekit/mcp": patch
---

Add document batch retrieval, facet exploration, and search suggestion helper operations.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/operation-docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
);
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/src/operation-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ const EXAMPLES: Record<string, JsonValue[]> = {
document: { id: "sku-1", title: "Lounge chair" },
},
],
"documents.get_many": [
{
collection: "products",
ids: ["sku-1", "sku-2"],
},
],
"documents.search": [
{
collection: "production__products",
Expand All @@ -78,6 +84,23 @@ const EXAMPLES: Record<string, JsonValue[]> = {
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",
Expand Down
41 changes: 41 additions & 0 deletions packages/core/src/operations/documents.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
17 changes: 17 additions & 0 deletions packages/core/src/operations/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/operations/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
62 changes: 62 additions & 0 deletions packages/core/src/operations/search.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
63 changes: 63 additions & 0 deletions packages/core/src/operations/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
return Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== undefined),
);
}

export const searchOperations = [
{
Expand Down Expand Up @@ -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<z.ZodTypeAny, unknown>[];
6 changes: 6 additions & 0 deletions packages/mcp/src/read-only.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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",
]);
});
Expand Down
3 changes: 3 additions & 0 deletions packages/mcp/src/read-only.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const READ_ONLY_OPERATION_NAMES = new Set([
"debug",
"documents.export",
"documents.get",
"documents.get_many",
"documents.search",
"health",
"metrics",
Expand All @@ -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",
Expand Down
Loading