feat(grid): load all remaining rows with confirmation guard

This commit is contained in:
zipg authored and GitHub committed 2026-09-24 12:53:00 +08:00
1 parent 0b3e79331b
commit 35d624764f
25 files changed
+336 -27

No files matched your search

@@ -2957,6 +2957,7 @@ defineExpose({ focusSearch });
:total-row-count-is-exact="totalIsExact"
:inexact-total-row-count-mode="documentStoreProvider.kind === 'mongodb' ? 'estimated' : 'at-least'"
:pagination-total-row-count="pageTotal"
:load-all-rows-enabled="false"
:count-total-rows="countExactDocumentTotal"
:full-export-result="documentStoreProvider.kind === 'mongodb' || documentStoreProvider.kind === 'dynamodb' || documentStoreProvider.kind === 'elasticsearch' ? exportAllDocumentStoreDocuments : undefined"
@sort="onSort"
+139 -10
View File
@@ -208,6 +208,7 @@ import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid
import {
canFetchNextDataGridSegment,
canGoNextDataGridPage,
dataGridLoadAllSegment,
dataGridTotalRowCountLabelKey,
dataGridTruncationHintKey,
ELASTICSEARCH_PAGE_JUMP_WARNING_REQUESTS,
@@ -493,6 +494,7 @@ interface DataGridProps {
inexactTotalRowCountMode?: DataGridInexactTotalRowCountMode;
paginationTotalRowCount?: number;
paginationEnabled?: boolean;
loadAllRowsEnabled?: boolean;
totalRowCountLoading?: boolean;
pageJumpProgress?: QueryPageJumpProgress;
/** Document stores (e.g. MongoDB) count exactly on demand without SQL tableMeta/countSql. */
@@ -556,6 +558,7 @@ const props = withDefaults(defineProps<DataGridProps>(), {
totalRowCountIsExact: true,
inexactTotalRowCountMode: "at-least",
paginationEnabled: true,
loadAllRowsEnabled: true,
// Omitted row-action limits must keep normal table-data editing.
allowInsertRows: undefined,
allowDeleteRows: undefined,
@@ -588,7 +591,7 @@ function logDataGridTiming(message: string, payload?: Record<string, unknown>) {
const emit = defineEmits<{
reload: [sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent];
paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string];
paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string, appendResult?: boolean];
sort: [column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode?: DataGridSortMode];
"update:whereInput": [value: string];
"update:orderByInput": [value: string];
@@ -3009,6 +3012,8 @@ let infiniteScrollCheckScheduled = false;
let infiniteScrollAllLoaded = false;
let infiniteScrollRequestedOffset: number | undefined;
let infiniteScrollRequestedLimit: number | undefined;
let infiniteScrollLoadAllPending = false;
const loadAllRowsActive = ref(false);
// Tracks whether the current loading cycle was triggered by a refresh/rollback
// (as opposed to a normal paginate). Used to decide whether to auto-redirect
// when the current page no longer exists after data was deleted.
@@ -3046,11 +3051,14 @@ watch(
},
{ immediate: true },
);
// Clear infinite-scroll loading when the parent finishes loading new data
// Complete an append only after both the loading state and appended result have
// propagated. Large results can update those props in separate render cycles.
watch(
() => props.loading,
(loading, prevLoading) => {
if (prevLoading && !loading && infiniteScrollLoading.value) {
() => [props.loading, props.result.rows.length, props.result.appended_from_row_count] as const,
([loading]) => {
if (!loading && infiniteScrollLoading.value) {
const shouldSelectLastRow = infiniteScrollLoadAllPending;
infiniteScrollLoadAllPending = false;
infiniteScrollLoading.value = false;
isInfiniteScrollPaginating.value = false;
const requestedOffset = infiniteScrollRequestedOffset;
@@ -3062,19 +3070,31 @@ watch(
// optimistic page marker so a later scroll can retry the same segment.
currentPage.value = Math.max(1, currentPage.value - 1);
lastInfiniteScrollPage = Math.max(0, currentPage.value - 1);
loadAllRowsActive.value = false;
return;
}
const appendedRows = props.result.rows.length - requestedOffset;
if (props.result.rows.length >= infiniteScrollMaxRows.value || appendedRows < (requestedLimit ?? pageSize.value)) {
infiniteScrollAllLoaded = true;
}
if (shouldSelectLastRow) selectAndRevealLastLoadedRow();
}
},
{ flush: "post" },
);
const manualTotalRowCount = ref<number | undefined>(undefined);
const manualTotalRowCountLoading = ref(false);
const esDeepPageJumpConfirmOpen = ref(false);
const pendingEsDeepPageJump = ref<{ targetPage: number; requestCount: number; updateCurrentPage: boolean }>();
// One "load all" click fetches the whole remaining segment in a single request;
// with the result-row cap disabled that segment is effectively unbounded, so a
// large shot needs an explicit confirmation the way ES deep page jumps do.
const LOAD_ALL_ROWS_CONFIRM_ROW_THRESHOLD = 100_000;
const loadAllRowsConfirmOpen = ref(false);
const pendingLoadAllRows = ref<{ remaining: number }>();
watch(loadAllRowsConfirmOpen, (open) => {
if (!open) pendingLoadAllRows.value = undefined;
});
watch(esDeepPageJumpConfirmOpen, (open) => {
if (!open) {
pendingEsDeepPageJump.value = undefined;
@@ -3318,8 +3338,6 @@ watch(
(values, previousValues) => {
if (!didDataGridInfiniteScrollContextChange(values, previousValues)) return;
manualTotalRowCount.value = undefined;
// Reset infinite-scroll allLoaded when query context changes
infiniteScrollAllLoaded = false;
},
);
@@ -3402,6 +3420,79 @@ function infiniteScrollNextPage() {
// row identities, which would invalidate pending edits while the user scrolls.
emit("paginate", nextOffset, nextLimit, currentWhereInput(), currentOrderBy());
}
function selectAndRevealLastLoadedRow() {
nextTick(() => {
let rowIndex = displayRowRefs.value.length - 1;
while (rowIndex >= 0 && !("sourceIndex" in displayRowRefs.value[rowIndex])) rowIndex--;
if (rowIndex < 0) return;
selectRow(rowIndex);
if (showTranspose.value) {
transposeRowIndex.value = rowIndex;
nextTick(() => scrollTransposeRecordIntoView(rowIndex, "nearest"));
return;
}
const rowHeight = useCanvasGridRows.value ? CANVAS_DATA_GRID_ROW_HEIGHT : DOM_DATA_GRID_ROW_HEIGHT;
const expectedRowBottom = (rowIndex + 1) * rowHeight;
let remainingFrames = 12;
const revealWhenReady = () => {
const scroller = gridScrollerElement();
if (scroller) {
if (useCanvasGridRows.value) scrollCanvasRowIntoView(rowIndex, "end");
else scrollDomRowIntoView(rowIndex, "end");
const expectedScrollTop = Math.max(0, expectedRowBottom - scroller.clientHeight);
if (scroller.scrollTop >= expectedScrollTop - 1) return;
}
remainingFrames--;
if (remainingFrames > 0) requestAnimationFrame(revealWhenReady);
};
requestAnimationFrame(revealWhenReady);
});
}
function loadAllRowsAndGoToLast() {
if (!props.loadAllRowsEnabled || gridSurfaceBusy.value || infiniteScrollLoading.value || props.result.rows.length === 0) return;
// search_after cursor paging fetches page by page; a single giant append
// against those cursors is untested, so ES/Easysearch grids keep the
// reveal-only shortcut instead of loading everything.
if (isResultsContext.value && (resolvedDatabaseType.value === "elasticsearch" || resolvedDatabaseType.value === "easysearch")) return;
const segment = dataGridLoadAllSegment(props.result.rows.length, infiniteScrollMaxRows.value, !infiniteScrollAllLoaded && canFetchNextInfiniteScrollSegment.value);
if (!segment) {
loadAllRowsActive.value = true;
infiniteScrollAllLoaded = true;
selectAndRevealLastLoadedRow();
return;
}
const knownTotal = displayedTotalRowCount.value;
const remaining = typeof knownTotal === "number" && Number.isFinite(knownTotal) && knownTotal >= props.result.rows.length ? knownTotal - props.result.rows.length : segment.limit;
if (remaining > LOAD_ALL_ROWS_CONFIRM_ROW_THRESHOLD) {
pendingLoadAllRows.value = { remaining };
loadAllRowsConfirmOpen.value = true;
return;
}
startLoadAllRows(segment);
}
function startLoadAllRows(segment: { offset: number; limit: number }) {
loadAllRowsActive.value = true;
infiniteScrollLoadAllPending = true;
infiniteScrollLoading.value = true;
isInfiniteScrollPaginating.value = true;
infiniteScrollRequestedOffset = segment.offset;
infiniteScrollRequestedLimit = segment.limit;
currentPage.value++;
emit("paginate", segment.offset, segment.limit, currentWhereInput(), currentOrderBy(), true);
}
function confirmLoadAllRows() {
const pending = pendingLoadAllRows.value;
if (!pending) return;
pendingLoadAllRows.value = undefined;
loadAllRowsConfirmOpen.value = false;
const segment = dataGridLoadAllSegment(props.result.rows.length, infiniteScrollMaxRows.value, !infiniteScrollAllLoaded && canFetchNextInfiniteScrollSegment.value);
if (segment) startLoadAllRows(segment);
}
function checkInfiniteScroll(scroller: HTMLElement) {
if (!infiniteScrollEnabled.value || infiniteScrollLoading.value || props.loading) return;
if (infiniteScrollAllLoaded) return;
@@ -3423,6 +3514,7 @@ function changePageSize(size: number) {
currentPage.value = 1;
lastInfiniteScrollPage = 0;
infiniteScrollAllLoaded = false;
loadAllRowsActive.value = false;
infiniteScrollPositions = new WeakMap();
resetGridVerticalScroll(true);
emit("paginate", 0, normalizedSize, currentWhereInput(), currentOrderBy());
@@ -4199,13 +4291,15 @@ function resetInfiniteScrollState() {
infiniteScrollRequestedLimit = undefined;
isInfiniteScrollPaginating.value = false;
infiniteScrollLoading.value = false;
infiniteScrollLoadAllPending = false;
loadAllRowsActive.value = false;
infiniteScrollPositions = new WeakMap();
resetGridVerticalScroll(true);
}
function prepareFullReload() {
const viewportAnchor = captureViewportAnchorForRefresh();
if (infiniteScrollEnabled.value) {
if (infiniteScrollEnabled.value || loadAllRowsActive.value) {
resetInfiniteScrollState();
}
const selection = captureCurrentSelectionForRefresh();
@@ -6114,7 +6208,7 @@ function applyColumnSort(column: string, columnIndex: number, direction: "asc" |
toast(t("grid.largeValueLocalSortUnavailable"), 5000);
return;
}
if (mode === "database" && infiniteScrollEnabled.value) {
if (mode === "database" && (infiniteScrollEnabled.value || loadAllRowsActive.value)) {
resetInfiniteScrollState();
} else {
currentPage.value = 1;
@@ -9983,8 +10077,21 @@ watch(
infiniteScrollLoading.value = false;
isInfiniteScrollPaginating.value = false;
}
// The append completion above already reset `infiniteScrollLoading`, so the
// post-flush loading watcher cannot observe this append; a "load all" run
// must still reveal its last row from here.
if (infiniteScrollLoadAllPending) {
infiniteScrollLoadAllPending = false;
selectAndRevealLastLoadedRow();
}
return;
}
// A non-append result replaces the whole data set, so a running "load all" is over.
loadAllRowsActive.value = false;
// The replacement also invalidates the all-loaded marker: a filter change or
// page jump swaps in a fresh first page whose remaining segments must be
// re-derived instead of being silently skipped.
infiniteScrollAllLoaded = false;
if (getResetScrollAfterResult()) {
clearResetScrollAfterResult();
resetGridVerticalScroll();
@@ -13872,9 +13979,12 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
:selection-summary="selectionSummary"
:selection-summary-sum-text="selectionSummarySumText"
:selection-summary-average-text="selectionSummaryAverageText"
:loading="gridPaginationBusy"
:loading="gridPaginationBusy || infiniteScrollLoading"
:infinite-scroll-enabled="infiniteScrollEnabled"
:infinite-scroll-all-loaded="infiniteScrollAllLoaded"
:load-all-rows-active="loadAllRowsActive"
:load-all-rows-enabled="loadAllRowsEnabled"
:can-load-all-rows="result.rows.length > 0"
:page-size="pageSize"
:default-page-size="defaultPageSize"
:page-size-menu-items="pageSizeMenuItems"
@@ -13891,6 +14001,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
@next-page="nextPage"
@jump-page="jumpPage"
@last-page="lastPage"
@load-all-rows="loadAllRowsAndGoToLast"
@select-export="selectExportMenuItem"
/>
</div>
@@ -13999,6 +14110,24 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
</DialogContent>
</Dialog>
<Dialog v-model:open="loadAllRowsConfirmOpen">
<DialogContent class="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle class="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<AlertTriangle class="h-5 w-5" />
{{ t("grid.loadAllRowsConfirmTitle") }}
</DialogTitle>
</DialogHeader>
<p class="py-3 text-sm leading-6 text-muted-foreground">
{{ t("grid.loadAllRowsConfirmMessage", { count: pendingLoadAllRows?.remaining ?? 0 }) }}
</p>
<DialogFooter>
<Button variant="outline" @click="loadAllRowsConfirmOpen = false">{{ t("dangerDialog.cancel") }}</Button>
<Button @click="confirmLoadAllRows">{{ t("grid.loadAllRowsContinue") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="generateIncrementDialogOpen">
<DialogContent class="sm:max-w-[380px]">
<DialogHeader>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Check, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Loader2 } from "@lucide/vue";
import { Check, ChevronLeft, ChevronRight, ChevronsDown, ChevronsLeft, ChevronsRight, Loader2 } from "@lucide/vue";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
@@ -20,6 +20,9 @@ const props = withDefaults(
loading: boolean;
infiniteScrollEnabled: boolean;
infiniteScrollAllLoaded: boolean;
loadAllRowsActive: boolean;
loadAllRowsEnabled: boolean;
canLoadAllRows: boolean;
pageSize: number;
defaultPageSize: number;
pageSizeMenuItems: LightDropdownItem[];
@@ -29,7 +32,7 @@ const props = withDefaults(
canGoNextPage: boolean;
canJumpLastPage: boolean;
}>(),
{ paginationEnabled: true },
{ paginationEnabled: true, loadAllRowsActive: false, loadAllRowsEnabled: true, canLoadAllRows: false },
);
const customPageSizeInput = defineModel<string>("customPageSizeInput", { default: "" });
@@ -49,6 +52,7 @@ const emit = defineEmits<{
nextPage: [];
jumpPage: [page: number];
lastPage: [];
loadAllRows: [];
selectExport: [value: string];
applyCustomPageSizeAndSetDefault: [];
}>();
@@ -96,10 +100,10 @@ function handlePageInputKeydown(event: KeyboardEvent) {
</div>
</div>
<Loader2 class="w-3 h-3 text-muted-foreground" :class="loading ? 'animate-spin' : 'invisible'" aria-hidden="true" />
<template v-if="paginationEnabled && infiniteScrollEnabled">
<template v-if="paginationEnabled && (infiniteScrollEnabled || loadAllRowsActive)">
<span v-if="infiniteScrollAllLoaded" class="text-xs text-muted-foreground shrink-0">{{ t("grid.allLoaded") }}</span>
</template>
<template v-if="paginationEnabled && !infiniteScrollEnabled">
<template v-if="paginationEnabled && !infiniteScrollEnabled && !loadAllRowsActive">
<LightDropdown
:model-value="String(pageSize)"
:items="pageSizeMenuItems"
@@ -152,6 +156,14 @@ function handlePageInputKeydown(event: KeyboardEvent) {
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || !canGoNextPage" @click="emit('nextPage')"><ChevronRight class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || !canJumpLastPage" @click="emit('lastPage')"><ChevronsRight class="h-3 w-3" /></Button>
</template>
<Tooltip v-if="paginationEnabled && loadAllRowsEnabled">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || !canLoadAllRows" :aria-label="t('grid.loadAllAndGoToLastRow')" @click="emit('loadAllRows')">
<ChevronsDown class="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{{ t("grid.loadAllAndGoToLastRow") }}</TooltipContent>
</Tooltip>
<DataGridExportMenu :items="exportMenuItems" :label="t('grid.export')" :on-select="(value) => emit('selectExport', value)" />
</div>
</template>
@@ -30,6 +30,7 @@ vi.mock("@lucide/vue", async () => {
ChevronsLeft: icon,
ChevronsRight: icon,
Download: icon,
ChevronsDown: icon,
Filter: icon,
Focus: icon,
FileDiff: icon,
@@ -288,7 +289,7 @@ describe("DataGridPagination", () => {
onNextPage: nextPage,
onLastPage: lastPage,
});
const navigation = findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0");
const navigation = findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0" && node.props["aria-label"] !== "grid.loadAllAndGoToLastRow");
expect(navigation.map((node) => node.props.disabled)).toEqual([true, true, true, true]);
navigation.forEach((node) => dispatch(node, "click"));
@@ -298,7 +299,7 @@ describe("DataGridPagination", () => {
expect(lastPage).not.toHaveBeenCalled();
await mounted.setProps({ currentPage: 2, canGoNextPage: true, canJumpLastPage: true });
const enabledNavigation = findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0");
const enabledNavigation = findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0" && node.props["aria-label"] !== "grid.loadAllAndGoToLastRow");
expect(enabledNavigation.map((node) => node.props.disabled)).toEqual([false, false, false, false]);
enabledNavigation.forEach((node) => dispatch(node, "click"));
expect(firstPage).toHaveBeenCalledOnce();
@@ -307,7 +308,7 @@ describe("DataGridPagination", () => {
expect(lastPage).toHaveBeenCalledOnce();
await mounted.setProps({ loading: true });
const busyNavigation = findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0");
const busyNavigation = findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0" && node.props["aria-label"] !== "grid.loadAllAndGoToLastRow");
expect(busyNavigation.map((node) => node.props.disabled)).toEqual([true, true, true, true]);
expect(findOne(mounted.root, (node) => node.props["aria-label"] === "grid.jumpToPage").props.disabled).toBe(true);
});
@@ -375,6 +376,36 @@ describe("DataGridPagination", () => {
expect(findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0")).toHaveLength(0);
});
it("loads all paginated rows and moves to the last row", async () => {
const loadAllRows = vi.fn();
const mounted = mountComponent(DataGridPagination, {
selectionSummary: null,
selectionSummarySumText: "",
loading: false,
infiniteScrollEnabled: false,
infiniteScrollAllLoaded: false,
canLoadAllRows: true,
pageSize: 100,
customPageSizeInput: "",
pageSizeMenuItems: [],
exportMenuItems: [],
currentPage: 1,
canGoNextPage: true,
canJumpLastPage: true,
onLoadAllRows: loadAllRows,
});
const loadAllButton = findOne(mounted.root, (node) => node.props["aria-label"] === "grid.loadAllAndGoToLastRow");
expect(loadAllButton.props.disabled).toBe(false);
dispatch(loadAllButton, "click");
expect(loadAllRows).toHaveBeenCalledOnce();
await mounted.setProps({ loading: true });
expect(findOne(mounted.root, (node) => node.props["aria-label"] === "grid.loadAllAndGoToLastRow").props.disabled).toBe(true);
await mounted.setProps({ loading: false, canLoadAllRows: false });
expect(findOne(mounted.root, (node) => node.props["aria-label"] === "grid.loadAllAndGoToLastRow").props.disabled).toBe(true);
});
});
describe("DataGridColumnHeader", () => {
@@ -2301,7 +2301,7 @@ defineExpose({
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
@local-column-filters-change="(filters: Record<string, string[]>) => queryStore.updateDataGridLocalColumnFilters(activeTab.id, filters)"
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) => emit('reload', activeTab.id, sql, searchText, whereInput, orderBy, limit, offset, intent)"
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string) => emit('paginate', activeTab.id, offset, limit, whereInput, orderBy)"
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string, appendResult?: boolean) => emit('paginate', activeTab.id, offset, limit, whereInput, orderBy, appendResult)"
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', activeTab.id, column, columnIndex, direction, whereInput, mode)"
@change-query-timeout="(connectionId: string) => emit('openConnectionSettings', connectionId, 'advanced')"
>
@@ -2727,7 +2727,7 @@ defineExpose({
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
@local-column-filters-change="(filters: Record<string, string[]>) => queryStore.updateDataGridLocalColumnFilters(activeTab.id, filters)"
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) => emit('reload', activeTab.id, sql, searchText, whereInput, orderBy, limit, offset, intent)"
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string) => emit('paginate', activeTab.id, offset, limit, whereInput, orderBy)"
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string, appendResult?: boolean) => emit('paginate', activeTab.id, offset, limit, whereInput, orderBy, appendResult)"
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', activeTab.id, column, columnIndex, direction, whereInput, mode)"
@change-query-timeout="(connectionId: string) => emit('openConnectionSettings', connectionId, 'advanced')"
>
@@ -91,7 +91,7 @@ export interface ContentAreaSurfaceEmits {
editorStateFlushed: [tabId: string];
formatError: [tabId: string];
reload: [tabId: string, sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent];
paginate: [tabId: string, offset: number, limit: number, whereInput?: string, orderBy?: string];
paginate: [tabId: string, offset: number, limit: number, whereInput?: string, orderBy?: string, appendResult?: boolean];
sort: [tabId: string, column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode?: DataGridSortMode];
executeSql: [tabId: string, sql: string];
clickTable: [tabId: string, target: SqlObjectNavigationTarget];