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

Add first-class global synonym set operations and guidance from collection synonym 404s.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 16 additions & 2 deletions packages/cli/src/operation-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,22 @@ const EXAMPLES: Record<string, JsonValue[]> = {
"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",
},
],
};
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/operations/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]),
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/operations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -19,6 +20,7 @@ export const operations = [
...searchOperations,
...aliasesOperations,
...synonymsOperations,
...synonymSetOperations,
...overridesOperations,
...keysOperations,
...analyticsOperations,
Expand Down
84 changes: 84 additions & 0 deletions packages/core/src/operations/synonym-sets.ts
Original file line number Diff line number Diff line change
@@ -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<z.ZodTypeAny, unknown>[];
81 changes: 81 additions & 0 deletions packages/core/src/operations/synonyms.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
53 changes: 51 additions & 2 deletions packages/core/src/operations/synonyms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CollectionSchema>(
collectionPath(input.collection),
);
throw new Error(
globalSynonymGuidance(input.collection, synonymSetNames(collection)),
);
}
},
},
{
name: "synonyms.create",
Expand Down
Loading