diff --git a/.changeset/global-synonym-sets.md b/.changeset/global-synonym-sets.md new file mode 100644 index 0000000..b849d38 --- /dev/null +++ b/.changeset/global-synonym-sets.md @@ -0,0 +1,6 @@ +--- +"@typesensekit/cli": patch +"@typesensekit/mcp": patch +--- + +Add first-class global synonym set operations and guidance from collection synonym 404s. diff --git a/README.md b/README.md index f0e61d3..ca6df56 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,13 @@ TypesenseKit covers the common Typesense administration and search surfaces, plu | System | health, metrics, stats, debug | | Escape hatch | raw HTTP calls through `api.call` | +Typesense v30 global synonym sets are available through `synonym_sets.*`: + +```sh +tsk synonym_sets.list --input '{}' --json +tsk synonym_sets.items.list --input '{"name":"products-core"}' --json +``` + ## Why It Exists Typesense work often jumps between dashboards, one-off scripts, local curl commands, and agent experiments. TypesenseKit keeps those workflows on one command and one tool registry: diff --git a/packages/cli/src/operation-docs.ts b/packages/cli/src/operation-docs.ts index a192bf6..fcbb08d 100644 --- a/packages/cli/src/operation-docs.ts +++ b/packages/cli/src/operation-docs.ts @@ -87,8 +87,22 @@ const EXAMPLES: Record = { "synonyms.create": [ { collection: "products", - id: "sofa-couch", - synonyms: ["sofa", "couch"], + name: "sofa-couch", + value: { synonyms: ["sofa", "couch"] }, + }, + ], + "synonym_sets.list": [{}], + "synonym_sets.create": [ + { + name: "products-core", + value: { + items: [{ id: "sofa-couch", synonyms: ["sofa", "couch"] }], + }, + }, + ], + "synonym_sets.items.list": [ + { + name: "products-core", }, ], }; diff --git a/packages/core/src/operations/index.test.ts b/packages/core/src/operations/index.test.ts index 111448e..0efc4cc 100644 --- a/packages/core/src/operations/index.test.ts +++ b/packages/core/src/operations/index.test.ts @@ -17,6 +17,8 @@ describe("operation registry", () => { "collections.fields.add", "collections.fields.drop", "collections.fields.replace", + "synonym_sets.list", + "synonym_sets.items.list", "api.call", "health", ]), diff --git a/packages/core/src/operations/index.ts b/packages/core/src/operations/index.ts index e188d73..afd7883 100644 --- a/packages/core/src/operations/index.ts +++ b/packages/core/src/operations/index.ts @@ -9,6 +9,7 @@ import { overridesOperations } from "./overrides.js"; import { presetsOperations } from "./presets.js"; import { searchOperations } from "./search.js"; import { stopwordsOperations } from "./stopwords.js"; +import { synonymSetOperations } from "./synonym-sets.js"; import { synonymsOperations } from "./synonyms.js"; import { systemOperations } from "./system.js"; import type { Operation } from "./types.js"; @@ -19,6 +20,7 @@ export const operations = [ ...searchOperations, ...aliasesOperations, ...synonymsOperations, + ...synonymSetOperations, ...overridesOperations, ...keysOperations, ...analyticsOperations, diff --git a/packages/core/src/operations/synonym-sets.ts b/packages/core/src/operations/synonym-sets.ts new file mode 100644 index 0000000..8a8b8bd --- /dev/null +++ b/packages/core/src/operations/synonym-sets.ts @@ -0,0 +1,84 @@ +import { z } from "zod"; +import { api, enc } from "./http.js"; +import type { Operation } from "./types.js"; + +function base(name?: string): string { + return name ? `/synonym_sets/${enc(name)}` : "/synonym_sets"; +} + +function itemPath(name: string, id?: string): string { + const path = `${base(name)}/items`; + return id ? `${path}/${enc(id)}` : path; +} + +export const synonymSetOperations = [ + { + name: "synonym_sets.list", + summary: "List global synonym sets", + category: "synonyms", + input: z.object({}), + execute: async (client) => api(client).get(base()), + }, + { + name: "synonym_sets.create", + summary: "Create or upsert a global synonym set", + category: "synonyms", + input: z.object({ + name: z.string(), + value: z.object({ + items: z.array(z.record(z.unknown())), + }), + }), + execute: async (client, input) => + api(client).put(base(input.name), input.value), + }, + { + name: "synonym_sets.retrieve", + summary: "Retrieve a global synonym set", + category: "synonyms", + input: z.object({ name: z.string() }), + execute: async (client, input) => api(client).get(base(input.name)), + }, + { + name: "synonym_sets.delete", + summary: "Delete a global synonym set", + category: "synonyms", + input: z.object({ name: z.string() }), + execute: async (client, input) => api(client).delete(base(input.name)), + }, + { + name: "synonym_sets.items.list", + summary: "List items in a global synonym set", + category: "synonyms", + input: z.object({ name: z.string() }), + execute: async (client, input) => api(client).get(itemPath(input.name)), + }, + { + name: "synonym_sets.items.create", + summary: "Create or upsert an item in a global synonym set", + category: "synonyms", + input: z.object({ + name: z.string(), + id: z.string(), + value: z.record(z.unknown()), + }), + execute: async (client, input) => + api(client).put(itemPath(input.name, input.id), input.value), + }, + { + name: "synonym_sets.items.retrieve", + summary: "Retrieve an item in a global synonym set", + category: "synonyms", + input: z.object({ name: z.string(), id: z.string() }), + execute: async (client, input) => + api(client).get(itemPath(input.name, input.id)), + }, + { + name: "synonym_sets.items.delete", + summary: "Delete an item in a global synonym set", + category: "synonyms", + input: z.object({ name: z.string(), id: z.string() }), + execute: async (client, input) => + api(client).delete(itemPath(input.name, input.id)), + }, +] satisfies Operation[]; diff --git a/packages/core/src/operations/synonyms.test.ts b/packages/core/src/operations/synonyms.test.ts new file mode 100644 index 0000000..2fac577 --- /dev/null +++ b/packages/core/src/operations/synonyms.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TypesenseClient } from "../client.js"; +import { synonymSetOperations } from "./synonym-sets.js"; +import { synonymsOperations } from "./synonyms.js"; + +function getSynonymOperation(name: string) { + const operation = synonymsOperations.find( + (candidate) => candidate.name === name, + ); + if (!operation) throw new Error(`${name} not found`); + return operation; +} + +function getSynonymSetOperation(name: string) { + const operation = synonymSetOperations.find( + (candidate) => candidate.name === name, + ); + if (!operation) throw new Error(`${name} not found`); + return operation; +} + +describe("synonym set operations", () => { + it("lists global synonym sets", async () => { + const listOperation = getSynonymSetOperation("synonym_sets.list"); + const get = vi.fn().mockResolvedValue({ synonym_sets: [] }); + const client = { apiCall: { get } } as unknown as TypesenseClient; + + await listOperation.execute(client, listOperation.input.parse({})); + + expect(get).toHaveBeenCalledWith("/synonym_sets"); + }); + + it("creates global synonym sets with items", async () => { + const createOperation = getSynonymSetOperation("synonym_sets.create"); + const put = vi.fn().mockResolvedValue({ ok: true }); + const client = { apiCall: { put } } as unknown as TypesenseClient; + const value = { + items: [{ id: "sofa-couch", synonyms: ["sofa", "couch"] }], + }; + + await createOperation.execute( + client, + createOperation.input.parse({ name: "products-core", value }), + ); + + expect(put).toHaveBeenCalledWith("/synonym_sets/products-core", value); + }); + + it("lists items in a global synonym set", async () => { + const listOperation = getSynonymSetOperation("synonym_sets.items.list"); + const get = vi.fn().mockResolvedValue({ items: [] }); + const client = { apiCall: { get } } as unknown as TypesenseClient; + + await listOperation.execute( + client, + listOperation.input.parse({ name: "products-core" }), + ); + + expect(get).toHaveBeenCalledWith("/synonym_sets/products-core/items"); + }); + + it("points collection synonym 404s to linked global synonym sets", async () => { + const listOperation = getSynonymOperation("synonyms.list"); + const get = vi + .fn() + .mockRejectedValueOnce({ httpStatus: 404 }) + .mockResolvedValueOnce({ synonym_sets: ["products-core"] }); + const client = { apiCall: { get } } as unknown as TypesenseClient; + + await expect( + listOperation.execute( + client, + listOperation.input.parse({ collection: "products" }), + ), + ).rejects.toThrow( + "This collection is linked to global synonym sets: products-core.", + ); + expect(get).toHaveBeenNthCalledWith(1, "/collections/products/synonyms"); + expect(get).toHaveBeenNthCalledWith(2, "/collections/products"); + }); +}); diff --git a/packages/core/src/operations/synonyms.ts b/packages/core/src/operations/synonyms.ts index e8a7e4f..ae3448f 100644 --- a/packages/core/src/operations/synonyms.ts +++ b/packages/core/src/operations/synonyms.ts @@ -2,14 +2,63 @@ import { z } from "zod"; import { api, collectionPath, enc } from "./http.js"; import type { Operation } from "./types.js"; +type CollectionSchema = { + synonym_sets?: unknown; +}; + +type ErrorWithStatus = { + httpStatus?: unknown; + status?: unknown; +}; + +function isNotFound(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + const { httpStatus, status } = error as ErrorWithStatus; + return httpStatus === 404 || status === 404; +} + +function synonymSetNames(collection: CollectionSchema): string[] { + return Array.isArray(collection.synonym_sets) + ? collection.synonym_sets.filter( + (name): name is string => typeof name === "string", + ) + : []; +} + +function globalSynonymGuidance(collection: string, sets: string[]): string { + return [ + `Collection-level synonyms are unavailable for ${collection}.`, + sets.length > 0 + ? `This collection is linked to global synonym sets: ${sets.join(", ")}.` + : "This Typesense version may use global synonym sets.", + "Use synonym_sets.list to inspect global synonym sets:", + "tsk synonym_sets.list --input '{}' --json", + ].join("\n"); +} + export const synonymsOperations = [ { name: "synonyms.list", summary: "List synonyms", category: "synonyms", input: z.object({ collection: z.string() }), - execute: async (client, input) => - api(client).get(`${collectionPath(input.collection)}/synonyms`), + execute: async (client, input) => { + const request = api(client); + try { + return await request.get( + `${collectionPath(input.collection)}/synonyms`, + ); + } catch (error) { + if (!isNotFound(error)) throw error; + + const collection = await request.get( + collectionPath(input.collection), + ); + throw new Error( + globalSynonymGuidance(input.collection, synonymSetNames(collection)), + ); + } + }, }, { name: "synonyms.create",