diff --git a/apps/desktop/src/components/common/ElasticsearchJsonResponsePanel.vue b/apps/desktop/src/components/common/ElasticsearchJsonResponsePanel.vue
index 73e24c199..4e54f73af 100644
--- a/apps/desktop/src/components/common/ElasticsearchJsonResponsePanel.vue
+++ b/apps/desktop/src/components/common/ElasticsearchJsonResponsePanel.vue
@@ -111,7 +111,7 @@ onMounted(() => {
-
{{ body }}
+
{{ body }}
diff --git a/apps/desktop/src/components/document/DocumentBrowser.vue b/apps/desktop/src/components/document/DocumentBrowser.vue
index b9176f349..ba35ab7b4 100644
--- a/apps/desktop/src/components/document/DocumentBrowser.vue
+++ b/apps/desktop/src/components/document/DocumentBrowser.vue
@@ -16,6 +16,7 @@ import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle";
+import { clampDocumentPage, documentPageRequestLimit, resetElasticsearchDocumentTotals, resolveElasticsearchDocumentTotals } from "@/lib/document/elasticsearchDocumentTotals";
import {
arrayObjectAncestorPathForDocumentField,
buildDocumentFilterCondition,
@@ -84,7 +85,9 @@ type ViewMode = "document" | "table";
const documents = ref
([]);
const copyDocuments = ref([]);
const lastGridColumns = ref([]);
-const total = ref(0);
+const total = ref(undefined);
+const totalIsExact = ref(true);
+const paginationTotal = ref(undefined);
const loading = ref(false);
const documentLoadExecutionId = ref("");
const documentLoadCancelling = ref(false);
@@ -123,15 +126,26 @@ const tableFindPaneWidth = ref(null);
const isResizingTableSearchSplit = ref(false);
let tableSearchSplitStartX = 0;
let tableSearchSplitStartWidth = 0;
+let elasticsearchCountKey: string | null = null;
+let elasticsearchExactTotal: number | undefined;
+let elasticsearchPaginationLowerBound: number | undefined;
+let elasticsearchCountExecutionId = "";
+let elasticsearchCountGeneration = 0;
const documentStoreProvider = computed(() => documentStoreProviderFor(props.databaseType));
+const pageTotal = computed(() => paginationTotal.value ?? total.value ?? 0);
+const documentRequestLimit = computed(() => {
+ if (documentStoreProvider.value.kind !== "elasticsearch" || paginationTotal.value === undefined) return pageSize.value;
+ return documentPageRequestLimit(page.value, pageSize.value, paginationTotal.value);
+});
+
const tableFindPaneStyle = computed(() => {
if (tableFindPaneWidth.value == null) return {};
return { flex: `0 0 ${tableFindPaneWidth.value}px` };
});
const documentFontStyle = computed(() => documentViewerFontStyle(settingsStore.editorSettings));
const documentStoreLabels = computed(() => ({
- documentsLabel: documentStoreProvider.value.documentsLabel({ total: total.value, t }),
+ documentsLabel: documentStoreProvider.value.documentsLabel({ total: total.value ?? 0, t }),
queryPreview: documentQueryPreview.value,
}));
@@ -436,7 +450,7 @@ const documentQueryPreview = computed(() => {
filterJson: filter,
sortJson: sortInput.value.trim(),
skip: page.value * pageSize.value,
- limit: pageSize.value,
+ limit: documentRequestLimit.value,
});
});
@@ -580,6 +594,7 @@ async function gridSave(changes: DocumentGridChanges) {
await api.documentInsertDocument(props.connectionId, props.database, props.collection, JSON.stringify(doc));
}
+ if (isEs) resetElasticsearchTotals({ preservePaginationTotal: true });
await load();
}
@@ -684,6 +699,100 @@ function startDocumentLoadingTimer() {
}, 100);
}
+function elasticsearchCountFilterKey(filter: string | undefined): string {
+ return JSON.stringify([props.connectionId, props.database, props.collection, filter ?? ""]);
+}
+
+function cancelElasticsearchCount() {
+ elasticsearchCountGeneration++;
+ const executionId = elasticsearchCountExecutionId;
+ elasticsearchCountExecutionId = "";
+ if (executionId) void api.cancelQuery(executionId);
+}
+
+function resetElasticsearchTotals(options: { preservePaginationTotal?: boolean } = {}) {
+ const nextTotals = resetElasticsearchDocumentTotals(paginationTotal.value, options.preservePaginationTotal);
+ cancelElasticsearchCount();
+ elasticsearchCountKey = null;
+ elasticsearchExactTotal = undefined;
+ elasticsearchPaginationLowerBound = undefined;
+ paginationTotal.value = nextTotals.paginationTotal;
+ total.value = nextTotals.total;
+ totalIsExact.value = nextTotals.totalIsExact;
+}
+
+function clampPageToPaginationTotal(): boolean {
+ const cap = paginationTotal.value;
+ if (cap === undefined) return false;
+ const nextPage = clampDocumentPage(page.value, pageSize.value, cap);
+ if (page.value === nextPage) return false;
+ page.value = nextPage;
+ return true;
+}
+
+function startElasticsearchExactCount(filter: string | undefined) {
+ if (elasticsearchCountExecutionId || elasticsearchExactTotal !== undefined || !elasticsearchCountKey) return;
+ const key = elasticsearchCountKey;
+ const executionId = uuid();
+ const generation = elasticsearchCountGeneration;
+ elasticsearchCountExecutionId = executionId;
+
+ void api
+ .elasticsearchCountDocuments(props.connectionId, props.collection, filter, executionId)
+ .then((exactCount) => {
+ if (generation !== elasticsearchCountGeneration || key !== elasticsearchCountKey || executionId !== elasticsearchCountExecutionId || !Number.isFinite(exactCount) || exactCount < 0) {
+ return;
+ }
+ elasticsearchExactTotal = exactCount;
+ const totals = resolveElasticsearchDocumentTotals(elasticsearchPaginationLowerBound ?? exactCount, false, exactCount);
+ total.value = totals.total;
+ totalIsExact.value = totals.totalIsExact;
+ paginationTotal.value = totals.paginationTotal;
+ if (clampPageToPaginationTotal()) void load();
+ })
+ .catch(() => {
+ // The lower-bound result remains truthful when a background count fails.
+ })
+ .finally(() => {
+ if (generation === elasticsearchCountGeneration && executionId === elasticsearchCountExecutionId) {
+ elasticsearchCountExecutionId = "";
+ }
+ });
+}
+
+function applyElasticsearchSearchTotal(searchTotal: number, isExact: boolean, filter: string | undefined) {
+ const key = elasticsearchCountFilterKey(filter);
+ if (key !== elasticsearchCountKey) {
+ cancelElasticsearchCount();
+ elasticsearchCountKey = key;
+ elasticsearchExactTotal = undefined;
+ elasticsearchPaginationLowerBound = undefined;
+ }
+
+ elasticsearchPaginationLowerBound = searchTotal;
+ const totals = resolveElasticsearchDocumentTotals(searchTotal, isExact, elasticsearchExactTotal);
+ if (isExact) {
+ cancelElasticsearchCount();
+ elasticsearchExactTotal = searchTotal;
+ total.value = totals.total;
+ totalIsExact.value = totals.totalIsExact;
+ paginationTotal.value = totals.paginationTotal;
+ return;
+ }
+
+ if (elasticsearchExactTotal !== undefined) {
+ total.value = totals.total;
+ totalIsExact.value = totals.totalIsExact;
+ paginationTotal.value = totals.paginationTotal;
+ return;
+ }
+
+ total.value = totals.total;
+ totalIsExact.value = totals.totalIsExact;
+ paginationTotal.value = totals.paginationTotal;
+ startElasticsearchExactCount(filter);
+}
+
async function load() {
if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value);
const executionId = uuid();
@@ -696,8 +805,12 @@ async function load() {
const previousSelectedId = previousSelectedIdx === null ? null : documentIdentity(documents.value[previousSelectedIdx]);
try {
const filter = currentDocumentFilter();
+ if (documentStoreProvider.value.kind === "elasticsearch" && elasticsearchCountKey !== null && elasticsearchCountKey !== elasticsearchCountFilterKey(filter)) {
+ resetElasticsearchTotals();
+ }
const sort = currentDocumentSortJson(sortInput.value);
- const result = await api.documentFindDocuments(props.connectionId, props.database, props.collection, page.value * pageSize.value, pageSize.value, filter, undefined, sort, executionId);
+ const skip = page.value * pageSize.value;
+ const result = await api.documentFindDocuments(props.connectionId, props.database, props.collection, skip, documentRequestLimit.value, filter, undefined, sort, executionId);
if (documentLoadExecutionId.value !== executionId) return;
const nextDocuments =
documentStoreProvider.value.kind === "elasticsearch" && result.raw_documents?.length === result.documents.length
@@ -722,7 +835,14 @@ async function load() {
}
lastGridColumns.value = [...keySet];
}
- total.value = result.total;
+ if (documentStoreProvider.value.kind === "elasticsearch") {
+ applyElasticsearchSearchTotal(result.total, result.total_is_exact !== false, filter);
+ } else {
+ cancelElasticsearchCount();
+ total.value = result.total;
+ totalIsExact.value = true;
+ paginationTotal.value = result.total;
+ }
syncSelectedDocumentAfterLoad(previousSelectedIdx, previousSelectedId);
} catch (e: unknown) {
if (documentLoadExecutionId.value === executionId) error.value = e instanceof Error ? e.message : String(e);
@@ -736,6 +856,11 @@ async function load() {
}
}
+async function refreshDocuments() {
+ if (documentStoreProvider.value.kind === "elasticsearch") resetElasticsearchTotals({ preservePaginationTotal: true });
+ await load();
+}
+
async function cancelDocumentLoad() {
const executionId = documentLoadExecutionId.value;
if (!executionId || documentLoadCancelling.value) return;
@@ -754,20 +879,22 @@ async function cancelDocumentLoad() {
function applyFilter() {
page.value = 0;
- load();
+ if (documentStoreProvider.value.kind === "elasticsearch") resetElasticsearchTotals();
+ void load();
}
function paginate(offset: number, limit: number) {
const normalizedLimit = normalizeResultPageSize(limit, pageSize.value);
pageSize.value = normalizedLimit;
- page.value = Math.floor(Math.max(0, offset) / normalizedLimit);
- load();
+ const requestedPage = Math.floor(Math.max(0, offset) / normalizedLimit);
+ page.value = clampDocumentPage(requestedPage, normalizedLimit, paginationTotal.value);
+ void load();
}
function onSort(column: string, _columnIndex: number, direction: "asc" | "desc" | null) {
sortInput.value = documentStoreProvider.value.sortInputForColumn(column, direction);
page.value = 0;
- load();
+ void load();
}
function asRecord(value: unknown): JsonRecord {
@@ -1129,6 +1256,7 @@ async function saveDoc() {
isNew.value = false;
documentEditMode.value = "fields";
editFields.value = [];
+ if (kind === "elasticsearch") resetElasticsearchTotals({ preservePaginationTotal: true });
await load();
if (selectedIdx.value !== null && documents.value[selectedIdx.value]) {
editJson.value = stringifyDocumentStoreValue(documents.value[selectedIdx.value], documentStoreProvider.value.kind, 2);
@@ -1151,6 +1279,7 @@ async function applyDeleteDoc(idx: number) {
selectedIdx.value = null;
editJson.value = "";
}
+ if (documentStoreProvider.value.kind === "elasticsearch") resetElasticsearchTotals({ preservePaginationTotal: true });
await load();
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : String(e);
@@ -1176,13 +1305,13 @@ async function confirmDelete() {
function prevPage() {
if (page.value <= 0) return;
page.value--;
- load();
+ void load();
}
function nextPage() {
- if ((page.value + 1) * pageSize.value >= total.value) return;
+ if ((page.value + 1) * pageSize.value >= pageTotal.value) return;
page.value++;
- load();
+ void load();
}
function docPreview(doc: JsonRecord): string {
@@ -1274,6 +1403,7 @@ onMounted(async () => {
onBeforeUnmount(() => {
window.removeEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value);
+ cancelElasticsearchCount();
stopDocumentLoadingTimer();
endTableSearchSplitResize();
});
@@ -1339,14 +1469,14 @@ defineExpose({ focusSearch });
{{ documentStoreLabels.documentsLabel }}
-
+
- {{ page + 1 }} / {{ Math.max(1, Math.ceil(total / pageSize)) }}
-
@@ -1446,8 +1576,10 @@ defineExpose({ focusSearch });
:page-offset="page * pageSize"
:page-limit="pageSize"
:total-row-count="total"
+ :total-row-count-is-exact="totalIsExact"
+ :pagination-total-row-count="pageTotal"
@sort="onSort"
- @reload="load"
+ @reload="refreshDocuments"
@paginate="(offset: number, limit: number) => paginate(offset, limit)"
>
void }">
diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue
index 80459ed1a..8bb21a851 100644
--- a/apps/desktop/src/components/grid/DataGrid.vue
+++ b/apps/desktop/src/components/grid/DataGrid.vue
@@ -287,6 +287,8 @@ interface DataGridProps {
pageLimit?: number;
countSql?: string;
totalRowCount?: number;
+ totalRowCountIsExact?: boolean;
+ paginationTotalRowCount?: number;
totalRowCountLoading?: boolean;
loading?: boolean;
cacheKey?: string;
@@ -2363,9 +2365,13 @@ const inferredBackendTotalRowCount = computed(() => {
});
const serverKnownTotalRowCount = computed(() => props.totalRowCount ?? manualTotalRowCount.value);
const displayedTotalRowCount = computed(() => serverKnownTotalRowCount.value ?? inferredBackendTotalRowCount.value);
+const totalRowCountIsExact = computed(() => props.totalRowCountIsExact !== false);
+// A backend can expose an exact display total while deliberately restricting
+// offset pagination to a smaller safe range.
+const paginationTotalRowCount = computed(() => props.paginationTotalRowCount ?? serverKnownTotalRowCount.value);
// Only a server-confirmed total drives pagination — an inferred total means
// rows exist that we never fetched, so navigation must stay inside rows.length.
-const hasKnownTotalRowCount = computed(() => typeof serverKnownTotalRowCount.value === "number" && serverKnownTotalRowCount.value >= 0);
+const hasKnownPaginationTotalRowCount = computed(() => typeof paginationTotalRowCount.value === "number" && paginationTotalRowCount.value >= 0);
// When context=results and the caller hasn't configured server-side
// pagination (no pageLimit), the backend handed us every row up-front and
// rowCount IS the total. Without this hint, the "page is full → assume more"
@@ -2391,11 +2397,11 @@ const canGoNextPage = computed(() => {
pageSize: pageSize.value,
pageOffset: props.pageOffset,
currentPage: currentPage.value,
- totalRowCount: hasKnownTotalRowCount.value ? displayedTotalRowCount.value : undefined,
+ totalRowCount: hasKnownPaginationTotalRowCount.value ? paginationTotalRowCount.value : undefined,
allRowsLoaded: allRowsLoaded.value,
});
});
-const canJumpLastPage = computed(() => canGoNextPage.value && (hasKnownTotalRowCount.value || allRowsLoaded.value || !!props.tableMeta || !!props.countSql));
+const canJumpLastPage = computed(() => canGoNextPage.value && (hasKnownPaginationTotalRowCount.value || allRowsLoaded.value || !!props.tableMeta || !!props.countSql));
const totalRowCountBusy = computed(() => props.totalRowCountLoading === true || manualTotalRowCountLoading.value);
const canCalculateTotalRowCount = computed(() => !!props.connectionId && (!!props.tableMeta || !!props.countSql));
// When a refresh/rollback completes and the current page exceeds the last
@@ -2408,7 +2414,7 @@ watch(
// and the completion was triggered by a refresh/rollback.
if (!loading && prevLoading && isRefreshingData.value) {
isRefreshingData.value = false;
- const total = displayedTotalRowCount.value;
+ const total = paginationTotalRowCount.value;
if (!total || total <= 0) return;
const lastPageNum = Math.max(1, Math.ceil(total / pageSize.value));
if (currentPage.value <= lastPageNum) return;
@@ -2571,8 +2577,8 @@ function applyCustomPageSize() {
async function lastPage() {
if (infiniteScrollEnabled.value) return;
- if (hasKnownTotalRowCount.value) {
- const total = displayedTotalRowCount.value ?? 0;
+ if (hasKnownPaginationTotalRowCount.value) {
+ const total = paginationTotalRowCount.value ?? 0;
if (total <= 0) return;
const lastPageNum = Math.ceil(total / pageSize.value);
if (lastPageNum <= currentPage.value) return;
@@ -8916,7 +8922,7 @@ const gridContextMenuItems = computed(() => {
{{ t(showTruncationWarning ? "grid.loadedRows" : "grid.totalRows", { count: result.rows.length }) }}
- {{ t("grid.totalRowCount", { count: displayedTotalRowCount }) }}
+ {{ t(totalRowCountIsExact === false ? "grid.totalRowCountAtLeast" : "grid.totalRowCount", { count: displayedTotalRowCount }) }}
{{ t("grid.totalRowCountLoading") }}
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index e16e8cb80..72722ddf7 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -873,6 +873,7 @@ export default {
totalRows: "Total {count} rows",
loadedRows: "Loaded {count} rows",
totalRowCount: "({count} total)",
+ totalRowCountAtLeast: "(at least {count} total)",
totalRowCountLoading: "(counting...)",
loadingMore: "Loading more data...",
allLoaded: "all loaded",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index 3c706437a..89bdf32f5 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -822,6 +822,7 @@ export default withEnglishFallback({
totalRows: "Total {count} filas",
loadedRows: "{count} filas cargadas",
totalRowCount: "({count} en total)",
+ totalRowCountAtLeast: "(al menos {count} en total)",
totalRowCountLoading: "(contando...)",
loadingMore: "Cargando más datos...",
allLoaded: "todo cargado",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index 084450786..764710f50 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -820,6 +820,7 @@ export default withEnglishFallback({
totalRows: "Totale {count} righe",
loadedRows: "{count} righe caricate",
totalRowCount: "({count} in totale)",
+ totalRowCountAtLeast: "(almeno {count} in totale)",
totalRowCountLoading: "(conteggio...)",
loadingMore: "Caricamento altri dati...",
allLoaded: "tutto caricato",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index 65e6e4d62..7363d8b9c 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -821,6 +821,7 @@ export default withEnglishFallback({
totalRows: "{count}件表示",
loadedRows: "{count}件読み込み済み",
totalRowCount: "(全{count}件)",
+ totalRowCountAtLeast: "(少なくとも{count}件)",
totalRowCountLoading: "(カウント中...)",
calculateTotalRows: "総行数をカウント",
calculateTotalRowsInline: "(総行数をカウント)",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index a5ca1309d..1d9fd4fb6 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -822,6 +822,7 @@ export default withEnglishFallback({
totalRows: "Total de {count} linhas",
loadedRows: "{count} linhas carregadas",
totalRowCount: "({count} no total)",
+ totalRowCountAtLeast: "(pelo menos {count} no total)",
totalRowCountLoading: "(contando...)",
loadingMore: "Carregando mais dados...",
allLoaded: "tudo carregado",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index fbdcefe05..871929f3b 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -875,6 +875,7 @@ export default withEnglishFallback({
totalRows: "共 {count} 行",
loadedRows: "已加载 {count} 行",
totalRowCount: "(总计 {count} 行)",
+ totalRowCountAtLeast: "(至少 {count} 行)",
totalRowCountLoading: "(统计中...)",
loadingMore: "加载更多数据...",
allLoaded: "已全部加载",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index 5daa5b50c..8952eb47f 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -822,6 +822,7 @@ export default withEnglishFallback({
totalRows: "共 {count} 筆",
loadedRows: "已載入 {count} 筆",
totalRowCount: "(總計 {count} 筆)",
+ totalRowCountAtLeast: "(至少 {count} 筆)",
totalRowCountLoading: "(統計中...)",
loadingMore: "載入更多資料...",
allLoaded: "已全部載入",
diff --git a/apps/desktop/src/lib/__tests__/document/elasticsearchDocumentTotals.spec.ts b/apps/desktop/src/lib/__tests__/document/elasticsearchDocumentTotals.spec.ts
new file mode 100644
index 000000000..10b0634bf
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/document/elasticsearchDocumentTotals.spec.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import { clampDocumentPage, documentPageRequestLimit, resetElasticsearchDocumentTotals, resolveElasticsearchDocumentTotals } from "@/lib/document/elasticsearchDocumentTotals";
+
+describe("Elasticsearch document totals", () => {
+ it("keeps a lower-bound search total separate from an exact background count", () => {
+ expect(resolveElasticsearchDocumentTotals(10_000, false)).toEqual({
+ total: 10_000,
+ totalIsExact: false,
+ paginationTotal: 10_000,
+ });
+ expect(resolveElasticsearchDocumentTotals(10_000, false, 552_033)).toEqual({
+ total: 552_033,
+ totalIsExact: true,
+ paginationTotal: 10_000,
+ });
+ });
+
+ it("uses exact search totals for both display and pagination", () => {
+ expect(resolveElasticsearchDocumentTotals(42, true, 100)).toEqual({
+ total: 42,
+ totalIsExact: true,
+ paginationTotal: 42,
+ });
+ });
+
+ it("clears a stale display total without losing the safe page cap during a refresh", () => {
+ expect(resetElasticsearchDocumentTotals(10_000, true)).toEqual({
+ total: undefined,
+ totalIsExact: true,
+ paginationTotal: 10_000,
+ });
+ expect(resetElasticsearchDocumentTotals(10_000)).toEqual({
+ total: undefined,
+ totalIsExact: true,
+ paginationTotal: undefined,
+ });
+ });
+
+ it("clamps the final request without exceeding the conservative page cap", () => {
+ expect(clampDocumentPage(30, 333, 10_000)).toBe(30);
+ expect(clampDocumentPage(31, 333, 10_000)).toBe(30);
+ expect(documentPageRequestLimit(30, 333, 10_000)).toBe(10);
+ expect(documentPageRequestLimit(0, 100, undefined)).toBe(100);
+ });
+});
diff --git a/apps/desktop/src/lib/__tests__/elasticsearchJsonResponse.spec.ts b/apps/desktop/src/lib/__tests__/elasticsearchJsonResponse.spec.ts
new file mode 100644
index 000000000..a2725506e
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/elasticsearchJsonResponse.spec.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+import { elasticsearchJsonResponseForResult } from "@/lib/elasticsearch/elasticsearchJsonResponse";
+import type { QueryResult } from "@/types/database";
+
+const catJsonResult: QueryResult = {
+ columns: ["status", "response"],
+ rows: [[200, '[{"index":"data_pack_and_box_index_v1","docs.count":"42"}]']],
+ affected_rows: 0,
+ execution_time_ms: 1,
+};
+
+describe("Elasticsearch JSON response detection", () => {
+ it("routes an unformatted CAT response to the JSON renderer", () => {
+ expect(elasticsearchJsonResponseForResult("elasticsearch", "GET /_cat/indices/data_pack_and_box_index_v1", catJsonResult)).toEqual({
+ status: 200,
+ body: '[{"index":"data_pack_and_box_index_v1","docs.count":"42"}]',
+ });
+ });
+});
diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts
index 54efda3c0..93203f016 100644
--- a/apps/desktop/src/lib/backend/api.ts
+++ b/apps/desktop/src/lib/backend/api.ts
@@ -491,6 +491,7 @@ export const mongoDropDatabase = forward("mongoDropDatabase");
export const mongoDropCollection = forward("mongoDropCollection");
export const mongoRenameCollection = forward("mongoRenameCollection");
export const documentFindDocuments = forward("documentFindDocuments");
+export const elasticsearchCountDocuments = forward("elasticsearchCountDocuments");
export const mongoFindDocuments = forward("mongoFindDocuments");
export const mongoParseShellCommand = forward("mongoParseShellCommand");
export const mongoFindOne = forward("mongoFindOne");
@@ -611,6 +612,7 @@ export type {
KvPutOptions,
KvPutResponse,
KvDeleteResponse,
+ DocumentQueryResult,
MongoDocumentResult,
HistoryEntry,
HistoryConnectionFilter,
diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts
index 173a2af63..a69ec5744 100644
--- a/apps/desktop/src/lib/backend/http.ts
+++ b/apps/desktop/src/lib/backend/http.ts
@@ -75,6 +75,7 @@ import type {
KvPutOptions,
KvPutResponse,
KvDeleteResponse,
+ DocumentQueryResult,
MongoDocumentResult,
MongoCollectionStatsResult,
MongoGridFsBucketInfo,
@@ -2178,10 +2179,14 @@ export async function mongoFindOne(connectionId: string, database: string, colle
return post("/api/mongo/find-one", { connectionId, database, collection, filter, projection, options, executionId });
}
-export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, executionId?: string): Promise {
+export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, executionId?: string): Promise {
return post("/api/document-store/find-documents", { connectionId, database, collection, skip, limit, filter, projection, sort, executionId });
}
+export async function elasticsearchCountDocuments(connectionId: string, index: string, filter?: string, executionId?: string): Promise {
+ return post("/api/document-store/elasticsearch-count-documents", { connectionId, index, filter, executionId });
+}
+
export async function mongoCountDocuments(connectionId: string, database: string, collection: string, filter?: string, mode?: "accurate" | "legacy", executionId?: string): Promise {
return post("/api/mongo/count-documents", { connectionId, database, collection, filter, mode, executionId });
}
diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts
index 0bc4fdc89..b096ec934 100644
--- a/apps/desktop/src/lib/backend/tauri.ts
+++ b/apps/desktop/src/lib/backend/tauri.ts
@@ -1849,14 +1849,18 @@ export async function zookeeperDelete(connectionId: string, key: string): Promis
return invoke("zookeeper_delete", { connectionId, key });
}
-// --- MongoDB ---
-export interface MongoDocumentResult {
+// --- Document stores ---
+export interface DocumentQueryResult {
documents: any[];
raw_documents?: string[];
extended_documents?: any[];
total: number;
+ total_is_exact?: boolean;
}
+// Kept for callers that are specifically using MongoDB APIs.
+export type MongoDocumentResult = DocumentQueryResult;
+
export interface MongoCollectionStatsResult {
count: unknown;
size: unknown;
@@ -1942,10 +1946,14 @@ export async function mongoParseShellCommand(source: string): Promise {
+export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, executionId?: string): Promise {
return invoke("document_find_documents", { connectionId, database, collection, skip, limit, filter, projection, sort, executionId });
}
+export async function elasticsearchCountDocuments(connectionId: string, index: string, filter?: string, executionId?: string): Promise {
+ return invoke("elasticsearch_count_documents", { connectionId, index, filter, executionId });
+}
+
export async function mongoCountDocuments(connectionId: string, database: string, collection: string, filter?: string, mode?: "accurate" | "legacy", executionId?: string): Promise {
return invoke("mongo_count_documents", { connectionId, database, collection, filter, mode, executionId });
}
diff --git a/apps/desktop/src/lib/document/elasticsearchDocumentTotals.ts b/apps/desktop/src/lib/document/elasticsearchDocumentTotals.ts
new file mode 100644
index 000000000..91f63f09e
--- /dev/null
+++ b/apps/desktop/src/lib/document/elasticsearchDocumentTotals.ts
@@ -0,0 +1,49 @@
+export interface ElasticsearchDocumentTotals {
+ total: number;
+ totalIsExact: boolean;
+ paginationTotal: number;
+}
+
+export interface ResetElasticsearchDocumentTotals {
+ total: undefined;
+ totalIsExact: boolean;
+ paginationTotal?: number;
+}
+
+export function resolveElasticsearchDocumentTotals(searchTotal: number, searchTotalIsExact: boolean, exactCount?: number): ElasticsearchDocumentTotals {
+ if (searchTotalIsExact || exactCount === undefined) {
+ return {
+ total: searchTotal,
+ totalIsExact: searchTotalIsExact,
+ paginationTotal: searchTotal,
+ };
+ }
+ return {
+ total: exactCount,
+ totalIsExact: true,
+ paginationTotal: Math.min(searchTotal, exactCount),
+ };
+}
+
+export function resetElasticsearchDocumentTotals(paginationTotal: number | undefined, preservePaginationTotal = false): ResetElasticsearchDocumentTotals {
+ return {
+ total: undefined,
+ totalIsExact: true,
+ paginationTotal: preservePaginationTotal ? paginationTotal : undefined,
+ };
+}
+
+export function clampDocumentPage(page: number, pageSize: number, paginationTotal?: number): number {
+ const normalizedPage = Math.max(0, Math.floor(page));
+ if (paginationTotal === undefined) return normalizedPage;
+ if (paginationTotal <= 0) return 0;
+ const lastPage = Math.max(0, Math.ceil(paginationTotal / Math.max(1, pageSize)) - 1);
+ return Math.min(normalizedPage, lastPage);
+}
+
+export function documentPageRequestLimit(page: number, pageSize: number, paginationTotal?: number): number {
+ const normalizedPageSize = Math.max(1, Math.floor(pageSize));
+ if (paginationTotal === undefined) return normalizedPageSize;
+ const remaining = paginationTotal - Math.max(0, Math.floor(page)) * normalizedPageSize;
+ return remaining > 0 ? Math.min(normalizedPageSize, remaining) : normalizedPageSize;
+}
diff --git a/apps/desktop/src/lib/elasticsearch/elasticsearchJsonResponse.ts b/apps/desktop/src/lib/elasticsearch/elasticsearchJsonResponse.ts
index 42ce8d926..cd2f137fd 100644
--- a/apps/desktop/src/lib/elasticsearch/elasticsearchJsonResponse.ts
+++ b/apps/desktop/src/lib/elasticsearch/elasticsearchJsonResponse.ts
@@ -9,9 +9,8 @@ export interface ElasticsearchJsonResponse {
const ELASTICSEARCH_REST_STATEMENT = /^(?:GET|POST|PUT|DELETE|HEAD)\s+\S+/i;
/**
- * Detect the result shape emitted for a JSON response to an explicit
- * Elasticsearch REST request. SQL and text (such as CAT) results keep using
- * the normal data-grid path.
+ * Detect the raw HTTP result emitted for an Elasticsearch REST request.
+ * DBX asks unformatted CAT requests for JSON so they use this response panel.
*/
export function elasticsearchJsonResponseForResult(databaseType: DatabaseType | undefined, sourceStatement: string | undefined, result: QueryResult | undefined): ElasticsearchJsonResponse | undefined {
if (databaseType !== "elasticsearch" || !result || typeof sourceStatement !== "string") return undefined;
diff --git a/crates/dbx-core/src/db/document_result.rs b/crates/dbx-core/src/db/document_result.rs
new file mode 100644
index 000000000..b9f1bb5d9
--- /dev/null
+++ b/crates/dbx-core/src/db/document_result.rs
@@ -0,0 +1,50 @@
+use serde::{Deserialize, Serialize};
+
+/// Common result shape returned by document-store queries.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DocumentQueryResult {
+ pub documents: Vec,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub raw_documents: Option>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub extended_documents: Option>,
+ pub total: u64,
+ // Older document stores always return an exact total. Keep that wire shape
+ // unchanged and only send the flag when Elasticsearch reports a lower bound.
+ #[serde(default = "default_total_is_exact", skip_serializing_if = "is_true")]
+ pub total_is_exact: bool,
+}
+
+fn default_total_is_exact() -> bool {
+ true
+}
+
+fn is_true(value: &bool) -> bool {
+ *value
+}
+
+#[cfg(test)]
+mod tests {
+ use super::DocumentQueryResult;
+
+ #[test]
+ fn exact_results_keep_the_existing_wire_shape() {
+ let result = DocumentQueryResult {
+ documents: Vec::new(),
+ raw_documents: None,
+ extended_documents: None,
+ total: 1,
+ total_is_exact: true,
+ };
+
+ let serialized = serde_json::to_value(result).unwrap();
+ assert!(serialized.get("total_is_exact").is_none());
+
+ let deserialized: DocumentQueryResult = serde_json::from_value(serde_json::json!({
+ "documents": [],
+ "total": 1,
+ }))
+ .unwrap();
+ assert!(deserialized.total_is_exact);
+ }
+}
diff --git a/crates/dbx-core/src/db/elasticsearch_driver.rs b/crates/dbx-core/src/db/elasticsearch_driver.rs
index 3a53d8fb1..99bc71f71 100644
--- a/crates/dbx-core/src/db/elasticsearch_driver.rs
+++ b/crates/dbx-core/src/db/elasticsearch_driver.rs
@@ -1,4 +1,4 @@
-use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
+use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, CONTROLS};
use reqwest::{Client as HttpClient, Method, StatusCode};
use serde::Deserialize;
use serde_json::Value;
@@ -7,7 +7,7 @@ use std::error::Error;
use std::time::Duration;
use super::{http_client_builder, with_connection_timeout};
-use crate::db::mongo_driver::MongoDocumentResult;
+use crate::db::document_result::DocumentQueryResult;
const ELASTICSEARCH_PATH_SEGMENT_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
@@ -421,6 +421,12 @@ fn push_mapping_column(
#[derive(Deserialize)]
struct SearchResponse {
hits: SearchHits,
+ #[serde(rename = "_shards")]
+ shards: Option,
+ #[serde(default)]
+ timed_out: bool,
+ #[serde(default)]
+ terminated_early: bool,
}
#[derive(Deserialize)]
@@ -431,13 +437,20 @@ struct SearchHits {
enum HitsTotal {
Count(u64),
- Value { value: u64 },
+ Value { value: u64, is_exact: bool },
}
impl HitsTotal {
fn value(&self) -> u64 {
match self {
- Self::Count(value) | Self::Value { value } => *value,
+ Self::Count(value) | Self::Value { value, .. } => *value,
+ }
+ }
+
+ fn is_exact(&self) -> bool {
+ match self {
+ Self::Count(_) => true,
+ Self::Value { is_exact, .. } => *is_exact,
}
}
}
@@ -452,7 +465,10 @@ impl<'de> Deserialize<'de> for HitsTotal {
return Ok(Self::Count(count));
}
if let Some(count) = value.get("value").and_then(serde_json::Value::as_u64) {
- return Ok(Self::Value { value: count });
+ // Object totals are exact only when Elasticsearch explicitly says
+ // so. Treat an absent or unknown relation conservatively.
+ let is_exact = value.get("relation").and_then(serde_json::Value::as_str) == Some("eq");
+ return Ok(Self::Value { value: count, is_exact });
}
Err(serde::de::Error::custom("expected hits.total as a number or an object with value"))
}
@@ -475,7 +491,7 @@ pub async fn find_documents(
limit: i64,
filter: Option<&str>,
sort: Option<&str>,
-) -> Result {
+) -> Result {
let body = build_find_documents_body(skip, limit, filter, sort)?;
let path = elasticsearch_index_path(index, "_search");
@@ -491,7 +507,58 @@ pub async fn find_documents(
search_response_to_document_result(result)
}
-fn search_response_to_document_result(result: SearchResponse) -> Result {
+#[derive(Deserialize)]
+struct CountResponse {
+ count: u64,
+ #[serde(rename = "_shards")]
+ shards: ElasticsearchShards,
+}
+
+#[derive(Deserialize)]
+struct ElasticsearchShards {
+ total: u64,
+ successful: u64,
+ #[serde(default)]
+ skipped: u64,
+ failed: u64,
+}
+
+impl ElasticsearchShards {
+ fn is_complete(&self) -> bool {
+ // Elasticsearch only skips shards that cannot match, so successful and
+ // skipped shards together must account for every requested shard.
+ self.failed == 0 && self.successful.checked_add(self.skipped) == Some(self.total)
+ }
+}
+
+pub async fn count_documents(client: &EsClient, index: &str, filter: Option<&str>) -> Result {
+ let body = build_count_documents_body(filter)?;
+ let path = elasticsearch_index_path(index, "_count");
+ let resp = client.post(&path).json(&body).send().await.map_err(|e| format!("Elasticsearch request failed: {e}"))?;
+
+ if !client.response_status(&resp).is_success() {
+ let body = resp.text().await.unwrap_or_default();
+ return Err(format!("Elasticsearch error: {body}"));
+ }
+
+ let result: CountResponse = resp.json().await.map_err(|e| format!("Elasticsearch count parse error: {e}"))?;
+ if !result.shards.is_complete() {
+ return Err(format!(
+ "Elasticsearch count returned an incomplete shard response: {} successful, {} skipped, {} failed of {} shards",
+ result.shards.successful, result.shards.skipped, result.shards.failed, result.shards.total,
+ ));
+ }
+ Ok(result.count)
+}
+
+fn search_response_to_document_result(result: SearchResponse) -> Result {
+ // A 200 search response can still omit failed shards. Only expose an
+ // exact total when both the total relation and shard metadata agree.
+ let total_is_exact = !result.timed_out
+ && !result.terminated_early
+ && result.hits.total.is_exact()
+ && result.shards.as_ref().is_some_and(ElasticsearchShards::is_complete);
+ let total = result.hits.total.value();
let documents: Vec = result
.hits
.hits
@@ -518,11 +585,12 @@ fn search_response_to_document_result(result: SearchResponse) -> Result, _>>()
.map_err(|e| format!("Elasticsearch document serialization failed: {e}"))?;
- Ok(MongoDocumentResult {
+ Ok(DocumentQueryResult {
documents,
raw_documents: Some(raw_documents),
extended_documents: None,
- total: result.hits.total.value(),
+ total,
+ total_is_exact,
})
}
@@ -544,6 +612,14 @@ fn build_find_documents_body(
Ok(serde_json::Value::Object(body))
}
+fn build_count_documents_body(filter: Option<&str>) -> Result {
+ let mut body = serde_json::Map::new();
+ if let Some(query) = elasticsearch_query_from_document_filter(filter)? {
+ body.insert("query".to_string(), query);
+ }
+ Ok(serde_json::Value::Object(body))
+}
+
fn elasticsearch_query_from_document_filter(filter: Option<&str>) -> Result