fix(grid): append infinite scroll result segments

This commit is contained in:
t8y2 2026-07-21 18:43:47 +08:00
parent 678033c82e
commit c153e93d54
9 changed files with 303 additions and 30 deletions

View File

@ -2269,6 +2269,8 @@ const isInfiniteScrollPaginating = ref(false);
let lastInfiniteScrollPage = 0;
let infiniteScrollCheckScheduled = false;
let infiniteScrollAllLoaded = false;
let infiniteScrollRequestedOffset: number | undefined;
let infiniteScrollRequestedLimit: number | undefined;
// 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.
@ -2313,9 +2315,19 @@ watch(
if (prevLoading && !loading && infiniteScrollLoading.value) {
infiniteScrollLoading.value = false;
isInfiniteScrollPaginating.value = false;
// Detect if the backend returned no new data for this page
const expectedRows = currentPage.value * pageSize.value;
if (props.result.rows.length < expectedRows) {
const requestedOffset = infiniteScrollRequestedOffset;
const requestedLimit = infiniteScrollRequestedLimit;
infiniteScrollRequestedOffset = undefined;
infiniteScrollRequestedLimit = undefined;
if (requestedOffset === undefined || props.result.appended_from_row_count !== requestedOffset) {
// Failed/stale append requests preserve the old result. Roll back the
// 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);
return;
}
const appendedRows = props.result.rows.length - requestedOffset;
if (props.result.rows.length >= infiniteScrollMaxRows.value || appendedRows < (requestedLimit ?? pageSize.value)) {
infiniteScrollAllLoaded = true;
}
}
@ -2489,21 +2501,21 @@ function nextPage() {
function infiniteScrollNextPage() {
if (infiniteScrollLoading.value || props.loading) return;
const nextOffset = props.result.rows.length;
const remainingRows = infiniteScrollMaxRows.value - nextOffset;
if (remainingRows <= 0) return;
const nextLimit = Math.min(pageSize.value, remainingRows);
const nextPageNum = currentPage.value + 1;
const cumulativeLimit = nextPageNum * pageSize.value;
if (cumulativeLimit > infiniteScrollMaxRows.value) return;
// Stop if we already know all data is loaded
if (infiniteScrollAllLoaded) return;
// Skip if we already have this many rows loaded (e.g. cached data)
if (props.result.rows.length >= cumulativeLimit) {
currentPage.value = nextPageNum;
return;
}
infiniteScrollLoading.value = true;
isInfiniteScrollPaginating.value = true;
infiniteScrollRequestedOffset = nextOffset;
infiniteScrollRequestedLimit = nextLimit;
currentPage.value = nextPageNum;
// Load cumulative data (all rows up to current page) to append instead of replace
emit("paginate", 0, cumulativeLimit, currentWhereInput(), currentOrderBy());
// Fetch only the missing segment. Re-reading offset 0 grows transfer and replaces
// row identities, which would invalidate pending edits while the user scrolls.
emit("paginate", nextOffset, nextLimit, currentWhereInput(), currentOrderBy());
}
function checkInfiniteScroll(scroller: HTMLElement) {
if (!infiniteScrollEnabled.value || infiniteScrollLoading.value || props.loading) return;
@ -2969,6 +2981,8 @@ function resetInfiniteScrollState() {
currentPage.value = 1;
lastInfiniteScrollPage = 0;
infiniteScrollAllLoaded = false;
infiniteScrollRequestedOffset = undefined;
infiniteScrollRequestedLimit = undefined;
isInfiniteScrollPaginating.value = false;
infiniteScrollLoading.value = false;
infiniteScrollPositions = new WeakMap();

View File

@ -247,13 +247,16 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
async function onPaginate(offset: number, limit: number, whereInput?: string, orderBy?: string) {
const tab = activeTab.value;
if (!tab) return;
const appendResult = settingsStore.editorSettings.infiniteScroll && offset > 0 && offset === tab.result?.rows.length;
const appendOptions = appendResult ? { appendResult: { maxRows: settingsStore.editorSettings.infiniteScrollMaxRows } } : {};
if (tab.mode !== "data") {
const sortColumns = visibleQuerySortColumns(tab.result?.columns ?? [], tab.result?.hidden_column_indexes, tab.resultSortColumnIndex ?? -1);
const hasDatabaseSort = !!tab.result?.hidden_column_indexes?.length && tab.resultSortMode === "database" && !!tab.resultSortDirection && !!tab.resultSortColumn && !!sortColumns;
const baseSql = hasDatabaseSort ? queryResultBaseSql(tab) : queryResultExecutionSql(tab);
if (!baseSql.trim()) return;
const expectedNextOffset = (tab.resultPageOffset ?? 0) + (tab.resultPageLimit ?? limit);
const sessionId = tab.result?.has_more && tab.result?.session_id && offset === expectedNextOffset && limit === tab.resultPageLimit ? tab.result.session_id : undefined;
const expectedNextOffset = appendResult ? tab.result?.rows.length : (tab.resultPageOffset ?? 0) + (tab.resultPageLimit ?? limit);
const continuesResultSession = appendResult ? offset === expectedNextOffset : offset === expectedNextOffset && limit === tab.resultPageLimit;
const sessionId = tab.result?.has_more && tab.result?.session_id && continuesResultSession ? tab.result.session_id : undefined;
const resultBaseSql = queryResultBaseSql(tab);
await queryStore.executeTabSql(tab.id, baseSql, {
resultBaseSql,
@ -269,6 +272,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
}
: {}),
pagination: { offset, limit, sessionId },
...appendOptions,
preserveResultDuringExecution: true,
preserveTotalRowCountDuringExecution: true,
replaceActiveResultInGroup: true,
@ -282,6 +286,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
queryStore.updateSql(tab.id, sql);
await queryStore.executeTabSql(tab.id, sql, {
pagination: { offset, limit },
...appendOptions,
preserveResultDuringExecution: true,
preserveTotalRowCountDuringExecution: true,
});

View File

@ -1,4 +1,4 @@
import { ref, computed, nextTick, watch, getCurrentInstance, onActivated, onBeforeUnmount, onDeactivated, onMounted, type ComputedRef, type Ref } from "vue";
import { ref, computed, nextTick, watch, getCurrentInstance, onActivated, onBeforeUnmount, onDeactivated, onMounted, toRaw, type ComputedRef, type Ref } from "vue";
import * as api from "@/lib/backend/api";
import type { CellValue } from "@/lib/dataGrid/cellValue";
import { coerceDataGridCellValue, dataGridCellEditorText } from "@/lib/dataGrid/dataGridCellCoercion";
@ -139,10 +139,15 @@ const closingPendingSnapshotTabs = new Set<string>();
const BEFORE_TAB_SWITCH_EVENT = "dbx:before-tab-switch";
const MAX_PENDING_CHANGES_HISTORY = 100;
function dataGridRowsIdentityChanged(previousRows: CellValue[][] | undefined, nextRows: CellValue[][]): boolean {
function dataGridRowsIdentityChanged(previousRows: CellValue[][] | undefined, nextRows: CellValue[][], appendedFromRowCount?: number): boolean {
if (!previousRows) return true;
if (previousRows.length !== nextRows.length) return true;
return previousRows.some((row, index) => row !== nextRows[index]);
if (appendedFromRowCount !== previousRows.length || previousRows.length > nextRows.length) {
if (previousRows.length !== nextRows.length) return true;
return previousRows.some((row, index) => toRaw(row) !== toRaw(nextRows[index]));
}
// Infinite scrolling appends rows without changing existing source indexes.
// Preserve pending edits only when every previously loaded row is the same object.
return previousRows.some((row, index) => toRaw(row) !== toRaw(nextRows[index]));
}
function cacheKeyBelongsToTab(cacheKey: string, tabId: string) {
@ -1334,13 +1339,13 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
exitTransaction();
}
// Pending changes reference rows by sourceIndex. When the result set changes
// (e.g. different WHERE clause, pagination), stale indices point to wrong rows.
// Pending changes reference rows by sourceIndex. Replacements (different WHERE,
// sort, normal pagination, refresh) invalidate them; prefix-only appends do not.
let previousResultRows = result.value.rows;
watch(
() => result.value.rows,
(rows) => {
if (!dataGridRowsIdentityChanged(previousResultRows, rows)) {
() => [result.value.rows, (result.value as { appended_from_row_count?: number }).appended_from_row_count] as const,
([rows, appendedFromRowCount]) => {
if (!dataGridRowsIdentityChanged(previousResultRows, rows, appendedFromRowCount)) {
previousResultRows = rows;
return;
}

View File

@ -131,6 +131,30 @@ function markQueryResultsRowsRaw(results: QueryResult[]): QueryResult[] {
return results;
}
function appendQueryResultSegment(previous: QueryResult, segment: QueryResult, maxRows: number): QueryResult {
if (segment.execution_error) throw new Error(String(segment.rows[0]?.[0] ?? "Failed to load the next result segment"));
if (previous.columns.length !== segment.columns.length || previous.columns.some((column, index) => column !== segment.columns[index])) {
throw new Error("Result columns changed while loading the next segment");
}
const remainingRows = Math.max(0, maxRows - previous.rows.length);
const appendedRowCount = Math.min(remainingRows, segment.rows.length);
const appendParallelValues = <T>(existing: T[] | undefined, next: T[] | undefined): T[] | undefined => {
if (!existing && !next) return undefined;
return [...(existing ?? []), ...(next ?? []).slice(0, appendedRowCount)];
};
// Keep prior row objects intact so source-index based dirty/new/deleted state
// remains valid, while bounding the in-memory result by the configured cap.
return markQueryResultRowsRaw({
...segment,
appended_from_row_count: previous.rows.length,
rows: [...previous.rows, ...segment.rows.slice(0, appendedRowCount)],
mongo_documents: appendParallelValues(previous.mongo_documents, segment.mongo_documents),
mongo_copy_documents: appendParallelValues(previous.mongo_copy_documents, segment.mongo_copy_documents),
execution_time_ms: (previous.execution_time_ms ?? 0) + (segment.execution_time_ms ?? 0),
has_more: previous.rows.length + appendedRowCount >= maxRows ? false : segment.has_more,
});
}
function markQueryResultRunsRowsRaw(resultRuns: NonNullable<QueryTab["resultRuns"]>): NonNullable<QueryTab["resultRuns"]> {
for (const run of resultRuns) {
if (run.result) markQueryResultRowsRaw(run.result);
@ -2797,6 +2821,7 @@ export const useQueryStore = defineStore("query", () => {
direction: "asc" | "desc";
};
pagination?: { limit: number; offset: number; sessionId?: string };
appendResult?: { maxRows: number };
mongoSafety?: MongoAggregateSafetyOptions;
preserveResultDuringExecution?: boolean;
preserveTotalRowCountDuringExecution?: boolean;
@ -3405,8 +3430,20 @@ export const useQueryStore = defineStore("query", () => {
if (current?.executionId === executionId) {
const activeGroupIndex = current.activeResultIndex;
const activeGroupResults = current.results;
const shouldAppendResult = !!options?.appendResult && !!current.result;
const shouldReplaceActiveResultInGroup = options?.replaceActiveResultInGroup === true && results.length === 1 && Array.isArray(activeGroupResults) && typeof activeGroupIndex === "number" && activeGroupIndex >= 0 && activeGroupIndex < activeGroupResults.length;
if (shouldReplaceActiveResultInGroup) {
if (shouldAppendResult) {
if (results.length !== 1) throw new Error("Expected one result while loading the next segment");
if (options.pagination?.offset !== current.result!.rows.length) {
throw new Error("Ignoring a stale result segment whose offset no longer matches the loaded rows");
}
const appendedResult = appendQueryResultSegment(current.result!, results[0]!, options.appendResult!.maxRows);
if (Array.isArray(activeGroupResults) && typeof activeGroupIndex === "number" && activeGroupIndex >= 0 && activeGroupIndex < activeGroupResults.length) {
current.results = activeGroupResults.slice();
current.results[activeGroupIndex] = appendedResult;
}
current.result = appendedResult;
} else if (shouldReplaceActiveResultInGroup) {
current.results = activeGroupResults.slice();
current.results[activeGroupIndex] = results[0];
current.result = results[0];
@ -3425,9 +3462,12 @@ export const useQueryStore = defineStore("query", () => {
current.resultBaseSql = shouldReplaceActiveResultInGroup ? (current.resultBaseSql ?? queryBaseSql) : queryBaseSql;
current.resultEditorFingerprint = shouldReplaceActiveResultInGroup ? (current.resultEditorFingerprint ?? executionEditorFingerprint) : executionEditorFingerprint;
current.resultSortedSql = resultSortedSql;
current.resultPageSql = pageSql;
// Appended rows form one logical result starting at the original page.
// Keep the base page state so later table refresh/cache recovery does
// not re-execute only the most recently fetched tail segment.
current.resultPageSql = shouldAppendResult ? (current.resultPageSql ?? pageSql) : pageSql;
current.resultPageLimit = pageLimit;
current.resultPageOffset = pageOffset;
current.resultPageOffset = shouldAppendResult ? (current.resultPageOffset ?? 0) : pageOffset;
current.resultCountSql = countSql;
current.resultSessionId = current.result?.session_id ?? undefined;
if (!options?.preserveTotalRowCountDuringExecution) {
@ -3451,7 +3491,7 @@ export const useQueryStore = defineStore("query", () => {
};
})()
: undefined;
const canAutoCalculateTotalRows = !!current.result && resultRowCount > 0 && !totalKnownFromIncompletePage && settingsStore.editorSettings.autoCalculateTotalRows && ((current.mode === "query" && !!countSql) || (current.mode === "data" && !!dataCountTarget));
const canAutoCalculateTotalRows = !options?.appendResult && !!current.result && resultRowCount > 0 && !totalKnownFromIncompletePage && settingsStore.editorSettings.autoCalculateTotalRows && ((current.mode === "query" && !!countSql) || (current.mode === "data" && !!dataCountTarget));
current.resultTotalRowCountLoading = canAutoCalculateTotalRows;
// Server-side pagination without a countSql: the backend (currently
// the Elasticsearch driver) already reports the true match total via
@ -3465,7 +3505,7 @@ export const useQueryStore = defineStore("query", () => {
}
touchResult(current);
syncDisplayedResultRun(current, queryBaseSql);
if (!totalRowCountResolved && (current.mode === "query" || current.mode === "data") && current.result) {
if (!options?.appendResult && !totalRowCountResolved && (current.mode === "query" || current.mode === "data") && current.result) {
countQueryTotalRowsInBackground({
tabId: id,
connectionId: current.connectionId,
@ -3520,6 +3560,12 @@ export const useQueryStore = defineStore("query", () => {
}
const current = tabs.value.find((t) => t.id === id);
if (current?.executionId === executionId) {
if (options?.appendResult && current.result) {
// A failed background segment must not replace the visible result or
// silently invalidate pending edits. The next explicit refresh can retry.
queryExecutionLog("warn", "append-result:preserved-after-error", { traceId, elapsed: elapsed() });
return;
}
const errorResult = toErrorResult(e);
const activeGroupIndex = current.activeResultIndex;
const activeGroupResults = current.results;

View File

@ -500,6 +500,8 @@ export interface OwnerInfo {
export interface QueryResult {
columns: string[];
/** Internal marker for a result built by appending a page to existing rows. */
appended_from_row_count?: number;
/** Set for synthesized query execution failures. */
execution_error?: true;
/** Zero-based index of the submitted statement that produced this result. */

View File

@ -988,6 +988,34 @@ test("keeps appended empty-table rows when parent refreshes an equivalent rows a
assert.equal(editor.newRows.value.length, 0);
});
test("keeps dirty new and deleted state when infinite scrolling appends rows", async () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
const firstRow = [1, "Ada"] as CellValue[];
const secondRow = [2, "Grace"] as CellValue[];
const result = ref<{ columns: string[]; rows: CellValue[][]; appended_from_row_count?: number }>({ columns: ["id", "name"], rows: [firstRow, secondRow] });
const editor = createPeopleGridEditor(computed(() => result.value));
editor.applyCellValue(0, 1, "Ada Lovelace");
editor.deletedRows.value = new Set([1]);
editor.addRow();
await nextTick();
result.value = { columns: ["id", "name"], rows: [firstRow, secondRow, [3, "Linus"] as CellValue[]], appended_from_row_count: 2 };
await nextTick();
assert.equal(editor.dirtyRows.value.get(0)?.get(1), "Ada Lovelace");
assert.deepEqual([...editor.deletedRows.value], [1]);
assert.equal(editor.newRows.value.length, 1);
result.value = { columns: ["id", "name"], rows: [[1, "Ada"] as CellValue[], [2, "Grace"] as CellValue[]] };
await nextTick();
assert.equal(editor.dirtyRows.value.size, 0, "explicit result replacement still clears stale source indexes");
assert.equal(editor.deletedRows.value.size, 0);
assert.equal(editor.newRows.value.length, 0);
});
test("saving manually typed JSON from a MySQL grid normalizes smart quotes", async () => {
setActivePinia(createPinia());
installBrowserTestGlobals();

View File

@ -1,4 +1,5 @@
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import { test } from "vitest";
import { dataGridScrollPosition, isDataGridNearScrollBottom, shouldCheckInfiniteScrollAfterScroll } from "../../apps/desktop/src/lib/dataGrid/dataGridInfiniteScroll.ts";
@ -23,3 +24,11 @@ test("near-bottom check matches the grid threshold", () => {
assert.equal(isDataGridNearScrollBottom({ scrollTop: 801, scrollHeight: 1000, clientHeight: 100 }), true);
assert.equal(isDataGridNearScrollBottom({ scrollTop: 800, scrollHeight: 1000, clientHeight: 100 }), false);
});
test("infinite scroll requests only the next bounded segment", () => {
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
assert.match(source, /const nextOffset = props\.result\.rows\.length/);
assert.match(source, /Math\.min\(pageSize\.value, remainingRows\)/);
assert.doesNotMatch(source, /emit\("paginate", 0, cumulativeLimit/);
assert.match(source, /props\.result\.appended_from_row_count !== requestedOffset/);
});

View File

@ -1,7 +1,7 @@
import { strict as assert } from "node:assert";
import { afterEach, test } from "vitest";
import { createPinia, disposePinia, getActivePinia, setActivePinia } from "pinia";
import { isReactive } from "vue";
import { isReactive, toRaw } from "vue";
import { decodeQueryResultArchive } from "../../apps/desktop/src/lib/query/queryResultArchive.ts";
import { analyzeEditableQueryEditability } from "../../apps/desktop/src/lib/sql/sqlAnalysis.ts";
import { resultSqlForGrid } from "../../apps/desktop/src/lib/tabs/tabPresentation.ts";
@ -2584,6 +2584,117 @@ test("data tab execution preserves pagination offset metadata", async () => {
}
});
test("append pagination preserves existing rows and respects the memory cap", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const store = useQueryStore();
const originalFetch = globalThis.fetch;
connectionStore.addEphemeralConnection(conn("conn-append"));
const tabId = store.createTab("conn-append", "db", "users", "data", "public");
const tab = store.tabs.find((item) => item.id === tabId);
assert.ok(tab);
const firstRow = [1] as (string | number | boolean | null)[];
tab.result = { columns: ["id"], rows: [firstRow], affected_rows: 0, execution_time_ms: 3 };
globalThis.fetch = withConnectionHealthMock(async (input) => {
if (String(input) === "/api/query/execute-multi") {
return Response.json([{ columns: ["id"], rows: [[2], [3]], affected_rows: 0, execution_time_ms: 4, has_more: true }]);
}
return new Response("unexpected request", { status: 500 });
});
try {
await store.executeTabSql(tabId, 'SELECT * FROM "users" LIMIT 2 OFFSET 1;', {
pagination: { limit: 2, offset: 1 },
appendResult: { maxRows: 2 },
preserveResultDuringExecution: true,
preserveTotalRowCountDuringExecution: true,
});
assert.deepEqual(tab.result?.rows, [[1], [2]]);
assert.equal(toRaw(tab.result?.rows[0]), firstRow);
assert.equal(tab.result?.execution_time_ms, 7);
assert.equal(tab.result?.has_more, false);
assert.equal(tab.resultPageOffset, 0, "later refreshes must restart from the logical result origin");
assert.equal(tab.resultPageLimit, 2);
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
}
});
test("failed append pagination preserves the visible result", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const store = useQueryStore();
const originalFetch = globalThis.fetch;
connectionStore.addEphemeralConnection(conn("conn-append-error"));
const tabId = store.createTab("conn-append-error", "db", "users", "data", "public");
const tab = store.tabs.find((item) => item.id === tabId);
assert.ok(tab);
const originalResult: QueryResult = { columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 3 };
tab.result = originalResult;
globalThis.fetch = withConnectionHealthMock(async (input) => {
if (String(input) === "/api/query/execute-multi") return new Response("segment failed", { status: 500 });
return new Response("unexpected request", { status: 500 });
});
try {
await store.executeTabSql(tabId, 'SELECT * FROM "users" LIMIT 2 OFFSET 1;', {
pagination: { limit: 2, offset: 1 },
appendResult: { maxRows: 10 },
preserveResultDuringExecution: true,
preserveTotalRowCountDuringExecution: true,
});
assert.equal(toRaw(tab.result), originalResult);
assert.deepEqual(tab.result?.rows, [[1]]);
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
}
});
test("stale append offsets do not duplicate already loaded rows", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const store = useQueryStore();
const originalFetch = globalThis.fetch;
connectionStore.addEphemeralConnection(conn("conn-append-stale"));
const tabId = store.createTab("conn-append-stale", "db", "users", "data", "public");
const tab = store.tabs.find((item) => item.id === tabId);
assert.ok(tab);
const originalResult: QueryResult = { columns: ["id"], rows: [[1], [2]], affected_rows: 0, execution_time_ms: 3 };
tab.result = originalResult;
globalThis.fetch = withConnectionHealthMock(async (input) => {
if (String(input) === "/api/query/execute-multi") return Response.json([{ columns: ["id"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 }]);
return new Response("unexpected request", { status: 500 });
});
try {
await store.executeTabSql(tabId, 'SELECT * FROM "users" LIMIT 1 OFFSET 1;', {
pagination: { limit: 1, offset: 1 },
appendResult: { maxRows: 10 },
preserveResultDuringExecution: true,
preserveTotalRowCountDuringExecution: true,
});
assert.equal(toRaw(tab.result), originalResult);
assert.deepEqual(tab.result?.rows, [[1], [2]]);
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
}
});
test("data tab default pagination uses the dedicated table-open page size", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());

View File

@ -1,9 +1,10 @@
import assert from "node:assert/strict";
import { computed } from "vue";
import { computed, toRaw } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { test, vi } from "vitest";
import { useConnectionStore } from "../../apps/desktop/src/stores/connectionStore.ts";
import { useQueryStore } from "../../apps/desktop/src/stores/queryStore.ts";
import { useSettingsStore } from "../../apps/desktop/src/stores/settingsStore.ts";
import type { ColumnInfo, ConnectionConfig } from "../../apps/desktop/src/types/database.ts";
vi.mock("vue-i18n", async () => {
@ -171,6 +172,58 @@ test("data reload preserves current page offset instead of resetting to page 1",
}
});
test("infinite pagination fetches and appends only the next table segment", async () => {
const restoreStorage = installMemoryStorage();
const originalFetch = globalThis.fetch;
const { useDataGridActions } = await import("../../apps/desktop/src/composables/useDataGridActions.ts");
let buildSqlOptions: any;
let executeBody: any;
globalThis.fetch = (async (input, init) => {
const url = new URL(String(input), "http://localhost");
if (url.pathname === "/api/connection/check-health") return Response.json(null);
if (url.pathname === "/api/query/build-table-select-sql") {
buildSqlOptions = JSON.parse(String(init?.body ?? "{}"))?.options;
return Response.json("SELECT * FROM `orders` LIMIT 100 OFFSET 100");
}
if (url.pathname === "/api/query/execute-multi") {
executeBody = JSON.parse(String(init?.body ?? "{}"));
return Response.json([{ columns: ["id"], rows: [[101], [102]], affected_rows: 0, execution_time_ms: 2 }]);
}
return new Response(`unexpected ${url.pathname}`, { status: 500 });
}) as typeof fetch;
try {
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
settingsStore.updateEditorSettings({ infiniteScroll: true, infiniteScrollMaxRows: 5000 });
connectionStore.addEphemeralConnection(conn("mysql-1"));
const tabId = queryStore.createTab("mysql-1", "app", "orders", "data");
queryStore.setTableMeta(tabId, { tableName: "orders", tableType: "TABLE", columns: [], primaryKeys: [] });
const tab = queryStore.tabs.find((item) => item.id === tabId);
assert.ok(tab);
const firstRow = [1] as (string | number | boolean | null)[];
tab.result = { columns: ["id"], rows: [firstRow], affected_rows: 0, execution_time_ms: 1 };
tab.resultPageLimit = 100;
tab.resultPageOffset = 0;
const actions = useDataGridActions(computed(() => tab));
await actions.onPaginate(1, 100);
assert.equal(buildSqlOptions?.offset, 1);
assert.equal(buildSqlOptions?.limit, 100);
assert.equal(executeBody.maxRows, 100, "the backend receives one segment, not a cumulative limit");
assert.equal(tab.result?.rows.length, 3);
assert.equal(toRaw(tab.result?.rows[0]), firstRow, "existing row identity must survive append");
assert.deepEqual(tab.result?.rows.slice(1), [[101], [102]]);
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
}
});
test("query toolbar refresh reruns the complete multi-result SQL and keeps the active result", async () => {
const restoreStorage = installMemoryStorage();
const { useDataGridActions } = await import("../../apps/desktop/src/composables/useDataGridActions.ts");