feat(elasticsearch): add mapping, settings, stats and clear actions
This commit is contained in:
1 parent
a84cb637ba
commit
8f3134dce8
32 files changed
+1262
-3
No files matched your search
+58
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user