feat(elasticsearch): add mapping, settings, stats and clear actions

This commit is contained in:
onenewcode authored and GitHub committed 2026-08-31 22:31:35 +08:00
1 parent a84cb637ba
commit 8f3134dce8
32 files changed
+1262 -3

No files matched your search

@@ -0,0 +1,58 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const tauri = readFileSync(new URL("../tauri.ts", import.meta.url), "utf8");
const http = readFileSync(new URL("../http.ts", import.meta.url), "utf8");
const api = readFileSync(new URL("../api.ts", import.meta.url), "utf8");
const tauriRegistry = readFileSync(new URL("../../../../../../src-tauri/src/lib.rs", import.meta.url), "utf8");
const webRegistry = readFileSync(new URL("../../../../../../crates/dbx-web/src/main.rs", import.meta.url), "utf8");
const coreOps = readFileSync(new URL("../../../../../../crates/dbx-core/src/document_ops.rs", import.meta.url), "utf8");
function functionBody(source: string, operation: string): string {
const start = source.indexOf(`export async function ${operation}(`);
expect(start, `${operation} transport function`).toBeGreaterThanOrEqual(0);
const end = source.indexOf("\nexport async function ", start + 1);
return source.slice(start, end === -1 ? source.length : end);
}
/**
* Nothing type-checks a frontend command name against its Rust registration, so
* a rename on either side would ship a menu whose actions all fail at runtime.
*/
const operations = [
{ name: "elasticsearchGetIndexMetadata", command: "elasticsearch_get_index_metadata", route: "/api/document-store/elasticsearch/index-metadata" },
{ name: "elasticsearchDeleteAllDocuments", command: "elasticsearch_delete_all_documents", route: "/api/document-store/elasticsearch/documents/delete-all" },
] as const;
describe("Elasticsearch index action dual transport contract", () => {
it.each(operations)("registers $name end-to-end", ({ name, command, route }) => {
expect(functionBody(tauri, name).match(/invoke\("([^"]+)"/)?.[1], `${name} invoke command`).toBe(command);
expect(functionBody(http, name).match(/post\("([^"]+)"/)?.[1], `${name} HTTP route`).toBe(route);
expect(api).toContain(`${name} = forward("${name}")`);
expect(tauriRegistry).toContain(`commands::document_cmd::${command},`);
expect(webRegistry).toContain(`"${route.replace(/^\/api/, "")}",`);
});
it("sends the metadata kind literals the Rust enum deserializes", () => {
const body = functionBody(tauri, "elasticsearchGetIndexMetadata");
expect(body).toContain("kind,");
// Mirrors ElasticsearchIndexMetadataKind's camelCase variant renaming.
expect(tauri).toContain('export type ElasticsearchIndexMetadataKind = "mapping" | "settings" | "stats"');
expect(coreOps).toContain("pub enum ElasticsearchIndexMetadataKind");
});
it("keeps the clear-index result fields the partial-clear check reads", () => {
for (const field of ["total", "deleted", "versionConflicts", "timedOut", "failures"]) {
expect(tauri, `ElasticsearchDeleteByQueryResult.${field}`).toContain(` ${field}:`);
}
});
it("guards the destructive clear behind a backend write check", () => {
const commands = readFileSync(new URL("../../../../../../src-tauri/src/commands/document_cmd.rs", import.meta.url), "utf8");
const routes = readFileSync(new URL("../../../../../../crates/dbx-web/src/routes/document_store.rs", import.meta.url), "utf8");
const tauriCommand = commands.slice(commands.indexOf("pub async fn elasticsearch_delete_all_documents("));
const webRoute = routes.slice(routes.indexOf("pub async fn elasticsearch_delete_all_documents("));
expect(tauriCommand.slice(0, tauriCommand.indexOf("\n}"))).toContain("ensure_connection_writable");
expect(webRoute.slice(0, webRoute.indexOf("\n}"))).toContain("ensure_writable");
});
});
+2
View File
@@ -790,6 +790,8 @@ export const mongoFindOneAndDelete = forward("mongoFindOneAndDelete");
// Elasticsearch
export const elasticsearchListIndices = forward("elasticsearchListIndices");
export const elasticsearchGetIndexMetadata = forward("elasticsearchGetIndexMetadata");
export const elasticsearchDeleteAllDocuments = forward("elasticsearchDeleteAllDocuments");
export const vectorListCollections = forward("vectorListCollections");
// History
+17
View File
@@ -147,6 +147,8 @@ import type {
PromptTemplate,
SshPromptResolution,
MeilisearchIndexOverview,
ElasticsearchIndexMetadataKind,
ElasticsearchDeleteByQueryResult,
} from "@/lib/backend/tauri";
import type { QueryEditability } from "@/lib/sql/sqlAnalysis";
import { isTerminalTransferProgress } from "@/lib/backend/transferProgress";
@@ -3869,6 +3871,21 @@ export async function elasticsearchCountDocuments(connectionId: string, index: s
});
}
export async function elasticsearchGetIndexMetadata(connectionId: string, index: string, kind: ElasticsearchIndexMetadataKind): Promise<Record<string, any>> {
return post("/api/document-store/elasticsearch/index-metadata", {
connectionId,
index,
kind,
});
}
export async function elasticsearchDeleteAllDocuments(connectionId: string, index: string): Promise<ElasticsearchDeleteByQueryResult> {
return post("/api/document-store/elasticsearch/documents/delete-all", {
connectionId,
index,
});
}
export async function mongoCountDocuments(connectionId: string, database: string, collection: string, filter?: string, mode?: "accurate" | "legacy", executionId?: string): Promise<number> {
return post("/api/mongo/count-documents", {
connectionId,
+27
View File
@@ -3859,6 +3859,33 @@ export async function elasticsearchCountDocuments(connectionId: string, index: s
});
}
/** Read-only index metadata endpoints exposed on the Elasticsearch index context menu. */
export type ElasticsearchIndexMetadataKind = "mapping" | "settings" | "stats";
/** Outcome of clearing an index: mapping and settings are kept, documents are not. */
export interface ElasticsearchDeleteByQueryResult {
total: number;
deleted: number;
versionConflicts: number;
timedOut: boolean;
failures: string[];
}
export async function elasticsearchGetIndexMetadata(connectionId: string, index: string, kind: ElasticsearchIndexMetadataKind): Promise<Record<string, any>> {
return invoke("elasticsearch_get_index_metadata", {
connectionId,
index,
kind,
});
}
export async function elasticsearchDeleteAllDocuments(connectionId: string, index: string): Promise<ElasticsearchDeleteByQueryResult> {
return invoke("elasticsearch_delete_all_documents", {
connectionId,
index,
});
}
export async function mongoCountDocuments(connectionId: string, database: string, collection: string, filter?: string, mode?: "accurate" | "legacy", executionId?: string): Promise<number> {
return invoke("mongo_count_documents", {
connectionId,
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { elasticsearchClearIndexPreview, isElasticsearchClearConfirmed, isElasticsearchIndexPattern, isElasticsearchProtocolIndex, isPartialElasticsearchClear, matchesElasticsearchIndexPattern } from "@/lib/sidebar/elasticsearchIndexActions";
import type { ElasticsearchDeleteByQueryResult } from "@/lib/backend/tauri";
function clearResult(overrides: Partial<ElasticsearchDeleteByQueryResult> = {}): ElasticsearchDeleteByQueryResult {
return { total: 10, deleted: 10, versionConflicts: 0, timedOut: false, failures: [], ...overrides };
}
describe("Elasticsearch index actions", () => {
it("only claims index nodes on Elasticsearch-protocol connections", () => {
expect(isElasticsearchProtocolIndex("elasticsearch-index", "elasticsearch")).toBe(true);
expect(isElasticsearchProtocolIndex("elasticsearch-index", "easysearch")).toBe(true);
// Meilisearch reuses the same tree node type but speaks a different API.
expect(isElasticsearchProtocolIndex("elasticsearch-index", "meilisearch")).toBe(false);
expect(isElasticsearchProtocolIndex("vector-collection", "elasticsearch")).toBe(false);
expect(isElasticsearchProtocolIndex("elasticsearch-index", undefined)).toBe(false);
});
it("previews the exact request the clear action sends", () => {
const preview = elasticsearchClearIndexPreview("logs-2026.08");
expect(preview).toContain("POST /logs-2026.08/_delete_by_query?conflicts=proceed&refresh=true");
expect(preview).toContain('"match_all"');
// The index itself must not appear as a DELETE target.
expect(preview).not.toContain("DELETE");
});
it("matches concrete indexes under a grouped node's pattern", () => {
expect(matchesElasticsearchIndexPattern("logs-2026.08.*", "logs-2026.08.01")).toBe(true);
expect(matchesElasticsearchIndexPattern("logs-?.*", "logs-1.daily")).toBe(true);
expect(matchesElasticsearchIndexPattern("logs-2026.08.*", "logs-2026.09.01")).toBe(false);
expect(matchesElasticsearchIndexPattern("logs-?", "logs-12")).toBe(false);
expect(matchesElasticsearchIndexPattern("logs", "logs-daily")).toBe(false);
expect(matchesElasticsearchIndexPattern("*", "anything")).toBe(true);
});
it("separates grouped wildcard nodes from real index names", () => {
expect(isElasticsearchIndexPattern("logs-2026.08.*")).toBe(true);
expect(isElasticsearchIndexPattern("logs-?")).toBe(true);
// Characters that are legal in an index name must not read as a pattern.
expect(isElasticsearchIndexPattern("logs-2026.08.04")).toBe(false);
expect(isElasticsearchIndexPattern("orders_v2-prod")).toBe(false);
});
it("lets a concrete index clear without extra typing", () => {
expect(isElasticsearchClearConfirmed("orders", "")).toBe(true);
expect(isElasticsearchClearConfirmed("logs-2026.08.04", "anything")).toBe(true);
});
it("holds a wildcard clear back until the pattern is typed back verbatim", () => {
expect(isElasticsearchClearConfirmed("logs-2026.08.*", "")).toBe(false);
expect(isElasticsearchClearConfirmed("logs-2026.08.*", "logs-2026.08")).toBe(false);
// A near miss must not unlock: this is the whole point of the gate.
expect(isElasticsearchClearConfirmed("logs-2026.08.*", "logs-2026.09.*")).toBe(false);
expect(isElasticsearchClearConfirmed("logs-2026.08.*", "LOGS-2026.08.*")).toBe(false);
expect(isElasticsearchClearConfirmed("logs-2026.08.*", "logs-2026.08.*")).toBe(true);
// Padding is invisible in the input, so it is forgiven.
expect(isElasticsearchClearConfirmed("logs-2026.08.*", " logs-2026.08.* ")).toBe(true);
});
it("treats a fully deleted match set as a complete clear", () => {
expect(isPartialElasticsearchClear(clearResult())).toBe(false);
expect(isPartialElasticsearchClear(clearResult({ total: 0, deleted: 0 }))).toBe(false);
});
it("flags every way _delete_by_query can leave documents behind", () => {
expect(isPartialElasticsearchClear(clearResult({ deleted: 7 }))).toBe(true);
expect(isPartialElasticsearchClear(clearResult({ versionConflicts: 2 }))).toBe(true);
expect(isPartialElasticsearchClear(clearResult({ timedOut: true }))).toBe(true);
expect(isPartialElasticsearchClear(clearResult({ failures: ['{"index":"logs"}'] }))).toBe(true);
});
});
@@ -0,0 +1,104 @@
import type { DatabaseType } from "@/types/database";
import type { ElasticsearchDeleteByQueryResult } from "@/lib/backend/tauri";
/**
* `elasticsearch-index` tree nodes are also used for Meilisearch indexes, which
* speak a different API. Only true Elasticsearch-protocol connections expose the
* mapping/settings/stats and clear-index actions.
*/
export function isElasticsearchProtocolIndex(nodeType: string, dbType: DatabaseType | undefined): boolean {
return nodeType === "elasticsearch-index" && (dbType === "elasticsearch" || dbType === "easysearch");
}
/**
* With an `index_grouping` regex configured, a sidebar index node is a collapsed
* pattern (`logs-2026.08.*`) rather than one index, and clearing it hits every
* index the pattern matches. `*` and `?` are illegal in real Elasticsearch index
* names, so their presence identifies a pattern node unambiguously — the clear
* confirmation must spell that blast radius out instead of naming "an index".
*/
export function isElasticsearchIndexPattern(index: string): boolean {
return index.includes("*") || index.includes("?");
}
/**
* Whether a concrete index name falls under a grouped node's pattern, so
* surfaces open on that index (e.g. document tabs) know a wildcard clear
* touched their data too. `*` matches any run of characters and `?` exactly
* one, mirroring the sidebar grouping semantics.
*/
export function matchesElasticsearchIndexPattern(pattern: string, index: string): boolean {
const match = (patternOffset: number, indexOffset: number): boolean => {
while (patternOffset < pattern.length) {
const character = pattern[patternOffset]!;
if (character === "*") {
for (let next = indexOffset; next <= index.length; next++) {
if (match(patternOffset + 1, next)) return true;
}
return false;
}
if (character !== "?" && character !== index[indexOffset]) return false;
if (indexOffset >= index.length) return false;
patternOffset++;
indexOffset++;
}
return indexOffset === index.length;
};
return match(0, 0);
}
/**
* Whether the operator has cleared the extra hurdle a wildcard node imposes.
*
* A concrete index name confirms with the button alone. A pattern node clears
* every index it matches, so it additionally requires the pattern to be typed
* back verbatim — the same stance Elasticsearch takes with its own
* `action.destructive_requires_name` default. Surrounding whitespace is
* forgiven because it is invisible in the input; nothing else is.
*/
export function isElasticsearchClearConfirmed(index: string, typedName: string): boolean {
if (!isElasticsearchIndexPattern(index)) return true;
return typedName.trim() === index;
}
/**
* The request the clear action sends, shown verbatim in the confirmation dialog
* so the reviewer can see it deletes documents rather than the index itself.
*/
export function elasticsearchClearIndexPreview(index: string): string {
return `POST /${index}/_delete_by_query?conflicts=proceed&refresh=true\n{ "query": { "match_all": {} } }`;
}
export const ELASTICSEARCH_INDEX_CLEARED_EVENT = "dbx-elasticsearch-index-cleared";
export interface ElasticsearchIndexClearedDetail {
connectionId: string;
index: string;
}
/**
* Tells an open document browser for this index that its rows are gone. Best
* effort: the clear itself already succeeded by the time this runs, so a
* missing DOM (non-browser host) must not turn into a reported failure.
*/
export function notifyElasticsearchIndexCleared(connectionId: string, index: string): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent<ElasticsearchIndexClearedDetail>(ELASTICSEARCH_INDEX_CLEARED_EVENT, { detail: { connectionId, index } }));
}
/** Returns an unsubscribe function, so callers cannot leak the listener by unregistering a different closure. */
export function subscribeElasticsearchIndexCleared(listener: (detail: ElasticsearchIndexClearedDetail) => void): () => void {
if (typeof window === "undefined") return () => undefined;
const handleEvent = (event: Event) => listener((event as CustomEvent<ElasticsearchIndexClearedDetail>).detail);
window.addEventListener(ELASTICSEARCH_INDEX_CLEARED_EVENT, handleEvent);
return () => window.removeEventListener(ELASTICSEARCH_INDEX_CLEARED_EVENT, handleEvent);
}
/**
* `_delete_by_query` returns HTTP 200 even when shards failed or documents were
* skipped on a version conflict, so a plain "cleared" toast can be wrong. Report
* a partial outcome whenever the run did not delete every matched document.
*/
export function isPartialElasticsearchClear(result: ElasticsearchDeleteByQueryResult): boolean {
return result.timedOut || result.failures.length > 0 || result.versionConflicts > 0 || result.deleted < result.total;
}
@@ -31,6 +31,8 @@ export interface SidebarDangerDialogRequest {
details?: string;
detailsText?: string;
loading?: boolean;
/** Keeps the confirm button held back until the request's own precondition is met (e.g. a typed target name). */
confirmDisabled?: boolean;
closeOnConfirm?: boolean;
progress?: SidebarDangerDialogProgress;
option?: SidebarDangerDialogOption;