feat: optimize tab result lifecycle caching

This commit is contained in:
t8y2 2026-06-06 20:30:35 +08:00
parent b226b445d6
commit 2b5300722f
22 changed files with 1125 additions and 56 deletions

1
Cargo.lock generated
View File

@ -1844,6 +1844,7 @@ name = "dbx"
version = "0.5.29"
dependencies = [
"anyhow",
"base64 0.22.1",
"calamine",
"chrono",
"csv",

View File

@ -279,7 +279,10 @@ function isGlobalUiZoomTarget(target: EventTarget | null): target is Element {
watch(
() => queryStore.activeTabId,
(id) => {
(id, previousId) => {
if (previousId && previousId !== id && typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("dbx:before-tab-switch", { detail: { tabId: id, fromTabId: previousId } }));
}
if (id) newQueryContextSource.value = "tab";
selectedSql.value = "";
activeOutputView.value = "result";
@ -862,11 +865,6 @@ async function reconnectRestoredTabs() {
await connectionStore.ensureConnected(activeConnectionId);
} catch {}
}
const tab = activeTab.value;
if (tab?.mode === "data" && tab.tableMeta && tab.sql) {
queryStore.executeTabSql(tab.id, tab.sql).catch(() => {});
}
}
function handleContextMenu(e: MouseEvent) {
@ -1058,7 +1056,7 @@ onUnmounted(() => {
@set-default-database="setActiveDatabaseAsDefault"
@clear-default-database="clearActiveDefaultDatabase"
/>
<KeepAlive :max="8">
<KeepAlive :max="4">
<ContentArea
ref="contentAreaRef"
:key="activeTab.id"

View File

@ -1766,6 +1766,8 @@ function markGridScrolling() {
function onScrollerScroll(e: Event) {
syncHeaderScroll(e);
const target = e.target;
recordScrollPosition(target instanceof HTMLElement ? { top: target.scrollTop, left: target.scrollLeft } : undefined);
markGridScrolling();
}
@ -2125,6 +2127,7 @@ const {
exitTransaction,
startEdit,
commitEdit,
commitEditFromBlur,
applyCellValue,
cancelEdit,
onEditKeydown,
@ -2147,6 +2150,7 @@ const {
getResetScrollAfterResult,
clearResetScrollAfterResult,
cleanupFrames,
recordScrollPosition,
} = editor;
const saveActionMode = computed(() =>
@ -3415,6 +3419,7 @@ function onCanvasScroll(event: Event) {
const gutter = scrollbarGutterWidth(scroller);
if (gridScrollbarGutter.value !== gutter) gridScrollbarGutter.value = gutter;
if (headerRef.value && headerRef.value.scrollLeft !== scrollLeft) headerRef.value.scrollLeft = scrollLeft;
recordScrollPosition({ top: scrollTop, left: scrollLeft });
markGridScrolling();
scheduleCanvasDraw();
}
@ -4606,6 +4611,8 @@ function updateTransposeViewport() {
function onTransposeScroll() {
updateTransposeViewport();
const el = transposeScrollElement();
recordScrollPosition(el ? { top: el.scrollTop, left: el.scrollLeft } : undefined);
markGridScrolling();
}
@ -6312,7 +6319,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
autocorrect="off"
spellcheck="false"
class="cell-edit-input absolute inset-0 bg-background border-2 border-primary px-1.5 py-0 text-xs leading-[26px] outline-none z-10"
@blur="commitEdit"
@blur="commitEditFromBlur"
@click.stop
@keydown.stop="onEditKeydown"
@paste.stop
@ -6948,7 +6955,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
autocorrect="off"
spellcheck="false"
class="cell-edit-input absolute inset-0 bg-background border-2 border-primary px-2.5 py-0 text-xs leading-[22px] outline-none z-10"
@blur="commitEdit"
@blur="commitEditFromBlur"
@click.stop
@keydown.stop="onEditKeydown"
@paste.stop
@ -7123,7 +7130,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
autocorrect="off"
spellcheck="false"
class="cell-edit-input absolute inset-0 bg-background border-2 border-primary px-2.5 py-0 text-xs leading-[22px] outline-none z-10"
@blur="commitEdit"
@blur="commitEditFromBlur"
@click.stop
@keydown.stop="onEditKeydown"
@paste.stop

View File

@ -229,17 +229,19 @@ function tabMenuIcon(tab: QueryTab) {
return Code2;
}
function activateTab(tabId: string) {
tabScrollBehavior.value = "auto";
queryStore.activeTabId = tabId;
emit("close-driver-store");
}
function handleTabClick(tab: QueryTab) {
if (tabDrag.state.wasDragged) return;
activateTab(tab.id);
}
function handleTabMouseDown(event: MouseEvent, tabId: string) {
if (event.button === 0) {
dispatchBeforeTabSwitch(tabId);
event.preventDefault();
}
tabDrag.startDrag(event, tabId);
}
function tabDropStyle(tabId: string) {
if (!tabDrag.state.active) return {};
if (tabDrag.state.draggedId === tabId) return { opacity: 0.4 };
@ -266,6 +268,20 @@ const tabOverflowControlClass = computed(() =>
? "h-full w-8 border-r border-border/80 dark:border-border/45 bg-background/80 text-foreground/75 hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-40"
: "h-7 w-7 rounded-md border border-border/60 bg-background text-foreground/70 hover:border-border hover:text-foreground",
);
function dispatchBeforeTabSwitch(tabId: string) {
if (tabId === queryStore.activeTabId) return;
window.dispatchEvent(
new CustomEvent("dbx:before-tab-switch", { detail: { tabId, fromTabId: queryStore.activeTabId } }),
);
}
function activateTab(tabId: string) {
dispatchBeforeTabSwitch(tabId);
tabScrollBehavior.value = "auto";
queryStore.activeTabId = tabId;
emit("close-driver-store");
}
</script>
<template>
@ -331,7 +347,7 @@ const tabOverflowControlClass = computed(() =>
@click="handleTabClick(tab)"
@dblclick.stop="startRenameTab(tab)"
@mousedown.middle.prevent="queryStore.closeTab(tab.id)"
@mousedown="tabDrag.startDrag($event, tab.id)"
@mousedown="handleTabMouseDown($event, tab.id)"
@mouseenter="tabDrag.updateTarget($event, tab.id)"
@mousemove="tabDrag.updateTarget($event, tab.id)"
@mouseleave="tabDrag.clearTarget(tab.id)"

View File

@ -1,4 +1,16 @@
import { ref, computed, nextTick, watch, onBeforeUnmount, type ComputedRef, type Ref } from "vue";
import {
ref,
computed,
nextTick,
watch,
getCurrentInstance,
onActivated,
onBeforeUnmount,
onDeactivated,
onMounted,
type ComputedRef,
type Ref,
} from "vue";
import * as api from "@/lib/api";
import { normalizeDataGridSaveError } from "@/lib/dataGridSql";
import { rowStatusFilterAfterAddingRow, type RowStatusFilter } from "@/lib/gridRowStatus";
@ -86,10 +98,41 @@ interface PendingChangesSnapshot {
newRows: CellValue[][];
dirtyRows: Map<number, Map<number, CellValue>>;
deletedRows: Set<number>;
editingCell?: { rowId: number; col: number } | null;
editValue?: string;
transactionActive?: boolean;
scroll?: { top: number; left: number };
columnCount: number;
rowCount: number;
}
const pendingChangesCache = new Map<string, PendingChangesSnapshot>();
const closingPendingSnapshotTabs = new Set<string>();
const BEFORE_TAB_SWITCH_EVENT = "dbx:before-tab-switch";
function cacheKeyBelongsToTab(cacheKey: string, tabId: string) {
return cacheKey === tabId || cacheKey.startsWith(`${tabId}-`);
}
function closedTabIdForCacheKey(cacheKey: string): string | undefined {
for (const tabId of closingPendingSnapshotTabs) {
if (cacheKeyBelongsToTab(cacheKey, tabId)) return tabId;
}
return undefined;
}
export function clearDataGridPendingSnapshotsForTab(tabId: string) {
closingPendingSnapshotTabs.add(tabId);
if (typeof window !== "undefined") {
window.setTimeout(() => closingPendingSnapshotTabs.delete(tabId), 5000);
} else {
setTimeout(() => closingPendingSnapshotTabs.delete(tabId), 5000);
}
pendingChangesCache.delete(tabId);
for (const key of pendingChangesCache.keys()) {
if (cacheKeyBelongsToTab(key, tabId)) pendingChangesCache.delete(key);
}
}
export function useDataGridEditor(options: UseDataGridEditorOptions) {
const connectionStore = useConnectionStore();
@ -123,15 +166,26 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const dirtyRows = ref<Map<number, Map<number, CellValue>>>(new Map());
const newRows = ref<CellValue[][]>([]);
const deletedRows = ref<Set<number>>(new Set());
let restoredEditingCell = false;
let restoredTransactionActive = false;
let suppressNextBlurCommit = false;
let pendingScrollRestore: PendingChangesSnapshot["scroll"] | undefined;
let saveScrollSnapshotTimer = 0;
let componentActive = true;
// Restore cached pending changes from a previous instance (e.g. after result eviction + reload)
const key = cacheKey?.value;
if (key) {
const cached = pendingChangesCache.get(key);
if (cached && cached.columnCount === result.value.columns.length) {
if (cached && cached.columnCount === result.value.columns.length && cached.rowCount === result.value.rows.length) {
newRows.value = cached.newRows;
dirtyRows.value = cached.dirtyRows;
deletedRows.value = cached.deletedRows;
editingCell.value = cached.editingCell ?? null;
editValue.value = cached.editValue ?? "";
restoredEditingCell = !!cached.editingCell;
restoredTransactionActive = cached.transactionActive === true;
pendingScrollRestore = cached.scroll;
pendingChangesCache.delete(key);
} else {
pendingChangesCache.delete(key);
@ -159,6 +213,34 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
if (hasPendingChanges.value && useTransaction.value) {
transactionActive.value = true;
}
if (restoredTransactionActive && useTransaction.value) transactionActive.value = true;
if (restoredEditingCell) {
focusEditInput();
}
function focusEditInput(select = true) {
const focusInput = () => {
if (typeof document === "undefined") return;
const root = getScrollerElement()?.closest("[data-grid-root]");
const input = (root ?? document).querySelector(".cell-edit-input") as HTMLInputElement | null;
input?.focus();
if (select && input) {
input.select();
input.setSelectionRange?.(0, input.value.length);
}
};
nextTick(() => {
focusInput();
if (typeof requestAnimationFrame === "undefined") return;
let attempts = 0;
const focusNextFrame = () => {
focusInput();
attempts += 1;
if (attempts < 3) requestAnimationFrame(focusNextFrame);
};
requestAnimationFrame(focusNextFrame);
});
}
function enterTransaction() {
transactionActive.value = true;
@ -218,6 +300,42 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
};
}
function readScrollPosition(): PendingChangesSnapshot["scroll"] | undefined {
const el = getScrollerElement();
if (!el) return undefined;
const top = Math.max(0, el.scrollTop);
const left = Math.max(0, el.scrollLeft);
if (top === 0 && left === 0) return undefined;
return { top, left };
}
function applyScrollPosition(scroll: PendingChangesSnapshot["scroll"] | undefined) {
if (!scroll) return;
const restoreScroll = () => {
const scroller = scrollerRef.value;
if (scroller && !(scroller instanceof HTMLElement)) {
scroller.scrollToPosition?.(scroll.top);
}
const el = getScrollerElement();
if (!el) return;
el.scrollTo?.({ top: scroll.top, left: scroll.left });
el.scrollTop = scroll.top;
el.scrollLeft = scroll.left;
};
restoreScrollAcrossFrames(restoreScroll);
}
function recordScrollPosition(scroll = readScrollPosition()) {
pendingScrollRestore = scroll;
const k = cacheKey?.value;
if (!k || typeof window === "undefined") return;
if (saveScrollSnapshotTimer) window.clearTimeout(saveScrollSnapshotTimer);
saveScrollSnapshotTimer = window.setTimeout(() => {
saveScrollSnapshotTimer = 0;
savePendingSnapshot(true, true);
}, 120);
}
function focusScrollerWithoutScrolling() {
const el = getScrollerElement();
if (!el) return;
@ -230,14 +348,18 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
restoreScroll();
nextTick(() => {
restoreScroll();
cancelScrollRestoreFrame = requestAnimationFrame(() => {
let attempts = 0;
const restoreNextFrame = () => {
restoreScroll();
cancelScrollRestoreFrame = requestAnimationFrame(() => {
restoreScroll();
attempts += 1;
if (attempts >= 8) {
cancelScrollRestoreFrame = 0;
isCancelling = false;
});
});
return;
}
cancelScrollRestoreFrame = requestAnimationFrame(restoreNextFrame);
};
cancelScrollRestoreFrame = requestAnimationFrame(restoreNextFrame);
});
}
@ -252,6 +374,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
function cleanupFrames() {
if (resetScrollFrame) cancelAnimationFrame(resetScrollFrame);
if (cancelScrollRestoreFrame) cancelAnimationFrame(cancelScrollRestoreFrame);
if (saveScrollSnapshotTimer) window.clearTimeout(saveScrollSnapshotTimer);
}
// --- Cell value coercion ---
@ -311,14 +434,11 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
if (!item || item.isDeleted) return;
if (!item.isNew && !canEditExistingRows.value) return;
isCancelling = false;
suppressNextBlurCommit = false;
editingCell.value = { rowId, col: colIdx };
const val = item?.data[colIdx] ?? null;
editValue.value = val === null ? "" : typeof val === "object" ? JSON.stringify(val) : String(val);
nextTick(() => {
const input = document.querySelector(".cell-edit-input") as HTMLInputElement;
input?.focus();
input?.select();
});
focusEditInput();
}
function commitEdit() {
@ -366,6 +486,14 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
editingCell.value = null;
}
function commitEditFromBlur() {
if (suppressNextBlurCommit) {
suppressNextBlurCommit = false;
return;
}
commitEdit();
}
function applyCellValue(rowId: number, col: number, value: string | null) {
if (!canEditColumn(col)) return;
const item = getRowItem(rowId);
@ -772,24 +900,78 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
watch(
() => result.value.rows,
() => {
pendingScrollRestore = undefined;
discardChanges();
},
);
// Save pending changes before the component is destroyed so they can be
// restored if a new DataGrid instance is created for the same tab
// (e.g. after result eviction + reload).
onBeforeUnmount(() => {
function savePendingSnapshot(includeEditing = false, includeScroll = false) {
const k = cacheKey?.value;
if (k && hasPendingChanges.value) {
pendingChangesCache.set(k, {
newRows: newRows.value.map((r) => [...r]),
dirtyRows: new Map([...dirtyRows.value].map(([i, m]) => [i, new Map(m)])),
deletedRows: new Set(deletedRows.value),
columnCount: result.value.columns.length,
});
if (!k) return;
if (closedTabIdForCacheKey(k)) {
pendingChangesCache.delete(k);
return;
}
});
const scroll = includeScroll ? (readScrollPosition() ?? pendingScrollRestore) : undefined;
if (includeScroll) pendingScrollRestore = scroll;
if (!hasPendingChanges.value && !(includeEditing && editingCell.value) && !scroll) {
pendingChangesCache.delete(k);
return;
}
pendingChangesCache.set(k, {
newRows: newRows.value.map((r) => [...r]),
dirtyRows: new Map([...dirtyRows.value].map(([i, m]) => [i, new Map(m)])),
deletedRows: new Set(deletedRows.value),
editingCell: includeEditing && editingCell.value ? { ...editingCell.value } : null,
editValue: editValue.value,
transactionActive: transactionActive.value,
scroll,
columnCount: result.value.columns.length,
rowCount: result.value.rows.length,
});
}
function restorePendingSnapshotFocus() {
suppressNextBlurCommit = false;
if (editingCell.value) focusEditInput(true);
applyScrollPosition(pendingScrollRestore);
}
function onBeforeTabSwitch() {
if (!componentActive) return;
savePendingSnapshot(true, true);
if (editingCell.value) suppressNextBlurCommit = true;
}
const componentInstance = getCurrentInstance();
if (componentInstance && typeof window !== "undefined") {
window.addEventListener(BEFORE_TAB_SWITCH_EVENT, onBeforeTabSwitch);
}
if (componentInstance) {
onMounted(() => {
componentActive = true;
applyScrollPosition(pendingScrollRestore);
});
onActivated(() => {
componentActive = true;
restorePendingSnapshotFocus();
});
onDeactivated(() => {
savePendingSnapshot(true, true);
componentActive = false;
});
// Save pending changes before the component is destroyed so they can be
// restored if a new DataGrid instance is created for the same tab
// (e.g. after result eviction + reload).
onBeforeUnmount(() => {
savePendingSnapshot(true, true);
if (typeof window !== "undefined") {
window.removeEventListener(BEFORE_TAB_SWITCH_EVENT, onBeforeTabSwitch);
}
});
}
return {
editingCell,
@ -811,6 +993,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
exitTransaction,
startEdit,
commitEdit,
commitEditFromBlur,
applyCellValue,
cancelEdit,
onEditKeydown,
@ -836,6 +1019,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
getResetScrollAfterResult,
clearResetScrollAfterResult,
cleanupFrames,
recordScrollPosition,
syncHeaderScroll: (headerRef: Ref<HTMLDivElement | undefined>) => (e: Event) => {
if (headerRef.value) {
headerRef.value.scrollLeft = (e.target as HTMLElement).scrollLeft;

View File

@ -25,6 +25,8 @@ export interface SavedOpenTab {
objectBrowser?: QueryTab["objectBrowser"];
objectSource?: QueryTab["objectSource"];
tableMeta?: QueryTab["tableMeta"];
resultEvicted?: boolean;
resultCacheKey?: string;
}
export interface RestoredOpenTabs {
@ -58,6 +60,10 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
objectBrowser: tab.objectBrowser,
objectSource: tab.objectSource,
tableMeta: tab.tableMeta,
...(tab.mode !== "data" && tab.resultEvicted ? { resultEvicted: true } : {}),
...(tab.mode !== "data" && tab.resultEvicted && tab.resultCacheKey !== undefined
? { resultCacheKey: tab.resultCacheKey }
: {}),
}));
}
@ -86,13 +92,19 @@ export function restoreOpenTabsState(
const saved = parsed.filter(isSavedOpenTab);
const filtered = options.queryOnly ? saved.filter((tab) => (tab.mode ?? "query") === "query") : saved;
const tabs: QueryTab[] = filtered.map((tab) => ({
...tab,
mode: tab.mode ?? "query",
isExecuting: false,
isCancelling: false,
isExplaining: false,
}));
const tabs: QueryTab[] = filtered.map((tab) => {
const mode = tab.mode ?? "query";
return {
...tab,
mode,
isExecuting: false,
isCancelling: false,
isExplaining: false,
resultEvicted: mode === "data" ? undefined : tab.resultEvicted,
resultCacheKey: mode === "data" ? undefined : tab.resultCacheKey,
resultCacheState: mode !== "data" && tab.resultCacheKey ? "disk" : undefined,
};
});
const activeTabId = rawActiveTabId || null;
return {

View File

@ -0,0 +1,417 @@
import type { QueryResult, QueryTab } from "@/types/database";
import { decode, encode } from "@msgpack/msgpack";
import { toRaw } from "vue";
import { isTauriRuntime } from "@/lib/tauriRuntime";
const DB_NAME = "dbx-tab-runtime-cache";
const DB_VERSION = 1;
const RESULT_STORE = "resultSnapshots";
const PAYLOAD_MAGIC = "DBX_TAB_RESULT_CACHE";
const PAYLOAD_VERSION = 1;
const PAYLOAD_CODEC = "msgpack-columnar";
type CellValue = QueryResult["rows"][number][number];
export interface TabResultSnapshot {
result?: QueryResult;
results?: QueryResult[];
activeResultIndex?: number;
queryAnalysis?: QueryTab["queryAnalysis"];
querySourceColumns?: QueryTab["querySourceColumns"];
queryEditabilityReason?: QueryTab["queryEditabilityReason"];
tableMeta?: QueryTab["tableMeta"];
resultPageSql?: string;
resultPageLimit?: number;
resultPageOffset?: number;
resultCountSql?: string;
resultTotalRowCount?: number;
cachedAt: number;
}
interface ColumnarQueryResult {
columns: string[];
columnValues: CellValue[][];
rowCount: number;
affected_rows: number;
execution_time_ms: number;
truncated?: boolean;
has_more?: boolean;
}
interface TabResultSnapshotPayload extends Omit<TabResultSnapshot, "result" | "results"> {
result?: ColumnarQueryResult;
results?: ColumnarQueryResult[];
}
interface TabResultCacheEnvelope {
magic: typeof PAYLOAD_MAGIC;
version: typeof PAYLOAD_VERSION;
codec: typeof PAYLOAD_CODEC;
cachedAt: number;
rowCount: number;
columnCount: number;
payload: TabResultSnapshotPayload;
}
function indexedDb(): IDBFactory | undefined {
return typeof globalThis.indexedDB === "undefined" ? undefined : globalThis.indexedDB;
}
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed"));
});
}
let dbPromise: Promise<IDBDatabase | null> | undefined;
const cacheKeyVersions = new Map<string, number>();
function bumpCacheKeyVersion(key: string): number {
const version = (cacheKeyVersions.get(key) ?? 0) + 1;
cacheKeyVersions.set(key, version);
return version;
}
function isCurrentCacheKeyVersion(key: string, version: number): boolean {
return cacheKeyVersions.get(key) === version;
}
function clearCacheKeyVersionIfCurrent(key: string, version: number) {
if (isCurrentCacheKeyVersion(key, version)) cacheKeyVersions.delete(key);
}
function openCacheDb(): Promise<IDBDatabase | null> {
if (dbPromise) return dbPromise;
const idb = indexedDb();
if (!idb) return Promise.resolve(null);
dbPromise = new Promise((resolve) => {
const request = idb.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(RESULT_STORE)) db.createObjectStore(RESULT_STORE);
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => {
console.warn("[DBX][tab-result-cache:open:error]", request.error);
resolve(null);
};
request.onblocked = () => resolve(null);
});
return dbPromise;
}
function clonePlain<T>(value: T): T {
const raw = toRaw(value);
if (typeof structuredClone === "function") return structuredClone(raw);
return JSON.parse(JSON.stringify(raw)) as T;
}
function stripSessionIds(result: QueryResult | undefined): QueryResult | undefined {
if (!result) return undefined;
return {
columns: [...result.columns],
rows: result.rows.map((row) => [...row]),
affected_rows: result.affected_rows,
execution_time_ms: result.execution_time_ms,
truncated: result.truncated,
session_id: undefined,
has_more: result.has_more,
};
}
function stripResultSessionIds(results: QueryResult[] | undefined): QueryResult[] | undefined {
return results?.map((result) => stripSessionIds(result)!);
}
function toColumnarResult(result: QueryResult | undefined): ColumnarQueryResult | undefined {
if (!result) return undefined;
const columnValues = result.columns.map((_, colIndex) => result.rows.map((row) => row[colIndex] ?? null));
return removeUndefinedFields({
columns: [...result.columns],
columnValues,
rowCount: result.rows.length,
affected_rows: result.affected_rows,
execution_time_ms: result.execution_time_ms,
truncated: result.truncated,
has_more: result.has_more,
});
}
function fromColumnarResult(result: ColumnarQueryResult | undefined): QueryResult | undefined {
if (!result) return undefined;
const rows = Array.from({ length: result.rowCount }, (_, rowIndex) =>
result.columnValues.map((values) => values[rowIndex] ?? null),
);
return {
columns: [...result.columns],
rows,
affected_rows: result.affected_rows,
execution_time_ms: result.execution_time_ms,
truncated: result.truncated,
session_id: undefined,
has_more: result.has_more,
};
}
function snapshotToPayload(snapshot: TabResultSnapshot): TabResultSnapshotPayload {
return removeUndefinedFields({
...snapshot,
result: toColumnarResult(snapshot.result),
results: snapshot.results?.map((result) => toColumnarResult(result)!),
});
}
function payloadToSnapshot(payload: TabResultSnapshotPayload): TabResultSnapshot {
return {
...payload,
result: fromColumnarResult(payload.result),
results: payload.results?.map((result) => fromColumnarResult(result)!),
};
}
function resultStats(snapshot: TabResultSnapshot): { rowCount: number; columnCount: number } {
const result = snapshot.result ?? snapshot.results?.[snapshot.activeResultIndex ?? 0] ?? snapshot.results?.[0];
return {
rowCount: result?.rows.length ?? 0,
columnCount: result?.columns.length ?? 0,
};
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
}
return btoa(binary);
}
function base64ToBytes(value: string): Uint8Array {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return bytes;
}
function canUseRemoteRuntimeCache(): boolean {
return (
typeof btoa !== "undefined" && typeof atob !== "undefined" && (isTauriRuntime() || typeof fetch !== "undefined")
);
}
async function writeRemoteRuntimeCache(
key: string,
bytes: Uint8Array,
stats: { rowCount: number; columnCount: number },
): Promise<boolean> {
if (!canUseRemoteRuntimeCache()) return false;
try {
if (isTauriRuntime()) {
const { invoke } = await import("@tauri-apps/api/core");
await invoke("save_tab_runtime_cache", {
key,
payloadBase64: bytesToBase64(bytes),
rowCount: stats.rowCount,
columnCount: stats.columnCount,
});
return true;
}
const response = await fetch("/api/tab-runtime-cache", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key,
payloadBase64: bytesToBase64(bytes),
rowCount: stats.rowCount,
columnCount: stats.columnCount,
}),
});
return response.ok;
} catch (error) {
console.warn("[DBX][tab-result-cache:remote-write:error]", { key, error });
return false;
}
}
async function readRemoteRuntimeCache(key: string): Promise<Uint8Array | undefined> {
if (!canUseRemoteRuntimeCache()) return undefined;
try {
if (isTauriRuntime()) {
const { invoke } = await import("@tauri-apps/api/core");
const entry = await invoke<{ payloadBase64?: string } | null>("load_tab_runtime_cache", { key });
return entry?.payloadBase64 ? base64ToBytes(entry.payloadBase64) : undefined;
}
const response = await fetch(`/api/tab-runtime-cache?key=${encodeURIComponent(key)}`);
if (!response.ok) return undefined;
const entry = (await response.json()) as { payloadBase64?: string } | null;
return entry?.payloadBase64 ? base64ToBytes(entry.payloadBase64) : undefined;
} catch (error) {
console.warn("[DBX][tab-result-cache:remote-read:error]", { key, error });
return undefined;
}
}
async function deleteRemoteRuntimeCache(key: string): Promise<void> {
if (!canUseRemoteRuntimeCache()) return;
try {
if (isTauriRuntime()) {
const { invoke } = await import("@tauri-apps/api/core");
await invoke("delete_tab_runtime_cache", { key });
return;
}
await fetch(`/api/tab-runtime-cache?key=${encodeURIComponent(key)}`, { method: "DELETE" });
} catch (error) {
console.warn("[DBX][tab-result-cache:remote-delete:error]", { key, error });
}
}
function scheduleRemoteRuntimeCacheWrite(
key: string,
bytes: Uint8Array,
stats: { rowCount: number; columnCount: number },
version: number,
) {
window.setTimeout(async () => {
if (!isCurrentCacheKeyVersion(key, version)) return;
await writeRemoteRuntimeCache(key, bytes, stats);
clearCacheKeyVersionIfCurrent(key, version);
}, 0);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function removeUndefinedFields<T>(value: T): T {
if (Array.isArray(value)) return value.map((item) => removeUndefinedFields(item)) as T;
if (!isRecord(value)) return value;
return Object.fromEntries(
Object.entries(value)
.filter(([, entryValue]) => entryValue !== undefined)
.map(([key, entryValue]) => [key, removeUndefinedFields(entryValue)]),
) as T;
}
function isBinaryPayload(value: unknown): value is Uint8Array {
return value instanceof Uint8Array || value instanceof ArrayBuffer;
}
export function encodeTabResultSnapshot(snapshot: TabResultSnapshot): Uint8Array {
const stats = resultStats(snapshot);
const envelope: TabResultCacheEnvelope = {
magic: PAYLOAD_MAGIC,
version: PAYLOAD_VERSION,
codec: PAYLOAD_CODEC,
cachedAt: snapshot.cachedAt,
rowCount: stats.rowCount,
columnCount: stats.columnCount,
payload: snapshotToPayload(snapshot),
};
return encode(removeUndefinedFields(envelope));
}
export function decodeTabResultSnapshot(bytes: Uint8Array | ArrayBuffer): TabResultSnapshot | undefined {
const decoded = decode(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
if (!isRecord(decoded)) return undefined;
if (decoded.magic !== PAYLOAD_MAGIC || decoded.version !== PAYLOAD_VERSION || decoded.codec !== PAYLOAD_CODEC) {
return undefined;
}
if (!isRecord(decoded.payload)) return undefined;
return payloadToSnapshot(decoded.payload as unknown as TabResultSnapshotPayload);
}
export function tabResultCacheKey(tabId: string): string {
return `tab:${tabId}:result`;
}
export function buildTabResultSnapshot(tab: QueryTab): TabResultSnapshot | undefined {
if (!tab.result && !tab.results) return undefined;
return {
result: stripSessionIds(tab.result),
results: stripResultSessionIds(tab.results),
activeResultIndex: tab.activeResultIndex,
queryAnalysis: tab.queryAnalysis ? clonePlain(tab.queryAnalysis) : undefined,
querySourceColumns: tab.querySourceColumns ? [...tab.querySourceColumns] : undefined,
queryEditabilityReason: tab.queryEditabilityReason,
tableMeta: tab.tableMeta ? clonePlain(tab.tableMeta) : undefined,
resultPageSql: tab.resultPageSql,
resultPageLimit: tab.resultPageLimit,
resultPageOffset: tab.resultPageOffset,
resultCountSql: tab.resultCountSql,
resultTotalRowCount: tab.resultTotalRowCount,
cachedAt: Date.now(),
};
}
export async function writeTabResultSnapshot(key: string, snapshot: TabResultSnapshot | undefined): Promise<boolean> {
if (!snapshot) return false;
const version = bumpCacheKeyVersion(key);
const encoded = encodeTabResultSnapshot(snapshot);
const stats = resultStats(snapshot);
let wroteLocal = false;
try {
const db = await openCacheDb();
if (db) {
const tx = db.transaction(RESULT_STORE, "readwrite");
await requestToPromise(tx.objectStore(RESULT_STORE).put(encoded, key));
wroteLocal = true;
}
} catch (error) {
console.warn("[DBX][tab-result-cache:write:error]", { key, error });
}
if (wroteLocal) {
if (typeof window === "undefined") {
void writeRemoteRuntimeCache(key, encoded, stats).finally(() => clearCacheKeyVersionIfCurrent(key, version));
} else {
scheduleRemoteRuntimeCacheWrite(key, encoded, stats, version);
}
return true;
}
if (!isCurrentCacheKeyVersion(key, version)) return false;
try {
return await writeRemoteRuntimeCache(key, encoded, stats);
} finally {
clearCacheKeyVersionIfCurrent(key, version);
}
}
export async function readTabResultSnapshot(key: string): Promise<TabResultSnapshot | undefined> {
try {
const db = await openCacheDb();
if (db) {
const value = await requestToPromise(db.transaction(RESULT_STORE, "readonly").objectStore(RESULT_STORE).get(key));
if (isBinaryPayload(value)) return decodeTabResultSnapshot(value);
if (value) return value as TabResultSnapshot;
}
const remoteBytes = await readRemoteRuntimeCache(key);
const remoteSnapshot = remoteBytes ? decodeTabResultSnapshot(remoteBytes) : undefined;
if (remoteBytes && remoteSnapshot && db) {
void requestToPromise(db.transaction(RESULT_STORE, "readwrite").objectStore(RESULT_STORE).put(remoteBytes, key));
}
return remoteSnapshot;
} catch (error) {
console.warn("[DBX][tab-result-cache:read:error]", { key, error });
const remoteBytes = await readRemoteRuntimeCache(key);
return remoteBytes ? decodeTabResultSnapshot(remoteBytes) : undefined;
}
}
export async function deleteTabResultSnapshot(key: string): Promise<void> {
const version = bumpCacheKeyVersion(key);
const clearVersionLater = () => {
const cleanup = () => clearCacheKeyVersionIfCurrent(key, version);
if (typeof window !== "undefined") window.setTimeout(cleanup, 5000);
else setTimeout(cleanup, 5000);
};
try {
const db = await openCacheDb();
if (db) await requestToPromise(db.transaction(RESULT_STORE, "readwrite").objectStore(RESULT_STORE).delete(key));
await deleteRemoteRuntimeCache(key);
clearVersionLater();
} catch (error) {
console.warn("[DBX][tab-result-cache:delete:error]", { key, error });
await deleteRemoteRuntimeCache(key);
clearVersionLater();
}
}

View File

@ -32,6 +32,14 @@ import { tableMetaForDataTab } from "@/lib/tableDataTabMeta";
import { quoteTableIdentifier } from "@/lib/tableSelectSql";
import { connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
import { queryTimeoutSecsForConnection } from "@/lib/queryTimeout";
import { clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor";
import {
buildTabResultSnapshot,
deleteTabResultSnapshot,
readTabResultSnapshot,
tabResultCacheKey,
writeTabResultSnapshot,
} from "@/lib/tabResultCache";
import * as api from "@/lib/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
@ -118,6 +126,9 @@ export const useQueryStore = defineStore("query", () => {
const restored = loadSavedTabs();
const tabs = ref<QueryTab[]>(restored.tabs);
const activeTabId = ref<string | null>(restored.activeTabId);
for (const tab of restored.tabs) {
if (tab.mode === "data") void deleteTabResultSnapshot(tabResultCacheKey(tab.id));
}
const tableStructureRefreshVersions = ref<Record<string, number>>({});
function tableStructureKey(
@ -176,7 +187,11 @@ export const useQueryStore = defineStore("query", () => {
}
function touchResult(tab: QueryTab | undefined, accessedAt = Date.now()) {
if (tab?.result || tab?.results) tab.resultAccessedAt = accessedAt;
if (tab?.result || tab?.results) {
tab.resultAccessedAt = accessedAt;
tab.resultCacheState = "memory";
tab.resultEvicted = undefined;
}
}
function clearResultPayload(tab: QueryTab, options: { evicted?: boolean } = {}) {
@ -190,10 +205,19 @@ export const useQueryStore = defineStore("query", () => {
tab.queryEditabilityReason = undefined;
if (tab.mode === "query") tab.tableMeta = undefined;
tab.resultEvicted = options.evicted ? true : undefined;
tab.resultCacheState = options.evicted ? tab.resultCacheState : undefined;
if (!options.evicted) {
if (tab.resultCacheKey) void deleteTabResultSnapshot(tab.resultCacheKey);
tab.resultCacheKey = undefined;
}
}
async function evictCachedResult(tab: QueryTab) {
await closeResultSession(tab);
const cacheKey = tabResultCacheKey(tab.id);
const cached = await writeTabResultSnapshot(cacheKey, buildTabResultSnapshot(tab));
tab.resultCacheKey = cached ? cacheKey : undefined;
tab.resultCacheState = cached ? "disk" : "missing";
clearResultPayload(tab, { evicted: true });
}
@ -222,6 +246,8 @@ export const useQueryStore = defineStore("query", () => {
objectBrowser: t.objectBrowser,
objectSource: t.objectSource,
tableMeta: t.tableMeta,
resultEvicted: t.resultEvicted,
resultCacheKey: t.resultCacheKey,
})),
);
@ -366,6 +392,7 @@ export const useQueryStore = defineStore("query", () => {
function closeTab(id: string) {
const idx = tabs.value.findIndex((t) => t.id === id);
if (idx < 0) return;
clearDataGridPendingSnapshotsForTab(id);
if (tabs.value[idx].isExecuting) void cancelTabExecution(id);
if (tabs.value[idx].isExplaining) void cancelTabExplain(id);
void closeResultSession(tabs.value[idx]);
@ -381,6 +408,7 @@ export const useQueryStore = defineStore("query", () => {
tabs.value
.filter((tab) => tab.id !== id)
.forEach((tab) => {
clearDataGridPendingSnapshotsForTab(tab.id);
if (tab.isExecuting) void cancelTabExecution(tab.id);
if (tab.isExplaining) void cancelTabExplain(tab.id);
void closeResultSession(tab);
@ -394,6 +422,7 @@ export const useQueryStore = defineStore("query", () => {
function closeAllTabs() {
tabs.value.forEach((tab) => {
clearDataGridPendingSnapshotsForTab(tab.id);
if (tab.isExecuting) void cancelTabExecution(tab.id);
if (tab.isExplaining) void cancelTabExplain(tab.id);
void closeResultSession(tab);
@ -412,6 +441,7 @@ export const useQueryStore = defineStore("query", () => {
tabs.value
.filter((tab) => closingIds.has(tab.id))
.forEach((tab) => {
clearDataGridPendingSnapshotsForTab(tab.id);
if (tab.isExecuting) void cancelTabExecution(tab.id);
if (tab.isExplaining) void cancelTabExplain(tab.id);
void closeResultSession(tab);
@ -1381,10 +1411,44 @@ export const useQueryStore = defineStore("query", () => {
touchResult(tabs.value.find((tab) => tab.id === id));
});
function restoreCachedResultPayload(tab: QueryTab, snapshot: Awaited<ReturnType<typeof readTabResultSnapshot>>) {
if (!snapshot) return false;
const results = snapshot.results ? markQueryResultsRowsRaw(snapshot.results) : undefined;
const activeIndex = snapshot.activeResultIndex ?? 0;
tab.results = results;
tab.activeResultIndex = snapshot.activeResultIndex;
tab.result = snapshot.result
? markQueryResultRowsRaw(snapshot.result)
: results?.[activeIndex]
? markQueryResultRowsRaw(results[activeIndex])
: undefined;
if (!tab.result && !tab.results) return false;
tab.queryAnalysis = snapshot.queryAnalysis;
tab.querySourceColumns = snapshot.querySourceColumns;
tab.queryEditabilityReason = snapshot.queryEditabilityReason;
tab.tableMeta = snapshot.tableMeta;
tab.resultPageSql = snapshot.resultPageSql;
tab.resultPageLimit = snapshot.resultPageLimit;
tab.resultPageOffset = snapshot.resultPageOffset;
tab.resultCountSql = snapshot.resultCountSql;
tab.resultTotalRowCount = snapshot.resultTotalRowCount;
tab.resultTotalRowCountLoading = false;
tab.resultSessionId = undefined;
tab.resultEvicted = undefined;
tab.resultCacheState = "memory";
touchResult(tab);
return true;
}
async function reloadEvictedTab(id: string) {
const tab = tabs.value.find((t) => t.id === id);
const shouldReloadMissingDataTab = tab?.mode === "data" && !tab.result && !tab.isExecuting;
if (!tab || (!tab.resultEvicted && !shouldReloadMissingDataTab)) return;
if (!tab || !tab.resultEvicted) return;
if (tab.resultCacheKey) {
const restored = restoreCachedResultPayload(tab, await readTabResultSnapshot(tab.resultCacheKey));
if (restored) return;
tab.resultCacheState = "missing";
}
tab.resultEvicted = false;
const sql = tab.lastExecutedSql ?? tab.sql;
if (!sql?.trim()) return;

View File

@ -363,6 +363,8 @@ export interface QueryTab {
resultTotalRowCountLoading?: boolean;
resultSessionId?: string;
resultAccessedAt?: number;
resultCacheKey?: string;
resultCacheState?: "memory" | "disk" | "missing";
pinned?: boolean;
result?: QueryResult;
results?: QueryResult[];

View File

@ -17,6 +17,16 @@ pub struct Storage {
db: SqliteHandle,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TabRuntimeCacheEntry {
pub key: String,
pub payload: Vec<u8>,
pub row_count: i64,
pub column_count: i64,
pub byte_size: i64,
pub updated_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DesktopSettings {
pub show_tray_icon: bool,
@ -99,6 +109,14 @@ const SCHEMA_STATEMENTS: &[&str] = &[
payload_json TEXT NOT NULL,
updated_at TEXT NOT NULL
)",
"CREATE TABLE IF NOT EXISTS tab_runtime_cache (
cache_key TEXT PRIMARY KEY,
payload BLOB NOT NULL,
row_count INTEGER NOT NULL DEFAULT 0,
column_count INTEGER NOT NULL DEFAULT 0,
byte_size INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
)",
"CREATE TABLE IF NOT EXISTS saved_sql_folders (
id TEXT PRIMARY KEY,
connection_id TEXT NOT NULL,
@ -953,6 +971,69 @@ impl Storage {
}
}
// Tab runtime cache
impl Storage {
pub async fn save_tab_runtime_cache(
&self,
key: &str,
payload: Vec<u8>,
row_count: i64,
column_count: i64,
) -> Result<(), String> {
let key = key.to_string();
let byte_size = payload.len() as i64;
self.with_conn(move |conn| {
conn.execute(
"INSERT INTO tab_runtime_cache \
(cache_key, payload, row_count, column_count, byte_size, updated_at) \
VALUES (?1, ?2, ?3, ?4, ?5, datetime('now')) \
ON CONFLICT(cache_key) DO UPDATE SET \
payload = excluded.payload, row_count = excluded.row_count, column_count = excluded.column_count, \
byte_size = excluded.byte_size, updated_at = excluded.updated_at",
params![key, payload, row_count, column_count, byte_size],
)
.map(|_| ())
.map_err(|e| e.to_string())
})
.await
}
pub async fn load_tab_runtime_cache(&self, key: &str) -> Result<Option<TabRuntimeCacheEntry>, String> {
let key = key.to_string();
self.with_conn(move |conn| {
conn.query_row(
"SELECT cache_key, payload, row_count, column_count, byte_size, updated_at \
FROM tab_runtime_cache WHERE cache_key = ?1",
[key],
|row| {
Ok(TabRuntimeCacheEntry {
key: row.get(0)?,
payload: row.get(1)?,
row_count: row.get(2)?,
column_count: row.get(3)?,
byte_size: row.get(4)?,
updated_at: row.get(5)?,
})
},
)
.optional()
.map_err(|e| e.to_string())
})
.await
}
pub async fn delete_tab_runtime_cache(&self, key: &str) -> Result<(), String> {
let key = key.to_string();
self.with_conn(move |conn| {
conn.execute("DELETE FROM tab_runtime_cache WHERE cache_key = ?1", [key])
.map(|_| ())
.map_err(|e| e.to_string())
})
.await
}
}
// JSON migration
impl Storage {
@ -1248,4 +1329,22 @@ mod tests {
);
assert_eq!(storage.load_password_hash().await.unwrap(), Some("hash-3".to_string()));
}
#[tokio::test]
async fn tab_runtime_cache_roundtrips_binary_payloads() {
let path = temp_db_path("tab-runtime-cache");
let storage = Storage::open(&path).await.unwrap();
storage.save_tab_runtime_cache("tab:1:result", vec![1, 2, 3, 4], 10, 3).await.unwrap();
let entry = storage.load_tab_runtime_cache("tab:1:result").await.unwrap().unwrap();
assert_eq!(entry.key, "tab:1:result");
assert_eq!(entry.payload, vec![1, 2, 3, 4]);
assert_eq!(entry.row_count, 10);
assert_eq!(entry.column_count, 3);
assert_eq!(entry.byte_size, 4);
storage.delete_tab_runtime_cache("tab:1:result").await.unwrap();
assert_eq!(storage.load_tab_runtime_cache("tab:1:result").await.unwrap(), None);
}
}

View File

@ -147,6 +147,12 @@ async fn main() {
post(routes::schema_cache::save_schema_cache).get(routes::schema_cache::load_schema_cache),
)
.route("/schema/cache-prefix", delete(routes::schema_cache::delete_schema_cache_prefix))
.route(
"/tab-runtime-cache",
post(routes::tab_runtime_cache::save_tab_runtime_cache)
.get(routes::tab_runtime_cache::load_tab_runtime_cache)
.delete(routes::tab_runtime_cache::delete_tab_runtime_cache),
)
// Query
.route("/query/execute", post(routes::query::execute_query))
.route("/query/execute-multi", post(routes::query::execute_multi))

View File

@ -16,6 +16,7 @@ pub mod schema;
pub mod schema_cache;
pub mod schema_diff;
pub mod sql_file;
pub mod tab_runtime_cache;
pub mod table_export;
pub mod table_import;
pub mod text_export;

View File

@ -0,0 +1,71 @@
use std::sync::Arc;
use axum::extract::{Query, State};
use axum::Json;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use serde::{Deserialize, Serialize};
use crate::error::AppError;
use crate::state::WebState;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveTabRuntimeCacheRequest {
pub key: String,
pub payload_base64: String,
pub row_count: i64,
pub column_count: i64,
}
#[derive(Deserialize)]
pub struct TabRuntimeCacheKeyQuery {
pub key: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LoadTabRuntimeCacheResponse {
pub key: String,
pub payload_base64: String,
pub row_count: i64,
pub column_count: i64,
pub byte_size: i64,
pub updated_at: String,
}
pub async fn save_tab_runtime_cache(
State(state): State<Arc<WebState>>,
Json(req): Json<SaveTabRuntimeCacheRequest>,
) -> Result<Json<()>, AppError> {
let payload = BASE64.decode(req.payload_base64).map_err(|e| AppError::bad_request(e.to_string()))?;
state
.app
.storage
.save_tab_runtime_cache(&req.key, payload, req.row_count, req.column_count)
.await
.map_err(AppError::internal)?;
Ok(Json(()))
}
pub async fn load_tab_runtime_cache(
State(state): State<Arc<WebState>>,
Query(query): Query<TabRuntimeCacheKeyQuery>,
) -> Result<Json<Option<LoadTabRuntimeCacheResponse>>, AppError> {
let entry = state.app.storage.load_tab_runtime_cache(&query.key).await.map_err(AppError::internal)?;
Ok(Json(entry.map(|entry| LoadTabRuntimeCacheResponse {
key: entry.key,
payload_base64: BASE64.encode(entry.payload),
row_count: entry.row_count,
column_count: entry.column_count,
byte_size: entry.byte_size,
updated_at: entry.updated_at,
})))
}
pub async fn delete_tab_runtime_cache(
State(state): State<Arc<WebState>>,
Query(query): Query<TabRuntimeCacheKeyQuery>,
) -> Result<Json<()>, AppError> {
state.app.storage.delete_tab_runtime_cache(&query.key).await.map_err(AppError::internal)?;
Ok(Json(()))
}

View File

@ -39,6 +39,7 @@
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.0",
"@lucide/vue": "1.17.0",
"@msgpack/msgpack": "^3.1.3",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.1",

View File

@ -97,6 +97,51 @@ test("serializes table tabs with reload context", () => {
);
});
test("serializes evicted result cache handles", () => {
const saved = serializeOpenTabs([
queryTab({
resultEvicted: true,
resultCacheKey: "tab:tab-1:result",
}),
]);
assert.equal(saved[0]?.resultEvicted, true);
assert.equal(saved[0]?.resultCacheKey, "tab:tab-1:result");
});
test("does not persist table data result cache handles across restarts", () => {
const saved = serializeOpenTabs([
queryTab({
mode: "data",
resultEvicted: true,
resultCacheKey: "tab:tab-1:result",
}),
]);
assert.equal(saved[0]?.resultEvicted, undefined);
assert.equal(saved[0]?.resultCacheKey, undefined);
});
test("restores evicted result cache handles as disk-backed runtime state", () => {
const raw = JSON.stringify([queryTab({ resultEvicted: true, resultCacheKey: "tab:tab-1:result" })]);
const restored = restoreOpenTabsState(raw, "tab-1");
assert.equal(restored.tabs[0]?.resultEvicted, true);
assert.equal(restored.tabs[0]?.resultCacheKey, "tab:tab-1:result");
assert.equal(restored.tabs[0]?.resultCacheState, "disk");
});
test("ignores legacy table data result cache handles on restore", () => {
const raw = JSON.stringify([queryTab({ mode: "data", resultEvicted: true, resultCacheKey: "tab:tab-1:result" })]);
const restored = restoreOpenTabsState(raw, "tab-1");
assert.equal(restored.tabs[0]?.resultEvicted, undefined);
assert.equal(restored.tabs[0]?.resultCacheKey, undefined);
assert.equal(restored.tabs[0]?.resultCacheState, undefined);
});
test("restores unsaved query tabs and active tab after restart", () => {
const raw = JSON.stringify([
queryTab({ id: "tab-1", sql: "select 1" }),

View File

@ -831,7 +831,7 @@ test("data tab execution preserves pagination offset metadata", async () => {
}
});
test("reloading an empty data tab fetches table results again", async () => {
test("activating an empty data tab waits for explicit execution", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
@ -863,10 +863,8 @@ test("reloading an empty data tab fetches table results again", async () => {
try {
await store.reloadEvictedTab(tabId);
assert.equal(executeBody.sql, 'SELECT * FROM "public"."users" LIMIT 50 OFFSET 50;');
assert.equal(executeBody.maxRows, 50);
assert.equal(executeBody.fetchSize, 50);
assert.deepEqual(tab.result?.rows, [[51]]);
assert.equal(executeBody, undefined);
assert.equal(tab.result, undefined);
assert.equal(tab.resultPageLimit, 50);
assert.equal(tab.resultPageOffset, 50);
} finally {

View File

@ -0,0 +1,83 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import {
buildTabResultSnapshot,
decodeTabResultSnapshot,
encodeTabResultSnapshot,
} from "../../apps/desktop/src/lib/tabResultCache.ts";
import type { QueryTab } from "../../apps/desktop/src/types/database.ts";
function queryTab(overrides: Partial<QueryTab> = {}): QueryTab {
return {
id: "tab-1",
title: "Query 1",
connectionId: "conn-1",
database: "app",
sql: "select * from users",
isExecuting: false,
mode: "query",
...overrides,
};
}
test("result snapshots strip live session handles and clone result rows", () => {
const tab = queryTab({
result: {
columns: ["id"],
rows: [[1]],
affected_rows: 0,
execution_time_ms: 1,
session_id: "live-session",
},
results: [
{
columns: ["id"],
rows: [[1]],
affected_rows: 0,
execution_time_ms: 1,
session_id: "live-session",
},
],
activeResultIndex: 0,
});
const snapshot = buildTabResultSnapshot(tab);
assert.equal(snapshot?.result?.session_id, undefined);
assert.equal(snapshot?.results?.[0]?.session_id, undefined);
assert.deepEqual(snapshot?.result?.rows, [[1]]);
tab.result!.rows[0]![0] = 2;
assert.deepEqual(snapshot?.result?.rows, [[1]]);
});
test("result snapshots encode as binary columnar payloads and decode back to rows", () => {
const snapshot = buildTabResultSnapshot(
queryTab({
result: {
columns: ["id", "name", "active"],
rows: [
[1, "Ada", true],
[2, "Linus", false],
],
affected_rows: 0,
execution_time_ms: 3,
session_id: "live-session",
has_more: true,
},
}),
);
assert.ok(snapshot);
const encoded = encodeTabResultSnapshot(snapshot);
const decoded = decodeTabResultSnapshot(encoded);
assert.ok(encoded instanceof Uint8Array);
assert.deepEqual(decoded?.result?.columns, ["id", "name", "active"]);
assert.deepEqual(decoded?.result?.rows, [
[1, "Ada", true],
[2, "Linus", false],
]);
assert.equal(decoded?.result?.session_id, undefined);
assert.equal(decoded?.result?.has_more, true);
assert.equal(decoded?.cachedAt, snapshot.cachedAt);
});

View File

@ -41,6 +41,9 @@ importers:
'@lucide/vue':
specifier: 1.17.0
version: 1.17.0(vue@3.5.35(typescript@6.0.3))
'@msgpack/msgpack':
specifier: ^3.1.3
version: 3.1.3
'@tauri-apps/api':
specifier: ^2.11.0
version: 2.11.0
@ -911,6 +914,10 @@ packages:
'@cfworker/json-schema':
optional: true
'@msgpack/msgpack@3.1.3':
resolution: {integrity: sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==}
engines: {node: '>= 18'}
'@napi-rs/wasm-runtime@1.1.4':
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
peerDependencies:
@ -4393,6 +4400,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@msgpack/msgpack@3.1.3': {}
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0

View File

@ -18,6 +18,7 @@ tauri-build = { version = "2.5.6", features = [] }
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
base64 = "0.22"
log = "0.4"
tauri = { version = "2.10.3", features = ["tray-icon"] }
tauri-plugin-log = "2"

View File

@ -25,6 +25,7 @@ pub mod schema_cache;
pub mod schema_diff;
pub mod sql_file;
pub mod system_fonts;
pub mod tab_runtime_cache;
pub mod table_export;
pub mod table_import;
pub mod text_export;

View File

@ -0,0 +1,50 @@
use std::sync::Arc;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use dbx_core::connection::AppState;
use serde::Serialize;
use tauri::State;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LoadTabRuntimeCacheResponse {
pub key: String,
pub payload_base64: String,
pub row_count: i64,
pub column_count: i64,
pub byte_size: i64,
pub updated_at: String,
}
#[tauri::command]
pub async fn save_tab_runtime_cache(
state: State<'_, Arc<AppState>>,
key: String,
payload_base64: String,
row_count: i64,
column_count: i64,
) -> Result<(), String> {
let payload = BASE64.decode(payload_base64).map_err(|e| e.to_string())?;
state.storage.save_tab_runtime_cache(&key, payload, row_count, column_count).await
}
#[tauri::command]
pub async fn load_tab_runtime_cache(
state: State<'_, Arc<AppState>>,
key: String,
) -> Result<Option<LoadTabRuntimeCacheResponse>, String> {
let entry = state.storage.load_tab_runtime_cache(&key).await?;
Ok(entry.map(|entry| LoadTabRuntimeCacheResponse {
key: entry.key,
payload_base64: BASE64.encode(entry.payload),
row_count: entry.row_count,
column_count: entry.column_count,
byte_size: entry.byte_size,
updated_at: entry.updated_at,
}))
}
#[tauri::command]
pub async fn delete_tab_runtime_cache(state: State<'_, Arc<AppState>>, key: String) -> Result<(), String> {
state.storage.delete_tab_runtime_cache(&key).await
}

View File

@ -393,6 +393,9 @@ pub fn run() {
commands::schema_cache::save_schema_cache,
commands::schema_cache::load_schema_cache,
commands::schema_cache::delete_schema_cache_prefix,
commands::tab_runtime_cache::save_tab_runtime_cache,
commands::tab_runtime_cache::load_tab_runtime_cache,
commands::tab_runtime_cache::delete_tab_runtime_cache,
commands::query::execute_query,
commands::query::execute_multi,
commands::query::cancel_query,