diff --git a/apps/desktop/src/components/meilisearch/MeilisearchDocumentsPage.vue b/apps/desktop/src/components/meilisearch/MeilisearchDocumentsPage.vue
new file mode 100644
index 0000000000..852084df52
--- /dev/null
+++ b/apps/desktop/src/components/meilisearch/MeilisearchDocumentsPage.vue
@@ -0,0 +1,801 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t("meilisearch.sortHint") }}
+ {{ t("meilisearch.sortNoAttributes") }}
+
+
+
{{ attr }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t("meilisearch.showRankingScore") }}
+
+
+
+ {{ t("meilisearch.rankingScoreThreshold") }}
+
+
+
+ {{ t("meilisearch.autoRefresh") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t("meilisearch.hybridNoEmbedders") }}
+
+
+
+
+
+ {{ hybridSemanticRatio.toFixed(2) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t("meilisearch.results") }}
+
+
+
+
+
+
{{ resultSummary }}
+
+
+
+
+
+
+
+
+ {{ t("meilisearch.empty") }}
+
+
+
+
+
+
+ {{ rankingScore(hit)!.toFixed(2) }}
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ {{ column }}
+ |
+ |
+
+
+
+
+ |
+
+
+ {{ cellText(hit, column) }}
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+ {{ rankingScore(hit)!.toFixed(2) }}
+
+
+
+
+
- {{ key }}
+ -
+
+ {{ formatCellValue(stripSearchMarks(value)) }}
+
+
+
+
+
+
+
+
+
+
+ {{ page + 1 }} / {{ totalPages }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desktop/src/components/meilisearch/MeilisearchIndexView.vue b/apps/desktop/src/components/meilisearch/MeilisearchIndexView.vue
new file mode 100644
index 0000000000..65d48de85b
--- /dev/null
+++ b/apps/desktop/src/components/meilisearch/MeilisearchIndexView.vue
@@ -0,0 +1,145 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desktop/src/components/meilisearch/MeilisearchSettingsPage.vue b/apps/desktop/src/components/meilisearch/MeilisearchSettingsPage.vue
new file mode 100644
index 0000000000..9f74b99732
--- /dev/null
+++ b/apps/desktop/src/components/meilisearch/MeilisearchSettingsPage.vue
@@ -0,0 +1,184 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t("meilisearch.settingsJson") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ saveError }}
+
+
+
+
+
{{ t("meilisearch.dangerZone") }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desktop/src/components/meilisearch/__tests__/MeilisearchDocumentsPage.export.spec.ts b/apps/desktop/src/components/meilisearch/__tests__/MeilisearchDocumentsPage.export.spec.ts
new file mode 100644
index 0000000000..3f24c0033d
--- /dev/null
+++ b/apps/desktop/src/components/meilisearch/__tests__/MeilisearchDocumentsPage.export.spec.ts
@@ -0,0 +1,135 @@
+// @vitest-environment happy-dom
+
+import { createApp, defineComponent, h, nextTick, type Component } from "vue";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ searchDocuments: vi.fn(),
+ fetchDocuments: vi.fn(),
+ getIndexSettings: vi.fn(),
+ saveTextFile: vi.fn(),
+ toast: vi.fn(),
+}));
+
+function passthrough(tag: string): Component {
+ return defineComponent({
+ inheritAttrs: false,
+ setup(_, { attrs, slots }) {
+ return () => h(tag, attrs, slots.default?.());
+ },
+ });
+}
+
+vi.mock("vue-i18n", () => ({
+ useI18n: () => ({ t: (key: string) => key }),
+}));
+vi.mock("@lucide/vue", () => ({
+ ArrowDown: passthrough("span"),
+ ArrowUp: passthrough("span"),
+ Braces: passthrough("span"),
+ ChevronLeft: passthrough("span"),
+ ChevronRight: passthrough("span"),
+ Copy: passthrough("span"),
+ Download: passthrough("span"),
+ LayoutGrid: passthrough("span"),
+ LoaderCircle: passthrough("span"),
+ Pencil: passthrough("span"),
+ Save: passthrough("span"),
+ Search: passthrough("span"),
+ Table2: passthrough("span"),
+ Trash2: passthrough("span"),
+}));
+vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
+vi.mock("@/components/ui/badge", () => ({ Badge: passthrough("span") }));
+vi.mock("@/components/ui/input", () => ({ Input: passthrough("input") }));
+vi.mock("@/components/ui/label", () => ({ Label: passthrough("label") }));
+vi.mock("@/components/ui/switch", () => ({ Switch: passthrough("div") }));
+vi.mock("@/components/ui/popover", () => ({
+ Popover: passthrough("div"),
+ PopoverAnchor: passthrough("div"),
+ PopoverContent: passthrough("div"),
+ PopoverTrigger: passthrough("div"),
+}));
+vi.mock("@/components/ui/dialog", () => ({
+ Dialog: passthrough("div"),
+ DialogContent: passthrough("div"),
+ DialogFooter: passthrough("div"),
+ DialogHeader: passthrough("div"),
+ DialogTitle: passthrough("div"),
+}));
+vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({ default: passthrough("div") }));
+vi.mock("@/components/ui/ErrorBanner.vue", () => ({ default: passthrough("div") }));
+vi.mock("@/components/common/QueryLoadingState.vue", () => ({ default: passthrough("div") }));
+vi.mock("@/components/common/JsonTree.vue", () => ({ default: passthrough("div") }));
+vi.mock("@/components/redis/RedisJsonEditor.vue", () => ({ default: passthrough("div") }));
+vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
+vi.mock("@/lib/export/saveTextFile", () => ({
+ saveTextFile: mocks.saveTextFile,
+ compactLocalTimestamp: () => "20260101-000000",
+ sanitizeExportBaseName: (value: string) => value,
+}));
+vi.mock("@/lib/backend/api", () => ({
+ meilisearchSearchDocuments: mocks.searchDocuments,
+ meilisearchFetchDocuments: mocks.fetchDocuments,
+ meilisearchGetIndexSettings: mocks.getIndexSettings,
+}));
+
+import MeilisearchDocumentsPage from "@/components/meilisearch/MeilisearchDocumentsPage.vue";
+
+let app: ReturnType
| undefined;
+let root: HTMLDivElement | undefined;
+
+beforeEach(() => {
+ mocks.getIndexSettings.mockResolvedValue({});
+ mocks.saveTextFile.mockResolvedValue(undefined);
+});
+
+afterEach(() => {
+ app?.unmount();
+ root?.remove();
+ app = undefined;
+ root = undefined;
+ vi.clearAllMocks();
+});
+
+describe("MeilisearchDocumentsPage export", () => {
+ it("uses full-document pagination instead of capped displayed search hits", async () => {
+ const batch = (size: number, from: number) => Array.from({ length: size }, (_, index) => ({ movie_id: from + index, title: `Movie ${from + index}`, internal_notes: `note ${from + index}` }));
+ mocks.searchDocuments.mockResolvedValue({
+ hits: [{ id: 0, document: { movie_id: 0, title: "Movie 0" } }],
+ totalHits: 1000,
+ processingTimeMs: 1,
+ });
+ mocks.fetchDocuments.mockImplementation((_connectionId: string, _index: string, params: { limit: number; offset: number }) => {
+ if (params.offset === 0) return Promise.resolve({ documents: batch(1000, 0), total: 1300 });
+ if (params.offset === 1000) return Promise.resolve({ documents: batch(300, 1000), total: 1300 });
+ return Promise.resolve({ documents: [], total: 1300 });
+ });
+
+ root = document.createElement("div");
+ document.body.append(root);
+ app = createApp(MeilisearchDocumentsPage, { connectionId: "c1", index: "movies" });
+ app.mount(root);
+
+ await vi.waitFor(() => expect(mocks.searchDocuments).toHaveBeenCalledTimes(1));
+ await nextTick();
+
+ const exportButton = Array.from(root.querySelectorAll("button")).find((button) => button.textContent?.includes("meilisearch.exportResults"));
+ expect(exportButton?.textContent).toContain("1000");
+ exportButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+
+ await vi.waitFor(() => expect(mocks.saveTextFile).toHaveBeenCalledTimes(1));
+
+ expect(mocks.searchDocuments).toHaveBeenCalledTimes(1);
+ const offsets = mocks.fetchDocuments.mock.calls.map((call) => (call[2] as { offset: number }).offset);
+ expect(offsets).toEqual([0, 1000]);
+
+ const [content, fileName] = mocks.saveTextFile.mock.calls[0] as [string, string];
+ expect(fileName).toBe("movies-20260101-000000.json");
+ const exported = JSON.parse(content) as Array>;
+ expect(exported).toHaveLength(1300);
+ // Exported entries are the stored documents, without search metadata.
+ expect(exported[0]).toEqual({ movie_id: 0, title: "Movie 0", internal_notes: "note 0" });
+ expect(exported[1299]).toEqual({ movie_id: 1299, title: "Movie 1299", internal_notes: "note 1299" });
+ });
+});
diff --git a/apps/desktop/src/components/meilisearch/__tests__/MeilisearchDocumentsPage.identity.spec.ts b/apps/desktop/src/components/meilisearch/__tests__/MeilisearchDocumentsPage.identity.spec.ts
new file mode 100644
index 0000000000..d324f7e82d
--- /dev/null
+++ b/apps/desktop/src/components/meilisearch/__tests__/MeilisearchDocumentsPage.identity.spec.ts
@@ -0,0 +1,172 @@
+// @vitest-environment happy-dom
+
+import { createApp, defineComponent, h, nextTick, type Component } from "vue";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ searchDocuments: vi.fn(),
+ getIndexSettings: vi.fn(),
+ getDocument: vi.fn(),
+ deleteDocument: vi.fn(),
+ toast: vi.fn(),
+}));
+
+function passthrough(tag: string): Component {
+ return defineComponent({
+ inheritAttrs: false,
+ setup(_, { attrs, slots }) {
+ return () => h(tag, attrs, slots.default?.());
+ },
+ });
+}
+
+/** Renders a confirm trigger while open, so tests drive the danger dialog. */
+function confirmDialog(): Component {
+ return defineComponent({
+ inheritAttrs: false,
+ emits: ["confirm", "update:open"],
+ setup(_, { attrs, emit }) {
+ return () => (attrs.open ? h("button", { "data-confirm-dialog": String(attrs.confirmLabel ?? ""), onClick: () => emit("confirm") }, "confirm") : null);
+ },
+ });
+}
+
+/** Editor stub that exposes the current model value as text for assertions. */
+function jsonEditor(): Component {
+ return defineComponent({
+ inheritAttrs: false,
+ setup(_, { attrs }) {
+ return () => h("pre", { "data-json-editor": "true" }, String(attrs.modelValue ?? ""));
+ },
+ });
+}
+
+vi.mock("vue-i18n", () => ({
+ useI18n: () => ({ t: (key: string) => key }),
+}));
+vi.mock("@lucide/vue", () => ({
+ ArrowDown: passthrough("span"),
+ ArrowUp: passthrough("span"),
+ Braces: passthrough("span"),
+ ChevronLeft: passthrough("span"),
+ ChevronRight: passthrough("span"),
+ Copy: passthrough("span"),
+ Download: passthrough("span"),
+ LayoutGrid: passthrough("span"),
+ LoaderCircle: passthrough("span"),
+ Pencil: passthrough("span"),
+ Save: passthrough("span"),
+ Search: passthrough("span"),
+ Table2: passthrough("span"),
+ Trash2: passthrough("span"),
+}));
+vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
+vi.mock("@/components/ui/badge", () => ({ Badge: passthrough("span") }));
+vi.mock("@/components/ui/input", () => ({ Input: passthrough("input") }));
+vi.mock("@/components/ui/label", () => ({ Label: passthrough("label") }));
+vi.mock("@/components/ui/switch", () => ({ Switch: passthrough("div") }));
+vi.mock("@/components/ui/popover", () => ({
+ Popover: passthrough("div"),
+ PopoverAnchor: passthrough("div"),
+ PopoverContent: passthrough("div"),
+ PopoverTrigger: passthrough("div"),
+}));
+vi.mock("@/components/ui/dialog", () => ({
+ Dialog: passthrough("div"),
+ DialogContent: passthrough("div"),
+ DialogFooter: passthrough("div"),
+ DialogHeader: passthrough("div"),
+ DialogTitle: passthrough("div"),
+}));
+vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({ default: confirmDialog() }));
+vi.mock("@/components/ui/ErrorBanner.vue", () => ({ default: passthrough("div") }));
+vi.mock("@/components/common/QueryLoadingState.vue", () => ({ default: passthrough("div") }));
+vi.mock("@/components/common/JsonTree.vue", () => ({ default: passthrough("div") }));
+vi.mock("@/components/redis/RedisJsonEditor.vue", () => ({ default: jsonEditor() }));
+vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
+vi.mock("@/lib/backend/api", () => ({
+ meilisearchSearchDocuments: mocks.searchDocuments,
+ meilisearchGetIndexSettings: mocks.getIndexSettings,
+ meilisearchGetDocument: mocks.getDocument,
+ documentDeleteDocument: mocks.deleteDocument,
+}));
+
+import MeilisearchDocumentsPage from "@/components/meilisearch/MeilisearchDocumentsPage.vue";
+
+let app: ReturnType | undefined;
+let root: HTMLDivElement | undefined;
+
+beforeEach(() => {
+ mocks.getIndexSettings.mockResolvedValue({});
+ mocks.getDocument.mockResolvedValue('{"movie_id":"123","title":"Alien","internal_notes":"canonical only"}');
+ mocks.deleteDocument.mockResolvedValue(1);
+ // The search hit is partial: it lacks the `internal_notes` field.
+ mocks.searchDocuments.mockResolvedValue({
+ hits: [{ id: "123", document: { movie_id: "123", title: "Alien" } }],
+ totalHits: 1,
+ processingTimeMs: 1,
+ });
+});
+
+afterEach(() => {
+ app?.unmount();
+ root?.remove();
+ app = undefined;
+ root = undefined;
+ vi.clearAllMocks();
+});
+
+async function mountPage(): Promise {
+ root = document.createElement("div");
+ document.body.append(root);
+ app = createApp(MeilisearchDocumentsPage, { connectionId: "c1", index: "movies" });
+ app.mount(root);
+ await vi.waitFor(() => expect(mocks.searchDocuments).toHaveBeenCalledTimes(1));
+ await nextTick();
+ return root;
+}
+
+function findButton(container: HTMLElement, title: string): HTMLButtonElement {
+ const button = Array.from(container.querySelectorAll("button")).find((candidate) => candidate.title === title);
+ if (!button) throw new Error(`button not found: ${title}`);
+ return button;
+}
+
+describe("MeilisearchDocumentsPage identity and canonical documents", () => {
+ it("serializes numeric-looking string ids with the store-id serializer on delete", async () => {
+ const container = await mountPage();
+
+ findButton(container, "meilisearch.deleteDocument").dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ await nextTick();
+ const confirm = container.querySelector("[data-confirm-dialog]");
+ confirm?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+
+ await vi.waitFor(() => expect(mocks.deleteDocument).toHaveBeenCalledTimes(1));
+ // The string id "123" must not round-trip as numeric 123.
+ expect(mocks.deleteDocument).toHaveBeenCalledWith("c1", "default", "movies", '__dbx_meilisearch_string_id__"123"');
+ });
+
+ it("edits the canonical document instead of the partial search hit", async () => {
+ const container = await mountPage();
+
+ findButton(container, "meilisearch.editDocument").dispatchEvent(new MouseEvent("click", { bubbles: true }));
+
+ await vi.waitFor(() => expect(mocks.getDocument).toHaveBeenCalledTimes(1));
+ expect(mocks.getDocument).toHaveBeenCalledWith("c1", "movies", '__dbx_meilisearch_string_id__"123"');
+ await vi.waitFor(() => {
+ const editor = container.querySelector("[data-json-editor]");
+ expect(editor?.textContent).toContain("internal_notes");
+ });
+ // The editor content comes from the canonical fetch, not the partial hit.
+ const editor = container.querySelector("[data-json-editor]");
+ expect(editor?.textContent).toContain('"title": "Alien"');
+ });
+
+ it("disables edit and delete when the hit has no identity", async () => {
+ mocks.searchDocuments.mockResolvedValue({ hits: [{ document: { title: "No PK" } }], totalHits: 1, processingTimeMs: 1 });
+ const container = await mountPage();
+
+ expect(findButton(container, "meilisearch.editDocument").disabled).toBe(true);
+ expect(findButton(container, "meilisearch.deleteDocument").disabled).toBe(true);
+ });
+});
diff --git a/apps/desktop/src/components/meilisearch/__tests__/MeilisearchSettingsPage.spec.ts b/apps/desktop/src/components/meilisearch/__tests__/MeilisearchSettingsPage.spec.ts
new file mode 100644
index 0000000000..efc07d3a1e
--- /dev/null
+++ b/apps/desktop/src/components/meilisearch/__tests__/MeilisearchSettingsPage.spec.ts
@@ -0,0 +1,142 @@
+// @vitest-environment happy-dom
+
+import { createApp, defineComponent, h, nextTick, reactive, type Component } from "vue";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ deleteIndex: vi.fn(),
+ getIndexSettings: vi.fn(),
+ closeTab: vi.fn(),
+ toast: vi.fn(),
+}));
+
+function passthrough(tag: string): Component {
+ return defineComponent({
+ inheritAttrs: false,
+ setup(_, { attrs, slots }) {
+ return () => h(tag, attrs, slots.default?.());
+ },
+ });
+}
+
+/** Renders a confirm trigger while open, so tests drive the danger dialogs. */
+function confirmDialog(): Component {
+ return defineComponent({
+ inheritAttrs: false,
+ emits: ["confirm", "update:open"],
+ setup(_, { attrs, emit }) {
+ return () =>
+ attrs.open
+ ? h(
+ "button",
+ {
+ "data-confirm-dialog": String(attrs.confirmLabel ?? ""),
+ onClick: () => emit("confirm"),
+ },
+ "confirm",
+ )
+ : null;
+ },
+ });
+}
+
+vi.mock("vue-i18n", () => ({
+ useI18n: () => ({ t: (key: string) => key }),
+}));
+vi.mock("@lucide/vue", () => ({ Save: passthrough("span") }));
+vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
+vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({ default: confirmDialog() }));
+vi.mock("@/components/ui/ErrorBanner.vue", () => ({ default: passthrough("div") }));
+vi.mock("@/components/common/QueryLoadingState.vue", () => ({ default: passthrough("div") }));
+vi.mock("@/components/common/JsonTree.vue", () => ({ default: passthrough("div") }));
+vi.mock("@/components/redis/RedisJsonEditor.vue", () => ({ default: passthrough("div") }));
+vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
+vi.mock("@/lib/backend/api", () => ({
+ meilisearchGetIndexSettings: mocks.getIndexSettings,
+ meilisearchDeleteIndex: mocks.deleteIndex,
+}));
+
+const store = reactive({
+ activeTabId: "tab-a" as string | null,
+ tabs: [{ id: "tab-a" }] as Array<{ id: string }>,
+ closeTab: mocks.closeTab,
+});
+
+vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => store }));
+
+import MeilisearchSettingsPage from "@/components/meilisearch/MeilisearchSettingsPage.vue";
+
+let app: ReturnType | undefined;
+let root: HTMLDivElement | undefined;
+
+beforeEach(() => {
+ store.activeTabId = "tab-a";
+ store.tabs = [{ id: "tab-a" }];
+ mocks.getIndexSettings.mockResolvedValue({});
+});
+
+afterEach(() => {
+ app?.unmount();
+ root?.remove();
+ app = undefined;
+ root = undefined;
+ vi.clearAllMocks();
+});
+
+async function mountPage(): Promise {
+ root = document.createElement("div");
+ document.body.append(root);
+ app = createApp(MeilisearchSettingsPage, { connectionId: "c1", index: "movies" });
+ app.mount(root);
+ await vi.waitFor(() => expect(mocks.getIndexSettings).toHaveBeenCalledTimes(1));
+ await nextTick();
+ return root;
+}
+
+async function confirmDeleteIndex(container: HTMLDivElement) {
+ const deleteButton = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.trim() === "meilisearch.deleteIndex");
+ deleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ await nextTick();
+ const confirmButton = container.querySelector("[data-confirm-dialog]");
+ confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+}
+
+describe("MeilisearchSettingsPage index deletion", () => {
+ it("closes the tab that started the delete even after switching tabs mid-flight", async () => {
+ let resolveDelete!: () => void;
+ mocks.deleteIndex.mockImplementation(() => new Promise((resolve) => (resolveDelete = resolve)));
+
+ const container = await mountPage();
+ await confirmDeleteIndex(container);
+ await vi.waitFor(() => expect(mocks.deleteIndex).toHaveBeenCalledTimes(1));
+
+ // The user switches to another tab while the delete is in flight.
+ store.tabs.push({ id: "tab-b" });
+ store.activeTabId = "tab-b";
+
+ resolveDelete();
+ await vi.waitFor(() => expect(mocks.closeTab).toHaveBeenCalledTimes(1));
+
+ expect(mocks.closeTab).toHaveBeenCalledWith("tab-a");
+ expect(mocks.closeTab).not.toHaveBeenCalledWith("tab-b");
+ });
+
+ it("does not close anything when the owning tab is already gone", async () => {
+ let resolveDelete!: () => void;
+ mocks.deleteIndex.mockImplementation(() => new Promise((resolve) => (resolveDelete = resolve)));
+
+ const container = await mountPage();
+ await confirmDeleteIndex(container);
+ await vi.waitFor(() => expect(mocks.deleteIndex).toHaveBeenCalledTimes(1));
+
+ // The user closed the tab themselves while the delete was in flight.
+ store.tabs = [];
+ store.activeTabId = null;
+
+ resolveDelete();
+ await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("meilisearch.indexDeleted"));
+ await nextTick();
+
+ expect(mocks.closeTab).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
index 0a59a8e9ec..b5dbb7ec82 100644
--- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
+++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
@@ -826,8 +826,13 @@ async function toggle(requestId = beginNavigationRequest()) {
await connectionStore.loadTableGroups(node.connectionId, node.database, node.label, node.schema, node.id);
} else if (node.type === "elasticsearch-index" && node.connectionId) {
await connectionStore.ensureConnected(node.connectionId);
- const tab = queryStore.createTab(node.connectionId, node.database || "default", node.label, "mongo");
- queryStore.updateSql(tab, node.label);
+ if (connectionStore.getConfig(node.connectionId)?.db_type === "meilisearch") {
+ const tab = queryStore.createTab(node.connectionId, node.database || "default", node.label, "meilisearch");
+ queryStore.updateSql(tab, node.label);
+ } else {
+ const tab = queryStore.createTab(node.connectionId, node.database || "default", node.label, "mongo");
+ queryStore.updateSql(tab, node.label);
+ }
} else if (node.type === "vector-collection" && node.connectionId) {
await connectionStore.ensureConnected(node.connectionId);
const collectionRef = (node.meta as { collectionId?: string } | undefined)?.collectionId ?? node.label;
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 2ba1bc72ca..62a3220dab 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -4591,6 +4591,60 @@ export default {
filterPlaceholder: "Filter...",
sortPlaceholder: "Sort...",
},
+ meilisearch: {
+ documents: "Documents",
+ settings: "Settings",
+ search: "Search",
+ keywordPlaceholder: "Search documents...",
+ filter: "Filter",
+ filterPlaceholder: 'status = "published"',
+ sort: "Sort",
+ sortPlaceholder: "field:asc",
+ sortHint: "Separate multiple sort expressions with commas (,)",
+ sortNoAttributes: "No sortable attributes configured for this index",
+ sortAsc: "Ascending",
+ sortDesc: "Descending",
+ limit: "Limit",
+ offset: "Offset",
+ resultSummary: "About {count} results · {time} ms",
+ viewJson: "JSON",
+ viewTable: "Table",
+ viewGrid: "Grid",
+ empty: "No documents found",
+ editDocument: "Edit document",
+ deleteDocument: "Delete document",
+ deleteDocumentMessage: "Delete this document? This action cannot be undone.",
+ documentDeleted: "Document deleted",
+ documentSaved: "Document saved",
+ settingsSaved: "Settings saved",
+ invalidJson: "Invalid JSON",
+ settingsJson: "Settings JSON",
+ dangerZone: "Danger zone",
+ clearDocuments: "Clear all documents",
+ clearDocumentsConfirmMessage: "This removes every document from the index. This action cannot be undone.",
+ documentsCleared: "All documents cleared",
+ deleteIndex: "Delete index",
+ deleteIndexConfirmMessage: "This permanently deletes the index and all its documents.",
+ indexDeleted: "Index deleted",
+ isIndexing: "Indexing",
+ uid: "UID",
+ updatedAt: "Updated",
+ primaryKey: "Primary key",
+ documentCountLabel: "Documents",
+ databaseSize: "Database size",
+ copyDocument: "Copy JSON",
+ copied: "Copied",
+ autoRefresh: "Auto refresh",
+ hybrid: "Hybrid search",
+ hybridEmbedder: "Embedder",
+ hybridSemanticRatio: "Semantic ratio",
+ hybridNoEmbedders: "No embedders configured in index settings; enter one manually.",
+ showRankingScore: "Ranking score",
+ rankingScoreThreshold: "Score threshold",
+ results: "Results",
+ exportResults: "Export results",
+ exportSearchUnsupported: "Full export is unavailable for text, hybrid, or score-threshold searches because Meilisearch caps search pagination. Clear those options to export every filtered document.",
+ },
dynamodb: {
tableMetadataUnavailable: "DynamoDB table metadata is not loaded. Refresh and try again.",
keyRequired: "DynamoDB items require the key attribute {key}",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index d86dba3c78..81e83e8ef4 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -4433,6 +4433,60 @@ export default withEnglishFallback({
indexOtherOptions: "Otras opciones",
indexPropertiesUnavailable: "El controlador Legacy de MongoDB no puede informar disperso, expiración, background o tamaño de bucket. Conéctese con el controlador nativo para verlos.",
},
+ meilisearch: {
+ documents: "Documentos",
+ settings: "Ajustes",
+ search: "Buscar",
+ keywordPlaceholder: "Buscar documentos...",
+ filter: "Filtro",
+ filterPlaceholder: 'status = "published"',
+ sort: "Orden",
+ sortPlaceholder: "campo:asc",
+ sortHint: "Separa varias expresiones de ordenación con comas (,)",
+ sortNoAttributes: "Este índice no tiene atributos ordenables configurados",
+ sortAsc: "Ascendente",
+ sortDesc: "Descendente",
+ limit: "Límite",
+ offset: "Desplazamiento",
+ resultSummary: "Aprox. {count} resultados · {time} ms",
+ viewJson: "JSON",
+ viewTable: "Tabla",
+ viewGrid: "Cuadrícula",
+ empty: "No se encontraron documentos",
+ editDocument: "Editar documento",
+ deleteDocument: "Eliminar documento",
+ deleteDocumentMessage: "¿Eliminar este documento? Esta acción no se puede deshacer.",
+ documentDeleted: "Documento eliminado",
+ documentSaved: "Documento guardado",
+ settingsSaved: "Ajustes guardados",
+ invalidJson: "JSON no válido",
+ settingsJson: "JSON de ajustes",
+ dangerZone: "Zona de peligro",
+ clearDocuments: "Vaciar todos los documentos",
+ clearDocumentsConfirmMessage: "Esto elimina todos los documentos del índice. No se puede deshacer.",
+ documentsCleared: "Documentos eliminados",
+ deleteIndex: "Eliminar índice",
+ deleteIndexConfirmMessage: "Esto elimina permanentemente el índice y todos sus documentos.",
+ indexDeleted: "Índice eliminado",
+ isIndexing: "Indexando",
+ uid: "UID",
+ updatedAt: "Actualizado",
+ primaryKey: "Clave primaria",
+ documentCountLabel: "Documentos",
+ databaseSize: "Tamaño de la base de datos",
+ copyDocument: "Copiar JSON",
+ copied: "Copiado",
+ autoRefresh: "Actualización automática",
+ hybrid: "Búsqueda híbrida",
+ hybridEmbedder: "Embedder",
+ hybridSemanticRatio: "Proporción semántica",
+ hybridNoEmbedders: "No hay embedders configurados en los ajustes del índice; introdúcelo manualmente.",
+ showRankingScore: "Puntuación de ranking",
+ rankingScoreThreshold: "Umbral de puntuación",
+ results: "Resultados",
+ exportResults: "Exportar resultados",
+ exportSearchUnsupported: "Meilisearch limita la paginación de búsquedas de texto, híbridas o con umbral de puntuación. Borre esas opciones para exportar todos los documentos filtrados.",
+ },
dynamodb: {
tableMetadataUnavailable: "Los metadatos de la tabla DynamoDB no se han cargado. Actualice e inténtelo de nuevo.",
keyRequired: "Los elementos de DynamoDB requieren el atributo de clave {key}",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index 08b1aec925..4f2a893ac3 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -4431,6 +4431,60 @@ export default withEnglishFallback({
indexOtherOptions: "Altre opzioni",
indexPropertiesUnavailable: "Il driver Legacy di MongoDB non può riportare sparsità, scadenza, background o dimensione bucket. Connettersi con il driver nativo per vederli.",
},
+ meilisearch: {
+ documents: "Documenti",
+ settings: "Impostazioni",
+ search: "Cerca",
+ keywordPlaceholder: "Cerca documenti...",
+ filter: "Filtro",
+ filterPlaceholder: 'status = "published"',
+ sort: "Ordinamento",
+ sortPlaceholder: "campo:asc",
+ sortHint: "Separa più espressioni di ordinamento con la virgola (,)",
+ sortNoAttributes: "Nessun attributo ordinabile configurato per questo indice",
+ sortAsc: "Crescente",
+ sortDesc: "Decrescente",
+ limit: "Limite",
+ offset: "Scostamento",
+ resultSummary: "Circa {count} risultati · {time} ms",
+ viewJson: "JSON",
+ viewTable: "Tabella",
+ viewGrid: "Griglia",
+ empty: "Nessun documento trovato",
+ editDocument: "Modifica documento",
+ deleteDocument: "Elimina documento",
+ deleteDocumentMessage: "Eliminare questo documento? L'operazione non può essere annullata.",
+ documentDeleted: "Documento eliminato",
+ documentSaved: "Documento salvato",
+ settingsSaved: "Impostazioni salvate",
+ invalidJson: "JSON non valido",
+ settingsJson: "JSON impostazioni",
+ dangerZone: "Zona pericolosa",
+ clearDocuments: "Svuota tutti i documenti",
+ clearDocumentsConfirmMessage: "Questa operazione rimuove tutti i documenti dall'indice e non può essere annullata.",
+ documentsCleared: "Documenti eliminati",
+ deleteIndex: "Elimina indice",
+ deleteIndexConfirmMessage: "Questa operazione elimina definitivamente l'indice e tutti i suoi documenti.",
+ indexDeleted: "Indice eliminato",
+ isIndexing: "Indicizzazione",
+ uid: "UID",
+ updatedAt: "Aggiornato",
+ primaryKey: "Chiave primaria",
+ documentCountLabel: "Documenti",
+ databaseSize: "Dimensione del database",
+ copyDocument: "Copia JSON",
+ copied: "Copiato",
+ autoRefresh: "Aggiornamento automatico",
+ hybrid: "Ricerca ibrida",
+ hybridEmbedder: "Embedder",
+ hybridSemanticRatio: "Rapporto semantico",
+ hybridNoEmbedders: "Nessun embedder configurato nelle impostazioni dell'indice; inseriscilo manualmente.",
+ showRankingScore: "Punteggio di ranking",
+ rankingScoreThreshold: "Soglia del punteggio",
+ results: "Risultati",
+ exportResults: "Esporta risultati",
+ exportSearchUnsupported: "Meilisearch limita la paginazione delle ricerche testuali, ibride o con soglia di punteggio. Rimuovi queste opzioni per esportare tutti i documenti filtrati.",
+ },
dynamodb: {
tableMetadataUnavailable: "I metadati della tabella DynamoDB non sono caricati. Aggiorna e riprova.",
keyRequired: "Gli elementi DynamoDB richiedono l'attributo chiave {key}",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index ee87ded752..59b512c90e 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -4461,6 +4461,60 @@ export default withEnglishFallback({
indexOtherOptions: "その他のオプション",
indexPropertiesUnavailable: "MongoDB Legacy ドライバーはスパース、有効期限、バックグラウンド、バケットサイズを報告できません。ネイティブドライバーで接続して確認してください。",
},
+ meilisearch: {
+ documents: "ドキュメント",
+ settings: "設定",
+ search: "検索",
+ keywordPlaceholder: "ドキュメントを検索...",
+ filter: "フィルター",
+ filterPlaceholder: 'status = "published"',
+ sort: "並び替え",
+ sortPlaceholder: "field:asc",
+ sortHint: "複数のソート式は半角カンマ(,)で区切ります",
+ sortNoAttributes: "このインデックスにソート可能な属性が設定されていません",
+ sortAsc: "昇順",
+ sortDesc: "降順",
+ limit: "件数",
+ offset: "オフセット",
+ resultSummary: "約 {count} 件 · {time} ms",
+ viewJson: "JSON",
+ viewTable: "テーブル",
+ viewGrid: "グリッド",
+ empty: "ドキュメントが見つかりません",
+ editDocument: "ドキュメントを編集",
+ deleteDocument: "ドキュメントを削除",
+ deleteDocumentMessage: "このドキュメントを削除しますか?この操作は元に戻せません。",
+ documentDeleted: "ドキュメントを削除しました",
+ documentSaved: "ドキュメントを保存しました",
+ settingsSaved: "設定を保存しました",
+ invalidJson: "無効な JSON",
+ settingsJson: "設定 JSON",
+ dangerZone: "危険ゾーン",
+ clearDocuments: "すべてのドキュメントを消去",
+ clearDocumentsConfirmMessage: "インデックス内のすべてのドキュメントが削除されます。この操作は元に戻せません。",
+ documentsCleared: "すべてのドキュメントを削除しました",
+ deleteIndex: "インデックスを削除",
+ deleteIndexConfirmMessage: "インデックスとそのすべてのドキュメントが完全に削除されます。",
+ indexDeleted: "インデックスを削除しました",
+ isIndexing: "インデックス中",
+ uid: "UID",
+ updatedAt: "更新日時",
+ primaryKey: "主キー",
+ documentCountLabel: "ドキュメント数",
+ databaseSize: "データベースサイズ",
+ copyDocument: "JSON をコピー",
+ copied: "コピーしました",
+ autoRefresh: "自動更新",
+ hybrid: "ハイブリッド検索",
+ hybridEmbedder: "エンベッダー",
+ hybridSemanticRatio: "セマンティック比率",
+ hybridNoEmbedders: "インデックス設定にエンベッダーが設定されていません。手動で入力してください。",
+ showRankingScore: "ランキングスコア",
+ rankingScoreThreshold: "スコアしきい値",
+ results: "検索結果",
+ exportResults: "結果をエクスポート",
+ exportSearchUnsupported: "Meilisearch はテキスト検索、ハイブリッド検索、スコアしきい値付き検索のページングを制限します。すべての絞り込み済みドキュメントを出力するには、これらの条件を解除してください。",
+ },
dynamodb: {
tableMetadataUnavailable: "DynamoDB テーブルのメタデータが読み込まれていません。更新して再試行してください。",
keyRequired: "DynamoDB 項目にはキー属性 {key} が必要です",
diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts
index 8c208aafc1..afb31cb60a 100644
--- a/apps/desktop/src/i18n/locales/ko.ts
+++ b/apps/desktop/src/i18n/locales/ko.ts
@@ -4075,6 +4075,60 @@ export default withEnglishFallback({
indexOtherOptions: "기타 옵션",
indexPropertiesUnavailable: "MongoDB Legacy 드라이버는 희소, 만료, 백그라운드 또는 버킷 크기를 보고할 수 없습니다. 네이티브 드라이버로 연결하여 확인하세요.",
},
+ meilisearch: {
+ documents: "문서",
+ settings: "설정",
+ search: "검색",
+ keywordPlaceholder: "문서 검색...",
+ filter: "필터",
+ filterPlaceholder: 'status = "published"',
+ sort: "정렬",
+ sortPlaceholder: "필드:asc",
+ sortHint: "여러 정렬식은 반각 쉼표(,)로 구분합니다",
+ sortNoAttributes: "이 인덱스에 설정된 정렬 가능 속성이 없습니다",
+ sortAsc: "오름차순",
+ sortDesc: "내림차순",
+ limit: "제한",
+ offset: "오프셋",
+ resultSummary: "약 {count}개 결과 · {time}ms",
+ viewJson: "JSON",
+ viewTable: "표",
+ viewGrid: "그리드",
+ empty: "문서를 찾을 수 없습니다",
+ editDocument: "문서 편집",
+ deleteDocument: "문서 삭제",
+ deleteDocumentMessage: "이 문서를 삭제할까요? 이 작업은 되돌릴 수 없습니다.",
+ documentDeleted: "문서가 삭제되었습니다",
+ documentSaved: "문서가 저장되었습니다",
+ settingsSaved: "설정이 저장되었습니다",
+ invalidJson: "잘못된 JSON",
+ settingsJson: "설정 JSON",
+ dangerZone: "위험 구역",
+ clearDocuments: "모든 문서 비우기",
+ clearDocumentsConfirmMessage: "인덱스의 모든 문서가 제거됩니다. 이 작업은 되돌릴 수 없습니다.",
+ documentsCleared: "모든 문서가 삭제되었습니다",
+ deleteIndex: "인덱스 삭제",
+ deleteIndexConfirmMessage: "인덱스와 모든 문서가 영구적으로 삭제됩니다.",
+ indexDeleted: "인덱스가 삭제되었습니다",
+ isIndexing: "인덱싱 중",
+ uid: "UID",
+ updatedAt: "업데이트됨",
+ primaryKey: "기본 키",
+ documentCountLabel: "문서 수",
+ databaseSize: "데이터베이스 크기",
+ copyDocument: "JSON 복사",
+ copied: "복사됨",
+ autoRefresh: "자동 새로고침",
+ hybrid: "하이브리드 검색",
+ hybridEmbedder: "임베더",
+ hybridSemanticRatio: "의미 비율",
+ hybridNoEmbedders: "인덱스 설정에 구성된 임베더가 없습니다. 직접 입력하세요.",
+ showRankingScore: "랭킹 점수",
+ rankingScoreThreshold: "점수 임계값",
+ results: "검색 결과",
+ exportResults: "결과보내기",
+ exportSearchUnsupported: "Meilisearch는 텍스트, 하이브리드 또는 점수 임계값 검색의 페이지 탐색을 제한합니다. 필터링된 모든 문서를 내보내려면 해당 옵션을 지우세요.",
+ },
dynamodb: {
tableMetadataUnavailable: "DynamoDB 테이블 메타데이터가 로드되지 않았습니다. 새로 고친 후 다시 시도하세요.",
keyRequired: "DynamoDB 항목에는 키 속성 {key}이(가) 필요합니다",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index 6906865782..36a5c9af2a 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -4433,6 +4433,60 @@ export default withEnglishFallback({
indexOtherOptions: "Outras opções",
indexPropertiesUnavailable: "O driver Legacy do MongoDB não pode informar esparso, expiração, background ou tamanho do bucket. Conecte-se com o driver nativo para vê-los.",
},
+ meilisearch: {
+ documents: "Documentos",
+ settings: "Configurações",
+ search: "Buscar",
+ keywordPlaceholder: "Buscar documentos...",
+ filter: "Filtro",
+ filterPlaceholder: 'status = "published"',
+ sort: "Ordenação",
+ sortPlaceholder: "campo:asc",
+ sortHint: "Separe várias expressões de ordenação com vírgulas (,)",
+ sortNoAttributes: "Nenhum atributo ordenável configurado para este índice",
+ sortAsc: "Crescente",
+ sortDesc: "Decrescente",
+ limit: "Limite",
+ offset: "Deslocamento",
+ resultSummary: "Cerca de {count} resultados · {time} ms",
+ viewJson: "JSON",
+ viewTable: "Tabela",
+ viewGrid: "Grade",
+ empty: "Nenhum documento encontrado",
+ editDocument: "Editar documento",
+ deleteDocument: "Excluir documento",
+ deleteDocumentMessage: "Excluir este documento? Esta ação não pode ser desfeita.",
+ documentDeleted: "Documento excluído",
+ documentSaved: "Documento salvo",
+ settingsSaved: "Configurações salvas",
+ invalidJson: "JSON inválido",
+ settingsJson: "JSON de configurações",
+ dangerZone: "Zona de perigo",
+ clearDocuments: "Limpar todos os documentos",
+ clearDocumentsConfirmMessage: "Isso remove todos os documentos do índice. Esta ação não pode ser desfeita.",
+ documentsCleared: "Documentos excluídos",
+ deleteIndex: "Excluir índice",
+ deleteIndexConfirmMessage: "Isso exclui permanentemente o índice e todos os seus documentos.",
+ indexDeleted: "Índice excluído",
+ isIndexing: "Indexando",
+ uid: "UID",
+ updatedAt: "Atualizado",
+ primaryKey: "Chave primária",
+ documentCountLabel: "Documentos",
+ databaseSize: "Tamanho do banco de dados",
+ copyDocument: "Copiar JSON",
+ copied: "Copiado",
+ autoRefresh: "Atualização automática",
+ hybrid: "Busca híbrida",
+ hybridEmbedder: "Embedder",
+ hybridSemanticRatio: "Proporção semântica",
+ hybridNoEmbedders: "Nenhum embedder configurado nas configurações do índice; insira manualmente.",
+ showRankingScore: "Pontuação de ranking",
+ rankingScoreThreshold: "Limite de pontuação",
+ results: "Resultados",
+ exportResults: "Exportar resultados",
+ exportSearchUnsupported: "O Meilisearch limita a paginação de buscas por texto, híbridas ou com limite de pontuação. Limpe essas opções para exportar todos os documentos filtrados.",
+ },
dynamodb: {
tableMetadataUnavailable: "Os metadados da tabela DynamoDB não foram carregados. Atualize e tente novamente.",
keyRequired: "Os itens do DynamoDB exigem o atributo de chave {key}",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index e8d075678a..0789c61016 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -4578,6 +4578,60 @@ export default withEnglishFallback({
filterPlaceholder: "过滤条件...",
sortPlaceholder: "排序条件...",
},
+ meilisearch: {
+ documents: "文档",
+ settings: "设置",
+ search: "搜索",
+ keywordPlaceholder: "搜索文档...",
+ filter: "过滤",
+ filterPlaceholder: 'status = "published"',
+ sort: "排序",
+ sortPlaceholder: "field:asc",
+ sortHint: "使用半角逗号(,)分隔多个排序表达式",
+ sortNoAttributes: "索引未配置可排序字段",
+ sortAsc: "升序",
+ sortDesc: "降序",
+ limit: "条数",
+ offset: "偏移",
+ resultSummary: "约 {count} 条结果 · {time} 毫秒",
+ viewJson: "JSON",
+ viewTable: "表格",
+ viewGrid: "网格",
+ empty: "未找到文档",
+ editDocument: "编辑文档",
+ deleteDocument: "删除文档",
+ deleteDocumentMessage: "确定删除此文档?此操作无法撤销。",
+ documentDeleted: "文档已删除",
+ documentSaved: "文档已保存",
+ settingsSaved: "设置已保存",
+ invalidJson: "无效的 JSON",
+ settingsJson: "设置 JSON",
+ dangerZone: "危险区域",
+ clearDocuments: "清空全部文档",
+ clearDocumentsConfirmMessage: "这将移除索引中的全部文档,且无法撤销。",
+ documentsCleared: "全部文档已清空",
+ deleteIndex: "删除索引",
+ deleteIndexConfirmMessage: "这将永久删除该索引及其全部文档。",
+ indexDeleted: "索引已删除",
+ isIndexing: "索引中",
+ uid: "UID",
+ updatedAt: "更新于",
+ primaryKey: "主键",
+ documentCountLabel: "文档数",
+ databaseSize: "数据库大小",
+ copyDocument: "复制 JSON",
+ copied: "已复制",
+ autoRefresh: "自动刷新",
+ hybrid: "混合搜索",
+ hybridEmbedder: "嵌入模型(Embedder)",
+ hybridSemanticRatio: "语义权重",
+ hybridNoEmbedders: "索引设置中未配置嵌入模型,可手动输入。",
+ showRankingScore: "排名分数",
+ rankingScoreThreshold: "分数阈值",
+ results: "搜索结果",
+ exportResults: "导出结果",
+ exportSearchUnsupported: "Meilisearch 会限制文本、混合或分数阈值搜索的分页,无法保证完整导出。请清除这些选项后导出全部筛选文档。",
+ },
dynamodb: {
tableMetadataUnavailable: "尚未加载 DynamoDB 表结构,请刷新后重试",
keyRequired: "DynamoDB 项目必须填写键字段 {key}",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index 7f6a2ff5ea..760920b386 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -3760,6 +3760,60 @@ export default withEnglishFallback({
invalidNativeJson: "原生 JSON 格式無效:{error}",
nativeDocumentRequired: "原生 JSON 的最上層必須是文件物件。",
},
+ meilisearch: {
+ documents: "文件",
+ settings: "設定",
+ search: "搜尋",
+ keywordPlaceholder: "搜尋文件...",
+ filter: "篩選",
+ filterPlaceholder: 'status = "published"',
+ sort: "排序",
+ sortPlaceholder: "field:asc",
+ sortHint: "使用半形逗號(,)分隔多個排序表達式",
+ sortNoAttributes: "索引未設定可排序欄位",
+ sortAsc: "升序",
+ sortDesc: "降序",
+ limit: "筆數",
+ offset: "位移",
+ resultSummary: "約 {count} 筆結果 · {time} 毫秒",
+ viewJson: "JSON",
+ viewTable: "表格",
+ viewGrid: "網格",
+ empty: "找不到文件",
+ editDocument: "編輯文件",
+ deleteDocument: "刪除文件",
+ deleteDocumentMessage: "確定刪除此文件?此操作無法復原。",
+ documentDeleted: "文件已刪除",
+ documentSaved: "文件已儲存",
+ settingsSaved: "設定已儲存",
+ invalidJson: "無效的 JSON",
+ settingsJson: "設定 JSON",
+ dangerZone: "危險區域",
+ clearDocuments: "清空全部文件",
+ clearDocumentsConfirmMessage: "這將移除索引中的全部文件,且無法復原。",
+ documentsCleared: "全部文件已清空",
+ deleteIndex: "刪除索引",
+ deleteIndexConfirmMessage: "這將永久刪除該索引及其全部文件。",
+ indexDeleted: "索引已刪除",
+ isIndexing: "建立索引中",
+ uid: "UID",
+ updatedAt: "更新於",
+ primaryKey: "主鍵",
+ documentCountLabel: "文件數",
+ databaseSize: "資料庫大小",
+ copyDocument: "複製 JSON",
+ copied: "已複製",
+ autoRefresh: "自動重新整理",
+ hybrid: "混合搜尋",
+ hybridEmbedder: "嵌入模型(Embedder)",
+ hybridSemanticRatio: "語意權重",
+ hybridNoEmbedders: "索引設定中未設定嵌入模型,可手動輸入。",
+ showRankingScore: "排名分數",
+ rankingScoreThreshold: "分數閾值",
+ results: "搜尋結果",
+ exportResults: "匯出結果",
+ exportSearchUnsupported: "Meilisearch 會限制文字、混合或分數閾值搜尋的分頁,無法保證完整匯出。請清除這些選項後匯出全部篩選文件。",
+ },
dynamodb: {
tableMetadataUnavailable: "尚未載入 DynamoDB 表結構,請重新整理後再試",
keyRequired: "DynamoDB 項目必須填寫鍵欄位 {key}",
diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts
index a9613bf4f5..0e8e14bc4a 100644
--- a/apps/desktop/src/lib/backend/api.ts
+++ b/apps/desktop/src/lib/backend/api.ts
@@ -728,6 +728,15 @@ export const mongoUpdateDocument = forward("mongoUpdateDocument");
export const mongoUpdateDocuments = forward("mongoUpdateDocuments");
export const documentDeleteDocument = forward("documentDeleteDocument");
export const documentSaveMeilisearchBatch = forward("documentSaveMeilisearchBatch");
+export const meilisearchSearchDocuments = forward("meilisearchSearchDocuments");
+export const meilisearchFetchDocuments = forward("meilisearchFetchDocuments");
+export const meilisearchGetDocument = forward("meilisearchGetDocument");
+export const meilisearchGetIndexSettings = forward("meilisearchGetIndexSettings");
+export const meilisearchUpdateIndexSettings = forward("meilisearchUpdateIndexSettings");
+export const meilisearchGetIndexStats = forward("meilisearchGetIndexStats");
+export const meilisearchGetIndexOverview = forward("meilisearchGetIndexOverview");
+export const meilisearchDeleteIndex = forward("meilisearchDeleteIndex");
+export const meilisearchDeleteAllDocuments = forward("meilisearchDeleteAllDocuments");
export const mongoDeleteDocument = forward("mongoDeleteDocument");
export const mongoDeleteDocuments = forward("mongoDeleteDocuments");
export const mongoFindOneAndUpdate = forward("mongoFindOneAndUpdate");
diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts
index 6eae92a068..fbe71efa6c 100644
--- a/apps/desktop/src/lib/backend/http.ts
+++ b/apps/desktop/src/lib/backend/http.ts
@@ -47,6 +47,7 @@ import type {
} from "@/types/database";
import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoShellCommand";
import { BackendErrorException, type BackendError } from "@/lib/backend/errorUtils";
+import { decodeMeilisearchDocumentPage, decodeMeilisearchSearchResult, type MeilisearchDocumentPage, type MeilisearchDocumentPageWire, type MeilisearchSearchResult, type MeilisearchSearchWireResult } from "@/lib/backend/meilisearchTransport";
import type { CollectionInfo } from "@/types/database";
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff";
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
@@ -139,6 +140,7 @@ import type {
AppSupportInfo,
PromptTemplate,
SshPromptResolution,
+ MeilisearchIndexOverview,
} from "@/lib/backend/tauri";
import type { QueryEditability } from "@/lib/sql/sqlAnalysis";
import { isTerminalTransferProgress } from "@/lib/backend/transferProgress";
@@ -3878,6 +3880,90 @@ export async function documentSaveMeilisearchBatch(connectionId: string, collect
});
}
+export async function meilisearchSearchDocuments(
+ connectionId: string,
+ index: string,
+ params: { q?: string | null; filter?: string | null; sort?: string | null; limit: number; offset: number; hybridEmbedder?: string | null; hybridSemanticRatio?: number | null; showRankingScore?: boolean; rankingScoreThreshold?: number | null },
+): Promise {
+ const result = await post("/api/document-store/meilisearch/search", {
+ connectionId,
+ index,
+ q: params.q ?? null,
+ filter: params.filter ?? null,
+ sort: params.sort ?? null,
+ limit: params.limit,
+ offset: params.offset,
+ hybridEmbedder: params.hybridEmbedder ?? null,
+ hybridSemanticRatio: params.hybridSemanticRatio ?? null,
+ showRankingScore: params.showRankingScore ?? false,
+ rankingScoreThreshold: params.rankingScoreThreshold ?? null,
+ });
+ return decodeMeilisearchSearchResult(result);
+}
+
+export async function meilisearchFetchDocuments(connectionId: string, index: string, params: { filter?: string | null; sort?: string | null; limit: number; offset: number }): Promise {
+ const page = await post("/api/document-store/meilisearch/documents/fetch", {
+ connectionId,
+ index,
+ filter: params.filter ?? null,
+ sort: params.sort ?? null,
+ limit: params.limit,
+ offset: params.offset,
+ });
+ return decodeMeilisearchDocumentPage(page);
+}
+
+export async function meilisearchGetDocument(connectionId: string, index: string, id: string): Promise {
+ return post("/api/document-store/meilisearch/documents/get", {
+ connectionId,
+ index,
+ id,
+ });
+}
+
+export async function meilisearchGetIndexSettings(connectionId: string, index: string): Promise> {
+ return post("/api/document-store/meilisearch/settings/get", {
+ connectionId,
+ index,
+ });
+}
+
+export async function meilisearchUpdateIndexSettings(connectionId: string, index: string, settings: Record): Promise {
+ return post("/api/document-store/meilisearch/settings/update", {
+ connectionId,
+ index,
+ settings,
+ });
+}
+
+export async function meilisearchGetIndexStats(connectionId: string, index: string): Promise<{ numberOfDocuments: number; isIndexing: boolean; fieldDistribution: Record } & Record> {
+ return post("/api/document-store/meilisearch/stats", {
+ connectionId,
+ index,
+ });
+}
+
+export async function meilisearchGetIndexOverview(connectionId: string, index: string): Promise {
+ return post("/api/document-store/meilisearch/overview", {
+ connectionId,
+ index,
+ });
+}
+
+export async function meilisearchDeleteIndex(connectionId: string, index: string): Promise {
+ return post("/api/document-store/meilisearch/index/delete", {
+ connectionId,
+ index,
+ });
+}
+
+export async function meilisearchDeleteAllDocuments(connectionId: string, index: string): Promise {
+ return post("/api/document-store/meilisearch/documents/delete-all", {
+ connectionId,
+ index,
+ });
+}
+
export async function mongoDeleteDocuments(connectionId: string, database: string, collection: string, filterJson: string, many: boolean): Promise<{ affected_rows: number }> {
return post("/api/mongo/delete-documents", {
connectionId,
diff --git a/apps/desktop/src/lib/backend/meilisearchTransport.spec.ts b/apps/desktop/src/lib/backend/meilisearchTransport.spec.ts
new file mode 100644
index 0000000000..1a6ecf9cc3
--- /dev/null
+++ b/apps/desktop/src/lib/backend/meilisearchTransport.spec.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest";
+import { serializeDocumentStoreId, stringifyDocumentStoreValue } from "@/lib/app/documentJsonValues";
+import { decodeMeilisearchDocumentPage, decodeMeilisearchSearchResult } from "./meilisearchTransport";
+
+describe("Meilisearch transport", () => {
+ it("keeps large integer identities and document JSON exact", () => {
+ const result = decodeMeilisearchSearchResult({
+ hits: [
+ {
+ idJson: "9007199254740993",
+ documentJson: '{"movie_id":9007199254740993,"title":"Alien"}',
+ },
+ ],
+ totalHits: 1,
+ processingTimeMs: 1,
+ });
+
+ expect(serializeDocumentStoreId(result.hits[0].id, "meilisearch")).toBe("9007199254740993");
+ expect(stringifyDocumentStoreValue(result.hits[0].document, "meilisearch")).toBe('{"movie_id":9007199254740993,"title":"Alien"}');
+ });
+
+ it("decodes full-document export pages without rounding integers", () => {
+ const page = decodeMeilisearchDocumentPage({
+ documentsJson: ['{"movie_id":9007199254740993,"internal_notes":"secret"}'],
+ total: 1,
+ });
+
+ expect(stringifyDocumentStoreValue(page.documents, "meilisearch")).toBe('[{"movie_id":9007199254740993,"internal_notes":"secret"}]');
+ });
+});
diff --git a/apps/desktop/src/lib/backend/meilisearchTransport.ts b/apps/desktop/src/lib/backend/meilisearchTransport.ts
new file mode 100644
index 0000000000..363e98fd64
--- /dev/null
+++ b/apps/desktop/src/lib/backend/meilisearchTransport.ts
@@ -0,0 +1,63 @@
+import { parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
+
+export type MeilisearchSearchHit = {
+ id?: unknown;
+ document: Record;
+ formatted?: Record;
+ rankingScore?: unknown;
+};
+
+export type MeilisearchSearchResult = {
+ hits: MeilisearchSearchHit[];
+ totalHits: number;
+ processingTimeMs: number;
+};
+
+export type MeilisearchSearchWireResult = {
+ hits: Array<{
+ idJson?: string;
+ documentJson: string;
+ formattedJson?: string;
+ rankingScoreJson?: string;
+ }>;
+ totalHits: number;
+ processingTimeMs: number;
+};
+
+export type MeilisearchDocumentPage = {
+ documents: Record[];
+ total: number;
+};
+
+export type MeilisearchDocumentPageWire = {
+ documentsJson: string[];
+ total: number;
+};
+
+function parseJsonObject(json: string, context: string): Record {
+ const value = parseJsonPreservingLargeNumbers(json);
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ throw new Error(`Invalid Meilisearch ${context} payload`);
+ }
+ return value as Record;
+}
+
+export function decodeMeilisearchSearchResult(result: MeilisearchSearchWireResult): MeilisearchSearchResult {
+ return {
+ hits: result.hits.map((hit) => ({
+ id: hit.idJson === undefined ? undefined : parseJsonPreservingLargeNumbers(hit.idJson),
+ document: parseJsonObject(hit.documentJson, "document"),
+ formatted: hit.formattedJson === undefined ? undefined : parseJsonObject(hit.formattedJson, "formatted document"),
+ rankingScore: hit.rankingScoreJson === undefined ? undefined : parseJsonPreservingLargeNumbers(hit.rankingScoreJson),
+ })),
+ totalHits: result.totalHits,
+ processingTimeMs: result.processingTimeMs,
+ };
+}
+
+export function decodeMeilisearchDocumentPage(page: MeilisearchDocumentPageWire): MeilisearchDocumentPage {
+ return {
+ documents: page.documentsJson.map((document) => parseJsonObject(document, "document")),
+ total: page.total,
+ };
+}
diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts
index 7ee125c1ed..c07cf2f848 100644
--- a/apps/desktop/src/lib/backend/tauri.ts
+++ b/apps/desktop/src/lib/backend/tauri.ts
@@ -4,6 +4,7 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoShellCommand";
import { ExternalSqlFileTooLargeError } from "@/lib/sql/sqlFileOpen";
import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
+import { decodeMeilisearchDocumentPage, decodeMeilisearchSearchResult, type MeilisearchDocumentPage, type MeilisearchDocumentPageWire, type MeilisearchSearchResult, type MeilisearchSearchWireResult } from "@/lib/backend/meilisearchTransport";
/** Normalize Tauri rejections once at the public backend boundary. */
async function invokeBackend(command: string, args?: Record): Promise {
@@ -3797,6 +3798,100 @@ export async function documentSaveMeilisearchBatch(connectionId: string, collect
});
}
+export async function meilisearchSearchDocuments(
+ connectionId: string,
+ index: string,
+ params: { q?: string | null; filter?: string | null; sort?: string | null; limit: number; offset: number; hybridEmbedder?: string | null; hybridSemanticRatio?: number | null; showRankingScore?: boolean; rankingScoreThreshold?: number | null },
+): Promise {
+ const result = await invoke("meilisearch_search_documents", {
+ connectionId,
+ index,
+ q: params.q ?? null,
+ filter: params.filter ?? null,
+ sort: params.sort ?? null,
+ limit: params.limit,
+ offset: params.offset,
+ hybridEmbedder: params.hybridEmbedder ?? null,
+ hybridSemanticRatio: params.hybridSemanticRatio ?? null,
+ showRankingScore: params.showRankingScore ?? false,
+ rankingScoreThreshold: params.rankingScoreThreshold ?? null,
+ });
+ return decodeMeilisearchSearchResult(result);
+}
+
+export async function meilisearchFetchDocuments(connectionId: string, index: string, params: { filter?: string | null; sort?: string | null; limit: number; offset: number }): Promise {
+ const page = await invoke("meilisearch_fetch_documents", {
+ connectionId,
+ index,
+ filter: params.filter ?? null,
+ sort: params.sort ?? null,
+ limit: params.limit,
+ offset: params.offset,
+ });
+ return decodeMeilisearchDocumentPage(page);
+}
+
+export async function meilisearchGetDocument(connectionId: string, index: string, id: string): Promise {
+ return invoke("meilisearch_get_document", {
+ connectionId,
+ index,
+ id,
+ });
+}
+
+export async function meilisearchGetIndexSettings(connectionId: string, index: string): Promise> {
+ return invoke("meilisearch_get_index_settings", {
+ connectionId,
+ index,
+ });
+}
+
+export async function meilisearchUpdateIndexSettings(connectionId: string, index: string, settings: Record): Promise {
+ return invoke("meilisearch_update_index_settings", {
+ connectionId,
+ index,
+ settings,
+ });
+}
+
+export async function meilisearchGetIndexStats(connectionId: string, index: string): Promise<{ numberOfDocuments: number; isIndexing: boolean; fieldDistribution: Record } & Record> {
+ return invoke("meilisearch_get_index_stats", {
+ connectionId,
+ index,
+ });
+}
+
+export interface MeilisearchIndexOverview {
+ uid: string;
+ primaryKey: string | null;
+ createdAt: string | null;
+ updatedAt: string | null;
+ numberOfDocuments: number;
+ isIndexing: boolean;
+ databaseSize: number | null;
+}
+
+export async function meilisearchGetIndexOverview(connectionId: string, index: string): Promise {
+ return invoke("meilisearch_get_index_overview", {
+ connectionId,
+ index,
+ });
+}
+
+export async function meilisearchDeleteIndex(connectionId: string, index: string): Promise {
+ return invoke("meilisearch_delete_index", {
+ connectionId,
+ index,
+ });
+}
+
+export async function meilisearchDeleteAllDocuments(connectionId: string, index: string): Promise {
+ return invoke("meilisearch_delete_all_documents", {
+ connectionId,
+ index,
+ });
+}
+
export async function mongoDeleteDocuments(connectionId: string, database: string, collection: string, filterJson: string, many: boolean): Promise<{ affected_rows: number }> {
const affectedRows = await invoke("mongo_delete_documents", {
connectionId,
diff --git a/apps/desktop/src/lib/common/__tests__/jsonTree.spec.ts b/apps/desktop/src/lib/common/__tests__/jsonTree.spec.ts
index 18d898cb36..aa97e68998 100644
--- a/apps/desktop/src/lib/common/__tests__/jsonTree.spec.ts
+++ b/apps/desktop/src/lib/common/__tests__/jsonTree.spec.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { appendJsonPointer, createJsonTreeRoot, getJsonTreeChildren, getVisibleJsonTreeNodes, isJsonTreeContainer, isJsonTreeInitiallyExpanded } from "../jsonTree";
+import { appendJsonPointer, createJsonTreeRoot, getJsonTreeChildren, getVisibleJsonTreeNodes, isJsonTreeClosingNode, isJsonTreeContainer, isJsonTreeInitiallyExpanded } from "../jsonTree";
import { parseJsonPreservingLargeNumbers } from "../safeJsonFormat";
describe("jsonTree", () => {
@@ -35,6 +35,15 @@ describe("jsonTree", () => {
expect(nodes.map((node) => node.path)).toEqual(["", "/first", "/second", "/second/0"]);
});
+ it("adds closing-bracket rows after expanded containers when requested", () => {
+ const root = createJsonTreeRoot({ first: { nested: true }, second: ["kept"] });
+ const nodes = getVisibleJsonTreeNodes(root, () => true, true);
+
+ expect(nodes.map((node) => node.path)).toEqual(["", "/first", "/first/nested", "/first/$close\0", "/second", "/second/0", "/second/$close\0", "/$close\0"]);
+ expect(isJsonTreeClosingNode(nodes[3])).toBe(true);
+ expect(isJsonTreeClosingNode(nodes[0])).toBe(false);
+ });
+
it("handles deeply nested expanded JSON without recursive traversal", () => {
let value: unknown = true;
for (let depth = 0; depth < 2_000; depth += 1) value = { child: value };
diff --git a/apps/desktop/src/lib/common/jsonTree.ts b/apps/desktop/src/lib/common/jsonTree.ts
index 62792d3f9c..0d03c53a2d 100644
--- a/apps/desktop/src/lib/common/jsonTree.ts
+++ b/apps/desktop/src/lib/common/jsonTree.ts
@@ -12,6 +12,8 @@ export interface JsonTreeNode {
/** Zero-based structural depth; the root value is at depth zero. */
depth: number;
parentKind: JsonTreeParentKind;
+ /** True only for the synthetic closing-bracket row of an expanded container. */
+ closing?: boolean;
}
export type JsonTreeContainer = Record | unknown[];
@@ -36,9 +38,14 @@ export function jsonTreeContainerKind(value: JsonTreeContainer): JsonTreeContain
return Array.isArray(value) ? "array" : "object";
}
-export function jsonTreeContainerSummary(value: JsonTreeContainer, includeObjectLength = true): string {
+/** Compact summary shown between the brackets of a collapsed container. */
+export function jsonTreeContainerSummary(value: JsonTreeContainer): string {
if (Array.isArray(value)) return `Array(${value.length})`;
- return includeObjectLength ? `Object(${Object.keys(value).length})` : "Object";
+ return `Object(${Object.keys(value).length})`;
+}
+
+export function isJsonTreeClosingNode(node: JsonTreeNode): boolean {
+ return node.closing === true;
}
/** Escape a reference token according to RFC 6901. */
@@ -81,9 +88,10 @@ export function getJsonTreeChildren(node: JsonTreeNode): JsonTreeNode[] {
/**
* Flatten the currently expanded tree with an iterative traversal. This lets
* virtual renderers keep every node logically expanded without recursive DOM
- * creation or deep-call-stack failures.
+ * creation or deep-call-stack failures. When `includeClosingRows` is set, each
+ * expanded container is followed by a synthetic closing-bracket row.
*/
-export function getVisibleJsonTreeNodes(root: JsonTreeNode, isExpanded: (node: JsonTreeNode) => boolean): JsonTreeNode[] {
+export function getVisibleJsonTreeNodes(root: JsonTreeNode, isExpanded: (node: JsonTreeNode) => boolean, includeClosingRows = false): JsonTreeNode[] {
const nodes: JsonTreeNode[] = [];
const pending = [root];
@@ -92,7 +100,20 @@ export function getVisibleJsonTreeNodes(root: JsonTreeNode, isExpanded: (node: J
if (!node) continue;
nodes.push(node);
- if (!isJsonTreeContainer(node.value) || !isExpanded(node)) continue;
+ if (isJsonTreeClosingNode(node) || !isJsonTreeContainer(node.value) || !isExpanded(node)) continue;
+ if (includeClosingRows) {
+ pending.push({
+ // NUL suffix keeps synthetic closing rows from colliding with a real
+ // `$close` key that a document may legitimately contain.
+ key: `${node.key}$close\0`,
+ label: "",
+ value: node.value,
+ path: `${node.path}/$close\0`,
+ depth: node.depth,
+ parentKind: node.parentKind,
+ closing: true,
+ });
+ }
const children = getJsonTreeChildren(node);
for (let index = children.length - 1; index >= 0; index -= 1) pending.push(children[index]);
}
diff --git a/apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts b/apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts
index 59840cb9bb..cee76136e4 100644
--- a/apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts
+++ b/apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts
@@ -116,6 +116,17 @@ export function activeTabSidebarTarget(tab: QueryTab | undefined | null): Active
};
}
+ if (tab.mode === "meilisearch") {
+ const collectionName = tab.sql || tab.title;
+ if (!collectionName) return null;
+ return {
+ type: "mongo-collection",
+ connectionId: tab.connectionId,
+ database: tab.database,
+ collectionName,
+ };
+ }
+
if (tab.mode === "mongo-bucket") {
return {
type: "mongo-gridfs",
diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts
index dadb0bf115..5db6a1182a 100644
--- a/apps/desktop/src/types/database.ts
+++ b/apps/desktop/src/types/database.ts
@@ -1186,6 +1186,7 @@ export interface QueryTab {
| "redis"
| "redis-dashboard"
| "mongo"
+ | "meilisearch"
| "mongo-gridfs"
| "mongo-bucket"
| "vector"
diff --git a/crates/dbx-core/src/db/meilisearch_driver.rs b/crates/dbx-core/src/db/meilisearch_driver.rs
index c8ceaf3598..cb116c434d 100644
--- a/crates/dbx-core/src/db/meilisearch_driver.rs
+++ b/crates/dbx-core/src/db/meilisearch_driver.rs
@@ -2,7 +2,8 @@ use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, CONTRO
use reqwest::{Client as HttpClient, Method, RequestBuilder, StatusCode};
use serde::Deserialize;
use serde_json::{Map, Value};
-use std::collections::BTreeMap;
+use std::collections::{BTreeMap, HashMap};
+use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use super::{http_client_builder, ColumnInfo};
@@ -38,6 +39,9 @@ pub struct MeilisearchClient {
http: HttpClient,
base_url: String,
api_key: Option,
+ /// Per-index metadata cache so repeat searches stay single-request. Shared
+ /// across clones of the pooled client; evicted when the index is deleted.
+ index_info_cache: Arc>>,
}
impl MeilisearchClient {
@@ -54,7 +58,12 @@ impl MeilisearchClient {
}
let http = builder.build().map_err(|error| format!("Meilisearch HTTP client error: {error}"))?;
let api_key = api_key.map(str::trim).filter(|value| !value.is_empty()).map(str::to_string);
- Ok(Self { http, base_url: url.trim_end_matches('/').to_string(), api_key })
+ Ok(Self {
+ http,
+ base_url: url.trim_end_matches('/').to_string(),
+ api_key,
+ index_info_cache: Arc::new(Mutex::new(HashMap::new())),
+ })
}
pub fn new_for_config(
@@ -167,7 +176,7 @@ pub async fn test_connection(client: &MeilisearchClient, _timeout: Duration) ->
Ok(())
}
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IndexInfoResponse {
uid: String,
@@ -183,13 +192,19 @@ struct IndexListResponse {
}
async fn index_info(client: &MeilisearchClient, index: &str) -> Result {
+ if let Some(cached) = client.index_info_cache.lock().unwrap().get(index).cloned() {
+ return Ok(cached);
+ }
let response = client
.get(&format!("/indexes/{}", encode_path_segment(index)))
.send()
.await
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
let value = response_json(response, "index lookup").await?;
- serde_json::from_value(value).map_err(|error| format!("Meilisearch index parse error: {error}"))
+ let info: IndexInfoResponse =
+ serde_json::from_value(value).map_err(|error| format!("Meilisearch index parse error: {error}"))?;
+ client.index_info_cache.lock().unwrap().insert(index.to_string(), info.clone());
+ Ok(info)
}
pub async fn list_indexes(client: &MeilisearchClient) -> Result, String> {
@@ -222,6 +237,26 @@ struct DocumentsResponse {
total: u64,
}
+fn meilisearch_fetch_body(
+ offset: u64,
+ limit: u64,
+ filter: Option<&str>,
+ sort: Option<&str>,
+ primary_key: Option<&str>,
+) -> Result