diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue
index 5210273d36..275e465ea8 100644
--- a/apps/desktop/src/components/layout/ContentArea.vue
+++ b/apps/desktop/src/components/layout/ContentArea.vue
@@ -311,7 +311,10 @@ const activeQueryError = computed(() => {
if (!result?.columns.includes("Error")) return "";
return String(result.rows[0]?.[0] ?? "");
});
-const hasQueryOutput = computed(() => !!props.activeTab.result || !!props.activeTab.explainPlan || !!props.activeTab.explainError || !!props.activeTab.explainTableResult || !!props.activeTab.explainTableError || props.activeTab.isExecuting === true || props.activeTab.isExplaining === true);
+const hasQueryOutput = computed(
+ () =>
+ !!props.activeTab.result || props.activeTab.resultEvicted === true || !!props.activeTab.explainPlan || !!props.activeTab.explainError || !!props.activeTab.explainTableResult || !!props.activeTab.explainTableError || props.activeTab.isExecuting === true || props.activeTab.isExplaining === true,
+);
const visibleResultItems = computed(() => tabularResultItems(props.activeTab.results ?? (props.activeTab.result ? [props.activeTab.result] : undefined)));
const tabularResults = computed(() => tabularResultItems(props.activeTab.results));
const allResultExportSheets = computed(() =>
@@ -1196,6 +1199,12 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
:cancelling="activeTab.isCancelling"
@cancel="emit('cancel')"
/>
+
+
{{ t("editor.cachedResultUnavailable") }}
+
+
{{ t("editor.pressToExecute", { mod: shortcutModifier }) }}
{{ t("editor.pressToSaveSql", { mod: shortcutModifier }) }}
diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue
index a85870ffd6..df37609ba5 100644
--- a/apps/desktop/src/components/sidebar/ConnectionTree.vue
+++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue
@@ -961,12 +961,20 @@ function openSidebarProcedure(node: TreeNode) {
sidebarProcedureOpen.value = true;
}
-function openSidebarData(node: TreeNode, requireSelection: boolean, runner: (node: TreeNode, request: SidebarDataOpenRequest) => Promise
) {
+function openSidebarData(node: TreeNode, requireSelection: boolean, openMode: "default" | "new-tab", runner: (node: TreeNode, request: SidebarDataOpenRequest) => Promise) {
const target = createSidebarActionTarget(node);
- runSidebarDataOpenImmediately((request) => {
- if (requireSelection && store.selectedTreeNodeId !== target.id) return;
- return runner(target, request);
- });
+ runSidebarDataOpenImmediately(
+ {
+ connectionKey: target.connectionId || target.id,
+ // Explicit new-tab opens are intentional independent work; ordinary
+ // navigation keeps latest-request-wins behavior.
+ supersede: openMode !== "new-tab",
+ },
+ (request) => {
+ if (requireSelection && store.selectedTreeNodeId !== target.id) return;
+ return runner(target, request);
+ },
+ );
}
function openSidebarVisibleDatabases(node: TreeNode) {
diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue
index 445657ad52..b0c7af0057 100644
--- a/apps/desktop/src/components/sidebar/TreeItem.vue
+++ b/apps/desktop/src/components/sidebar/TreeItem.vue
@@ -80,6 +80,7 @@ import { buildTableDeleteTemplate, buildTableInsertTemplate, buildTableSelectTem
import { connectionFilePath, defaultSqliteBackupFileName, isMemorySqlitePath, sqliteBackupSourcePath } from "@/lib/connection/connectionFile";
import { driverStoreFocusForInstallError } from "@/lib/connection/agentDriverInstallHint";
import { revealPathInFileManager } from "@/lib/backend/tauri";
+import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { clearActiveTableReferencePayload, createTableReferencePayload, createTableReferenceDropEvent, setActiveTableReferencePayload, type QueryEditorTableReferencePayload } from "@/lib/editor/queryEditorTableDrop";
import { usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit";
@@ -354,7 +355,7 @@ const emit = defineEmits<{
"open-ddl": [node: TreeNode];
"open-object-source": [node: TreeNode, initialEditing: boolean];
"open-procedure": [node: TreeNode];
- "open-data": [node: TreeNode, requireSelection: boolean, runner: (node: TreeNode, request: SidebarDataOpenRequest) => Promise];
+ "open-data": [node: TreeNode, requireSelection: boolean, openMode: DataTabOpenMode, runner: (node: TreeNode, request: SidebarDataOpenRequest) => Promise];
"open-visible-databases": [node: TreeNode];
"open-visible-schemas": [node: TreeNode];
"open-danger-dialog": [request: SidebarDangerDialogRequest];
@@ -1353,15 +1354,15 @@ async function openDamengJobAdmin() {
}
function scheduleOpenData(node: TreeNode) {
- emit("open-data", node, true, openData);
+ emit("open-data", node, true, "default", openData);
}
function openDataImmediately(node: TreeNode = props.node) {
- emit("open-data", node, false, openData);
+ emit("open-data", node, false, "default", openData);
}
function openDataInNewTabImmediately(node: TreeNode = props.node) {
- emit("open-data", node, false, (target, request) => openData(target, request, "new-tab"));
+ emit("open-data", node, false, "new-tab", (target, request) => openData(target, request, "new-tab"));
}
async function openData(node: TreeNode, request?: SidebarDataOpenRequest, openMode: DataTabOpenMode = "default") {
@@ -1371,9 +1372,12 @@ async function openData(node: TreeNode, request?: SidebarDataOpenRequest, openMo
const startedAt = performance.now();
let lastPhaseAt = startedAt;
const elapsed = () => `${Math.round(performance.now() - startedAt)}ms`;
+ const openDataLog = (level: "info" | "warn" | "error" | "debug", event: string, details: Record) => {
+ appendDebugLog(level, `[DBX][openData:${event}]`, details);
+ };
const logPhase = (phase: string, extra: Record = {}) => {
const now = performance.now();
- console.info("[DBX][openData:phase]", {
+ openDataLog("info", "phase", {
traceId,
phase,
deltaMs: Math.round(now - lastPhaseAt),
@@ -1382,14 +1386,11 @@ async function openData(node: TreeNode, request?: SidebarDataOpenRequest, openMo
});
lastPhaseAt = now;
};
- console.info("[DBX][openData:start]", {
+ openDataLog("info", "start", {
traceId,
type: node.type,
- connectionId: node.connectionId,
- database: node.database,
- schema: node.schema,
- table: node.label,
dbType: config?.db_type,
+ openMode,
});
const tableSchema = connectionObjectTreeNodeSchema(config, node.database, node.schema);
const tableType = node.type === "view" ? "VIEW" : node.type === "materialized_view" ? "MATERIALIZED_VIEW" : (node.tableType ?? "TABLE");
@@ -1445,7 +1446,7 @@ async function openData(node: TreeNode, request?: SidebarDataOpenRequest, openMo
}
return queryStore.createTab(node.connectionId, node.database, node.label, "data", tableSchema);
})();
- console.info("[DBX][openData:tab-created]", { traceId, tabId, elapsed: elapsed() });
+ openDataLog("info", "tab-created", { traceId, tabId, elapsed: elapsed() });
logPhase("tab-created", { tabId });
// Cancel any previous execution on this tab before starting a new one
@@ -1519,24 +1520,21 @@ async function openData(node: TreeNode, request?: SidebarDataOpenRequest, openMo
};
try {
- console.info("[DBX][openData:ensure-connected:start]", { traceId, elapsed: elapsed() });
+ openDataLog("info", "ensure-connected:start", { traceId, elapsed: elapsed() });
await connectionStore.ensureConnected(node.connectionId);
if (!isActive()) {
logPhase("superseded-after-ensure-connected", { tabId });
return;
}
- console.info("[DBX][openData:ensure-connected:done]", { traceId, elapsed: elapsed() });
+ openDataLog("info", "ensure-connected:done", { traceId, elapsed: elapsed() });
logPhase("ensure-connected", { tabId });
if (!config) throw new Error("Connection config not found");
const limit = tableOpenPageLimit();
const refreshTableMetaInBackground = async () => {
const metadataStartedAt = performance.now();
- console.info("[DBX][openData:metadata:start]", {
+ openDataLog("info", "metadata:start", {
traceId,
- database: node.database,
- schema: querySchema,
- table: node.label,
elapsed: elapsed(),
});
try {
@@ -1549,10 +1547,10 @@ async function openData(node: TreeNode, request?: SidebarDataOpenRequest, openMo
databaseType: metadataDatabaseType,
driverProfile: config.driver_profile || config.db_type,
catalog: node.catalog,
- traceLogger: (event) => console.debug("[DBX][openData:metadata:trace]", { sourceTraceId: traceId, ...event }),
+ traceLogger: isDebugLoggingEnabled() ? (event) => openDataLog("debug", "metadata:trace", { sourceTraceId: traceId, ...event }) : undefined,
});
if (!isCurrentDataTab()) {
- console.info("[DBX][openData:metadata:stale]", {
+ openDataLog("info", "metadata:stale", {
traceId,
tabId,
columnCount: loadedMetadata.metadata.columns.length,
@@ -1562,7 +1560,7 @@ async function openData(node: TreeNode, request?: SidebarDataOpenRequest, openMo
}
const nextTableMeta = tableMetadataToDataTabMeta(loadedMetadata.metadata, tableSchema);
queryStore.setTableMeta(tabId, nextTableMeta);
- console.info("[DBX][openData:metadata:done]", {
+ openDataLog("info", "metadata:done", {
traceId,
tabId,
columnCount: nextTableMeta.columns.length,
@@ -1573,12 +1571,12 @@ async function openData(node: TreeNode, request?: SidebarDataOpenRequest, openMo
metadataMs: Math.round(performance.now() - metadataStartedAt),
});
} catch (error) {
- console.warn("[DBX][openData:metadata:error]", { traceId, tabId, elapsed: elapsed(), error });
+ openDataLog("warn", "metadata:error", { traceId, tabId, elapsed: elapsed(), error });
}
};
const shouldRefreshTableMeta = !cachedTableMeta;
if (cachedTableMeta) {
- console.info("[DBX][openData:metadata:cache-hit]", {
+ openDataLog("info", "metadata:cache-hit", {
traceId,
tabId,
columnCount: cachedTableMeta.columns.length,
@@ -1613,24 +1611,24 @@ async function openData(node: TreeNode, request?: SidebarDataOpenRequest, openMo
limit,
includeRowId,
});
- console.info("[DBX][openData:sql-built]", {
+ openDataLog("info", "sql-built", {
traceId,
- primaryKeys,
+ primaryKeyCount: primaryKeys.length,
includeRowId,
- sql,
+ sqlLength: sql.length,
elapsed: elapsed(),
});
logPhase("sql-built", { tabId, columnCount: columns.length, primaryKeyCount: primaryKeys.length });
queryStore.updateSql(tabId, sql);
logPhase("sql-updated", { tabId });
- console.info("[DBX][openData:execute:start]", { traceId, tabId, elapsed: elapsed() });
+ openDataLog("info", "execute:start", { traceId, tabId, elapsed: elapsed() });
await queryStore.executeTabSql(tabId, sql, {
sourceTraceId: traceId,
skipEnsureConnected: true,
pagination: { limit, offset: 0 },
});
- console.info("[DBX][openData:execute:done]", { traceId, tabId, elapsed: elapsed() });
+ openDataLog("info", "execute:done", { traceId, tabId, elapsed: elapsed() });
logPhase("execute-tab-sql", { tabId });
if (shouldRefreshTableMeta && isCurrentDataTab()) {
void refreshTableMetaInBackground();
@@ -1641,7 +1639,7 @@ async function openData(node: TreeNode, request?: SidebarDataOpenRequest, openMo
logPhase("superseded-after-error", { tabId });
return;
}
- console.error("[DBX][openData:error]", { traceId, elapsed: elapsed(), error: e });
+ openDataLog("error", "error", { traceId, elapsed: elapsed(), error: e });
logPhase("error", { tabId });
queryStore.setErrorResult(tabId, e);
}
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index b383a92fa0..b651c27d83 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -1038,6 +1038,8 @@ export default {
noColumnsFound: "No columns found",
queryError: "Query Error",
dataUnavailable: "Table data needs to be reloaded.",
+ cachedResultUnavailable: "The cached result is missing or incompatible.",
+ reexecuteQuery: "Run query again",
dataUnavailableHintPrefix: "Press ",
dataUnavailableHintSuffix: " or click Refresh below to reload.",
refresh: "Refresh",
diff --git a/apps/desktop/src/lib/__tests__/fixtures/queryResultLifecycle.ts b/apps/desktop/src/lib/__tests__/fixtures/queryResultLifecycle.ts
new file mode 100644
index 0000000000..e46903cb72
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/fixtures/queryResultLifecycle.ts
@@ -0,0 +1,57 @@
+import type { QueryTab } from "@/types/database";
+import type { TabResultSnapshot } from "@/lib/tabs/tabResultCache";
+
+export function queryResultLifecycleSnapshot(): TabResultSnapshot {
+ const first = {
+ columns: ["id", "name"],
+ rows: [[1, "Ada"]],
+ affected_rows: 0,
+ execution_time_ms: 4,
+ sourceStatement: "SELECT id, name FROM users",
+ };
+ const second = {
+ columns: ["count"],
+ rows: [[1]],
+ affected_rows: 0,
+ execution_time_ms: 2,
+ sourceStatement: "SELECT COUNT(*) FROM users",
+ };
+ const resultRuns: NonNullable = [
+ {
+ id: "run-1",
+ sequence: 1,
+ result: first,
+ results: [first, second],
+ activeResultIndex: 0,
+ resultLocalSortOriginalRows: [[1, "Ada"]],
+ resultBaseSql: "SELECT id, name FROM users",
+ resultPageSql: "SELECT id, name FROM users LIMIT 100 OFFSET 200",
+ resultPageLimit: 100,
+ resultPageOffset: 200,
+ tableMeta: {
+ schema: "public",
+ tableName: "users",
+ tableType: "TABLE",
+ columns: [{ name: "id", data_type: "integer", nullable: false }],
+ primaryKeys: ["id"],
+ },
+ },
+ ];
+ return {
+ result: first,
+ results: [first, second],
+ activeResultIndex: 0,
+ resultLocalSortOriginalRows: [[1, "Ada"]],
+ resultRuns,
+ activeResultRunId: "run-1",
+ queryAnalysis: { tableName: "users", schema: "public", allowInsert: true, allowInsertDelete: true },
+ querySourceColumns: ["id", "name"],
+ tableMeta: resultRuns[0].tableMeta,
+ resultPageSql: "SELECT id, name FROM users LIMIT 100 OFFSET 200",
+ resultPageLimit: 100,
+ resultPageOffset: 200,
+ resultCountSql: "SELECT COUNT(*) FROM users",
+ resultTotalRowCount: 301,
+ cachedAt: 1,
+ };
+}
diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarDataOpenCoordinator.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarDataOpenCoordinator.spec.ts
index b3dae50285..8b678ab380 100644
--- a/apps/desktop/src/lib/__tests__/sidebar/sidebarDataOpenCoordinator.spec.ts
+++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarDataOpenCoordinator.spec.ts
@@ -1,95 +1,141 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cancelPendingSidebarDataOpen, runSidebarDataOpenImmediately } from "@/lib/sidebar/sidebarDataOpenCoordinator";
+function deferred() {
+ let resolve!: () => void;
+ const promise = new Promise((done) => {
+ resolve = done;
+ });
+ return { promise, resolve };
+}
+
describe("sidebarDataOpenCoordinator", () => {
afterEach(() => {
cancelPendingSidebarDataOpen();
});
- it("cancels the active request when a newer open starts", async () => {
+ it("cancels the active request when a newer ordinary open starts", async () => {
const cancel = vi.fn();
+ const running = deferred();
let firstIsCurrent = true;
- runSidebarDataOpenImmediately(async (request) => {
+ runSidebarDataOpenImmediately({ connectionKey: "conn" }, async (request) => {
request.registerCancel(cancel);
- await Promise.resolve();
+ await running.promise;
firstIsCurrent = request.isCurrent();
});
- runSidebarDataOpenImmediately(() => undefined);
+ runSidebarDataOpenImmediately({ connectionKey: "conn" }, () => undefined);
await Promise.resolve();
expect(cancel).toHaveBeenCalledOnce();
+ expect(firstIsCurrent).toBe(true);
+
+ running.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
expect(firstIsCurrent).toBe(false);
});
+ it("replaces a superseded queued request before it starts", async () => {
+ const running = deferred();
+ const second = vi.fn();
+ const third = vi.fn();
+
+ runSidebarDataOpenImmediately({ connectionKey: "conn", supersede: false }, () => running.promise);
+ runSidebarDataOpenImmediately({ connectionKey: "conn" }, second);
+ runSidebarDataOpenImmediately({ connectionKey: "conn" }, third);
+ running.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(second).not.toHaveBeenCalled();
+ expect(third).toHaveBeenCalledOnce();
+ });
+
+ it("bounds active work per connection", async () => {
+ const first = deferred();
+ const starts: string[] = [];
+
+ runSidebarDataOpenImmediately({ connectionKey: "conn", supersede: false }, async () => {
+ starts.push("first");
+ await first.promise;
+ });
+ runSidebarDataOpenImmediately({ connectionKey: "conn", supersede: false }, () => {
+ starts.push("second");
+ });
+
+ expect(starts).toEqual(["first"]);
+ first.resolve();
+ await vi.waitFor(() => expect(starts).toEqual(["first", "second"]));
+ });
+
+ it("allows different connections to make progress independently", () => {
+ const blocked = deferred();
+ const otherConnection = vi.fn();
+
+ runSidebarDataOpenImmediately({ connectionKey: "conn-a", supersede: false }, () => blocked.promise);
+ runSidebarDataOpenImmediately({ connectionKey: "conn-b", supersede: false }, otherConnection);
+
+ expect(otherConnection).toHaveBeenCalledOnce();
+ blocked.resolve();
+ });
+
+ it("keeps explicit new-tab opens independent while still queueing them", async () => {
+ const first = deferred();
+ const firstCancel = vi.fn();
+ const second = vi.fn();
+
+ runSidebarDataOpenImmediately({ connectionKey: "conn", supersede: false }, async (request) => {
+ request.registerCancel(firstCancel);
+ await first.promise;
+ });
+ runSidebarDataOpenImmediately({ connectionKey: "conn", supersede: false }, second);
+
+ expect(firstCancel).not.toHaveBeenCalled();
+ expect(second).not.toHaveBeenCalled();
+ first.resolve();
+ await vi.waitFor(() => expect(second).toHaveBeenCalledOnce());
+ });
+
it("runs registered cancellation immediately for an already stale request", async () => {
const cancel = vi.fn();
let registerLateCancel: (() => void) | undefined;
+ const running = deferred();
- runSidebarDataOpenImmediately((request) => {
+ runSidebarDataOpenImmediately({ connectionKey: "conn" }, async (request) => {
registerLateCancel = () => request.registerCancel(cancel);
+ await running.promise;
});
- runSidebarDataOpenImmediately(() => undefined);
+ runSidebarDataOpenImmediately({ connectionKey: "conn" }, () => undefined);
registerLateCancel?.();
await Promise.resolve();
expect(cancel).toHaveBeenCalledOnce();
+ running.resolve();
});
it("recovers after a runner throws synchronously", async () => {
const nextRunner = vi.fn();
- runSidebarDataOpenImmediately(() => {
+ runSidebarDataOpenImmediately({ connectionKey: "conn", supersede: false }, () => {
throw new Error("boom");
});
await Promise.resolve();
- runSidebarDataOpenImmediately(nextRunner);
await Promise.resolve();
+ runSidebarDataOpenImmediately({ connectionKey: "conn", supersede: false }, nextRunner);
expect(nextRunner).toHaveBeenCalledOnce();
});
- it("keeps only the newest rapid navigation request current", async () => {
- const states: boolean[] = [];
- const captures: Array<() => void> = [];
+ it("aborts a superseded request signal", () => {
+ let signal: AbortSignal | undefined;
- for (let index = 0; index < 3; index += 1) {
- runSidebarDataOpenImmediately((request) => {
- captures.push(() => states.push(request.isCurrent()));
- });
- }
- captures.forEach((capture) => capture());
-
- expect(states).toEqual([false, false, true]);
- });
-
- it("does not cancel work that was not registered as sidebar-owned", async () => {
- const sidebarCancel = vi.fn();
- const unrelatedEditorCancel = vi.fn();
-
- runSidebarDataOpenImmediately((request) => request.registerCancel(sidebarCancel));
- runSidebarDataOpenImmediately(() => undefined);
- await Promise.resolve();
-
- expect(sidebarCancel).toHaveBeenCalledOnce();
- expect(unrelatedEditorCancel).not.toHaveBeenCalled();
- });
-
- it("prevents a superseded connection completion from becoming current", async () => {
- let finishConnection: (() => void) | undefined;
- let completionWasCurrent = true;
-
- runSidebarDataOpenImmediately(async (request) => {
- await new Promise((resolve) => {
- finishConnection = resolve;
- });
- completionWasCurrent = request.isCurrent();
+ runSidebarDataOpenImmediately({ connectionKey: "conn" }, (request) => {
+ signal = request.signal;
});
- runSidebarDataOpenImmediately(() => undefined);
- finishConnection?.();
- await Promise.resolve();
- await Promise.resolve();
+ runSidebarDataOpenImmediately({ connectionKey: "conn" }, () => undefined);
- expect(completionWasCurrent).toBe(false);
+ expect(signal?.aborted).toBe(true);
});
});
diff --git a/apps/desktop/src/lib/__tests__/tabs/queryResultSize.spec.ts b/apps/desktop/src/lib/__tests__/tabs/queryResultSize.spec.ts
new file mode 100644
index 0000000000..ad0d9c302e
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/tabs/queryResultSize.spec.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+import { estimateQueryResultBytes, estimateQueryResultsBytes, selectInactiveResultEvictions } from "@/lib/tabs/queryResultSize";
+
+function result(rows: unknown[][]) {
+ return { columns: ["value"], rows, affected_rows: 0, execution_time_ms: 1 };
+}
+
+describe("query result size estimation", () => {
+ it("grows with string and binary payloads", () => {
+ const small = estimateQueryResultBytes(result([["a"]]));
+ const large = estimateQueryResultBytes(result([["a".repeat(10_000)], [new Uint8Array(20_000)]]));
+ expect(large).toBeGreaterThan(small + 30_000);
+ });
+
+ it("counts multiple results and tolerates cyclic document values", () => {
+ const document: Record = { name: "node" };
+ document.self = document;
+ const first = result([[document]]);
+ const second = result(Array.from({ length: 100 }, (_, index) => [index]));
+ expect(estimateQueryResultsBytes(undefined, [first, second])).toBeGreaterThan(estimateQueryResultBytes(first));
+ });
+});
+
+describe("inactive result byte budgets", () => {
+ it("evicts oldest small results to the count limit", () => {
+ const entries = Array.from({ length: 8 }, (_, index) => ({ id: `${index}`, estimatedBytes: 10, accessedAt: index }));
+ expect(selectInactiveResultEvictions(entries, 1_000, 5)).toEqual(["0", "1", "2"]);
+ });
+
+ it("evicts least-recently-used large results to the byte limit", () => {
+ const entries = [
+ { id: "old", estimatedBytes: 80, accessedAt: 1 },
+ { id: "middle", estimatedBytes: 70, accessedAt: 2 },
+ { id: "new", estimatedBytes: 60, accessedAt: 3 },
+ ];
+ expect(selectInactiveResultEvictions(entries, 100, 10)).toEqual(["old", "middle"]);
+ });
+
+ it("does not consider the active result even when it exceeds the budget", () => {
+ const inactive = [{ id: "inactive", estimatedBytes: 10, accessedAt: 1 }];
+ expect(selectInactiveResultEvictions(inactive, 100, 5)).toEqual([]);
+ });
+});
diff --git a/apps/desktop/src/lib/__tests__/tabs/resourceLifecycleBaseline.spec.ts b/apps/desktop/src/lib/__tests__/tabs/resourceLifecycleBaseline.spec.ts
new file mode 100644
index 0000000000..178281ea47
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/tabs/resourceLifecycleBaseline.spec.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from "vitest";
+import { encodeTabResultSnapshot } from "@/lib/tabs/tabResultCache";
+import { estimateQueryResultBytes } from "@/lib/tabs/queryResultSize";
+import { cancelPendingSidebarDataOpen, runSidebarDataOpenImmediately, sidebarDataOpenDiagnostics } from "@/lib/sidebar/sidebarDataOpenCoordinator";
+
+describe("resource lifecycle performance harness", () => {
+ it("records deterministic serialization and memory-estimation measurements", () => {
+ const result = {
+ columns: ["id", "label", "payload"],
+ rows: Array.from({ length: 25_000 }, (_, index) => [index, `row-${index}`, "x".repeat(64)]),
+ affected_rows: 0,
+ execution_time_ms: 1,
+ };
+
+ const estimateStartedAt = performance.now();
+ const estimatedBytes = estimateQueryResultBytes(result);
+ const estimateDurationMs = performance.now() - estimateStartedAt;
+ const serializeStartedAt = performance.now();
+ const encodedBytes = encodeTabResultSnapshot({ result, cachedAt: 1 }).byteLength;
+ const serializationDurationMs = performance.now() - serializeStartedAt;
+
+ console.info("[resource-lifecycle-baseline]", JSON.stringify({ rows: result.rows.length, estimatedBytes, encodedBytes, estimateDurationMs, serializationDurationMs }));
+ expect(estimatedBytes).toBeGreaterThan(encodedBytes);
+ expect(estimateDurationMs).toBeLessThan(250);
+ expect(serializationDurationMs).toBeLessThan(1_500);
+ });
+});
+
+describe("rapid table-open performance harness", () => {
+ it("coalesces a 100-request burst to one active and one latest queued request", async () => {
+ let release!: () => void;
+ const blocked = new Promise((resolve) => {
+ release = resolve;
+ });
+ const committed: number[] = [];
+ const startedAt = performance.now();
+ runSidebarDataOpenImmediately({ connectionKey: "connection" }, async (request) => {
+ await blocked;
+ if (request.isCurrent()) committed.push(0);
+ });
+ for (let index = 1; index < 100; index += 1) {
+ runSidebarDataOpenImmediately({ connectionKey: "connection" }, (request) => {
+ if (request.isCurrent()) committed.push(index);
+ });
+ }
+ const enqueueDurationMs = performance.now() - startedAt;
+ const burstDiagnostics = sidebarDataOpenDiagnostics();
+ release();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ const totalDurationMs = performance.now() - startedAt;
+ console.info("[rapid-table-open-baseline]", JSON.stringify({ enqueueDurationMs, totalDurationMs, ...burstDiagnostics }));
+
+ expect(burstDiagnostics).toEqual({ activeTasks: 1, queuedTasks: 1, trackedRequests: 2 });
+ expect(committed).toEqual([99]);
+ cancelPendingSidebarDataOpen();
+ });
+});
diff --git a/apps/desktop/src/lib/__tests__/tabs/tabResultCache.spec.ts b/apps/desktop/src/lib/__tests__/tabs/tabResultCache.spec.ts
index badad44d45..0dbe1475cc 100644
--- a/apps/desktop/src/lib/__tests__/tabs/tabResultCache.spec.ts
+++ b/apps/desktop/src/lib/__tests__/tabs/tabResultCache.spec.ts
@@ -1,7 +1,78 @@
-import { describe, expect, it } from "vitest";
-import { decodeTabResultSnapshot, encodeTabResultSnapshot } from "@/lib/tabs/tabResultCache";
+import { describe, expect, it, vi } from "vitest";
+import { decodeTabResultSnapshot, encodeTabResultSnapshot, readResultCacheBackends, promoteFallbackResultCacheRead, resultCacheBackendOrder, resultCacheRuntimeConfig, selectResultCachePruneKeys, writeResultCacheBackends, type ResultCacheBackend } from "@/lib/tabs/tabResultCache";
+import { queryResultLifecycleSnapshot } from "@/lib/__tests__/fixtures/queryResultLifecycle";
+
+function backend(name: ResultCacheBackend["name"], overrides: Partial = {}): ResultCacheBackend {
+ return {
+ name,
+ available: () => true,
+ read: async () => undefined,
+ write: async () => true,
+ delete: async () => undefined,
+ listMetadata: async () => [],
+ prune: async () => ({ deletedEntries: 0, deletedBytes: 0, orphanDeletions: 0, remainingEntries: 0, remainingBytes: 0 }),
+ deleteOwner: async () => undefined,
+ ...overrides,
+ };
+}
describe("tab result cache statement execution metadata", () => {
+ it("selects one authoritative backend before fallback", () => {
+ expect(resultCacheBackendOrder(true)).toEqual(["runtime", "indexed-db"]);
+ expect(resultCacheBackendOrder(false)).toEqual(["indexed-db", "runtime"]);
+ });
+
+ it("uses explicit browser runtime configuration", () => {
+ expect(resultCacheRuntimeConfig(false, { VITE_DBX_RESULT_CACHE_BACKEND: "http", VITE_DBX_RESULT_CACHE_FALLBACK: "true" } as ImportMetaEnv)).toEqual({
+ primary: "runtime",
+ fallbackEnabled: true,
+ });
+ });
+
+ it("writes only to the first successful backend", async () => {
+ const primaryWrite = vi.fn(async () => true);
+ const fallbackWrite = vi.fn(async () => true);
+ await writeResultCacheBackends([backend("runtime", { write: primaryWrite }), backend("indexed-db", { write: fallbackWrite })], "key", new Uint8Array([1]), { rowCount: 1, columnCount: 1 });
+ expect(primaryWrite).toHaveBeenCalledOnce();
+ expect(fallbackWrite).not.toHaveBeenCalled();
+ });
+
+ it("reads legacy fallback data for promotion", async () => {
+ const bytes = new Uint8Array([1, 2, 3]);
+ const primary = backend("runtime");
+ const fallback = backend("indexed-db", { read: async () => bytes });
+ expect(await readResultCacheBackends([primary, fallback], "key")).toEqual({ bytes, backend: fallback });
+ });
+
+ it("promotes legacy IndexedDB or native bytes to the authoritative backend", async () => {
+ const bytes = new Uint8Array([1, 2, 3]);
+ const primaryWrite = vi.fn(async () => true);
+ const primary = backend("runtime", { write: primaryWrite });
+ const legacy = backend("indexed-db");
+
+ expect(await promoteFallbackResultCacheRead([primary, legacy], "key", { bytes, backend: legacy }, { rowCount: 2, columnCount: 1 }, "connection-1")).toBe(true);
+ expect(primaryWrite).toHaveBeenCalledWith("key", bytes, { rowCount: 2, columnCount: 1 }, "connection-1");
+ });
+
+ it("skips an obsolete write before touching a backend", async () => {
+ const write = vi.fn(async () => true);
+ expect(await writeResultCacheBackends([backend("runtime", { write })], "key", new Uint8Array([1]), { rowCount: 1, columnCount: 1 }, () => false)).toBe(false);
+ expect(write).not.toHaveBeenCalled();
+ });
+
+ it("prunes crash leftovers and then unreferenced LRU entries", () => {
+ const selection = selectResultCachePruneKeys(
+ [
+ { key: "live", rowCount: 1, columnCount: 1, byteSize: 8, createdAt: 1, lastAccessedAt: 1 },
+ { key: "crash", rowCount: 1, columnCount: 1, byteSize: 7, createdAt: 1, lastAccessedAt: 1 },
+ { key: "recent", rowCount: 1, columnCount: 1, byteSize: 6, createdAt: 9_999, lastAccessedAt: 9_999 },
+ ],
+ { liveKeys: ["live"], maxBytes: 10, orphanGraceMs: 100, maxAgeMs: 1_000 },
+ 10_000,
+ );
+ expect(selection.keys).toEqual(["crash", "recent"]);
+ expect(selection.result).toMatchObject({ orphanDeletions: 1, remainingBytes: 8 });
+ });
it("preserves statement identity and the editor fingerprint", () => {
const encoded = encodeTabResultSnapshot({
results: [
@@ -35,4 +106,22 @@ describe("tab result cache statement execution metadata", () => {
{ statementIndex: 1, error: true },
]);
});
+
+ it("restores multi-result, pagination, local-sort, and editable metadata fixtures", () => {
+ const restored = decodeTabResultSnapshot(encodeTabResultSnapshot(queryResultLifecycleSnapshot()));
+
+ expect(restored?.results).toHaveLength(2);
+ expect(restored?.resultRuns?.[0]?.resultLocalSortOriginalRows).toEqual([[1, "Ada"]]);
+ expect(restored?.resultPageOffset).toBe(200);
+ expect(restored?.resultTotalRowCount).toBe(301);
+ expect(restored?.tableMeta?.primaryKeys).toEqual(["id"]);
+ expect(restored?.querySourceColumns).toEqual(["id", "name"]);
+ });
+
+ it("treats corrupt and unsupported snapshots as missing", () => {
+ expect(decodeTabResultSnapshot(new Uint8Array([0xff, 0x00]))).toBeUndefined();
+ const encoded = encodeTabResultSnapshot(queryResultLifecycleSnapshot());
+ encoded[0] = 0xff;
+ expect(decodeTabResultSnapshot(encoded)).toBeUndefined();
+ });
});
diff --git a/apps/desktop/src/lib/diagnostics/resourceLifecycleDiagnostics.ts b/apps/desktop/src/lib/diagnostics/resourceLifecycleDiagnostics.ts
new file mode 100644
index 0000000000..60d7052e96
--- /dev/null
+++ b/apps/desktop/src/lib/diagnostics/resourceLifecycleDiagnostics.ts
@@ -0,0 +1,25 @@
+import type { QueryTab } from "@/types/database";
+import { getResultCacheDiagnostics } from "@/lib/tabs/tabResultCache";
+
+const cancellation = {
+ count: 0,
+ totalLatencyMs: 0,
+ maxLatencyMs: 0,
+};
+
+export function recordQueryCancellationLatency(durationMs: number) {
+ cancellation.count += 1;
+ cancellation.totalLatencyMs += durationMs;
+ cancellation.maxLatencyMs = Math.max(cancellation.maxLatencyMs, durationMs);
+}
+
+export function resourceLifecycleDiagnostics(tabs: readonly QueryTab[]) {
+ const cache = getResultCacheDiagnostics();
+ return {
+ activeTasks: tabs.filter((tab) => tab.isExecuting || tab.isExplaining || tab.isCancelling).length,
+ cancellationCount: cancellation.count,
+ averageCancellationLatencyMs: cancellation.count ? cancellation.totalLatencyMs / cancellation.count : 0,
+ maxCancellationLatencyMs: cancellation.maxLatencyMs,
+ ...cache,
+ };
+}
diff --git a/apps/desktop/src/lib/sidebar/sidebarDataOpenCoordinator.ts b/apps/desktop/src/lib/sidebar/sidebarDataOpenCoordinator.ts
index 53365ffe9e..ded24db65d 100644
--- a/apps/desktop/src/lib/sidebar/sidebarDataOpenCoordinator.ts
+++ b/apps/desktop/src/lib/sidebar/sidebarDataOpenCoordinator.ts
@@ -1,60 +1,166 @@
export interface SidebarDataOpenRequest {
isCurrent: () => boolean;
+ signal: AbortSignal;
registerCancel: (cancel: () => void | Promise) => void;
}
+export interface SidebarDataOpenOptions {
+ connectionKey: string;
+ supersede?: boolean;
+}
+
type OpenDataRunner = (request: SidebarDataOpenRequest) => void | Promise;
-let generation = 0;
-let activeGeneration = 0;
-let activeCancel: (() => void | Promise) | null = null;
+interface QueuedOpen {
+ id: number;
+ connectionKey: string;
+ superseding: boolean;
+ controller: AbortController;
+ runner: OpenDataRunner;
+ cancel: (() => void | Promise) | null;
+ started: boolean;
+ finished: boolean;
+}
+
+const DEFAULT_CONNECTION_KEY = "__sidebar__";
+const MAX_ACTIVE_PER_CONNECTION = 1;
+
+let nextRequestId = 0;
+let latestSupersedingRequestId = 0;
+const queuedByConnection = new Map();
+const activeCountByConnection = new Map();
+const requests = new Map();
function runCancellation(cancel: (() => void | Promise) | null) {
if (!cancel) return;
void Promise.resolve(cancel()).catch(() => undefined);
}
-function supersedeCurrentRequest(): number {
- generation += 1;
- runCancellation(activeCancel);
- activeCancel = null;
- activeGeneration = 0;
- return generation;
+function isCurrent(request: QueuedOpen): boolean {
+ if (request.finished || request.controller.signal.aborted) return false;
+ return !request.superseding || request.id === latestSupersedingRequestId;
}
-function executeRequest(requestGeneration: number, runner: OpenDataRunner) {
- if (requestGeneration !== generation) return;
- const request: SidebarDataOpenRequest = {
- isCurrent: () => requestGeneration === generation,
+function removeQueuedRequest(request: QueuedOpen) {
+ const queue = queuedByConnection.get(request.connectionKey);
+ if (!queue) return;
+ const index = queue.indexOf(request);
+ if (index >= 0) queue.splice(index, 1);
+ if (queue.length === 0) queuedByConnection.delete(request.connectionKey);
+}
+
+function cancelRequest(request: QueuedOpen) {
+ if (request.finished) return;
+ request.controller.abort();
+ runCancellation(request.cancel);
+ request.cancel = null;
+ if (!request.started) {
+ request.finished = true;
+ removeQueuedRequest(request);
+ requests.delete(request.id);
+ }
+}
+
+function supersedeCurrentRequest() {
+ const current = requests.get(latestSupersedingRequestId);
+ if (current) cancelRequest(current);
+}
+
+function finishRequest(request: QueuedOpen) {
+ if (request.finished) return;
+ request.finished = true;
+ request.cancel = null;
+ requests.delete(request.id);
+ const activeCount = activeCountByConnection.get(request.connectionKey) ?? 0;
+ if (activeCount <= 1) activeCountByConnection.delete(request.connectionKey);
+ else activeCountByConnection.set(request.connectionKey, activeCount - 1);
+ drainConnection(request.connectionKey);
+}
+
+function executeRequest(request: QueuedOpen) {
+ if (!isCurrent(request)) {
+ cancelRequest(request);
+ return;
+ }
+
+ request.started = true;
+ activeCountByConnection.set(request.connectionKey, (activeCountByConnection.get(request.connectionKey) ?? 0) + 1);
+ const openRequest: SidebarDataOpenRequest = {
+ isCurrent: () => isCurrent(request),
+ signal: request.controller.signal,
registerCancel: (cancel) => {
- if (requestGeneration !== generation) {
+ if (!isCurrent(request)) {
runCancellation(cancel);
return;
}
- activeGeneration = requestGeneration;
- activeCancel = cancel;
+ request.cancel = cancel;
},
};
+
let result: void | Promise;
try {
- result = runner(request);
+ result = request.runner(openRequest);
} catch {
result = undefined;
}
void Promise.resolve(result)
.catch(() => undefined)
- .finally(() => {
- if (activeGeneration !== requestGeneration) return;
- activeGeneration = 0;
- activeCancel = null;
- });
+ .finally(() => finishRequest(request));
}
-export function runSidebarDataOpenImmediately(runner: OpenDataRunner) {
- const requestGeneration = supersedeCurrentRequest();
- executeRequest(requestGeneration, runner);
+function drainConnection(connectionKey: string) {
+ if ((activeCountByConnection.get(connectionKey) ?? 0) >= MAX_ACTIVE_PER_CONNECTION) return;
+ const queue = queuedByConnection.get(connectionKey);
+ const request = queue?.shift();
+ if (!request) {
+ queuedByConnection.delete(connectionKey);
+ return;
+ }
+ if (queue?.length === 0) queuedByConnection.delete(connectionKey);
+ executeRequest(request);
+}
+
+function enqueueRequest(options: SidebarDataOpenOptions, runner: OpenDataRunner) {
+ const superseding = options.supersede !== false;
+ if (superseding) supersedeCurrentRequest();
+
+ const request: QueuedOpen = {
+ id: ++nextRequestId,
+ connectionKey: options.connectionKey || DEFAULT_CONNECTION_KEY,
+ superseding,
+ controller: new AbortController(),
+ runner,
+ cancel: null,
+ started: false,
+ finished: false,
+ };
+ if (superseding) latestSupersedingRequestId = request.id;
+ requests.set(request.id, request);
+ const queue = queuedByConnection.get(request.connectionKey) ?? [];
+ queue.push(request);
+ queuedByConnection.set(request.connectionKey, queue);
+ drainConnection(request.connectionKey);
+}
+
+export function runSidebarDataOpenImmediately(runner: OpenDataRunner): void;
+export function runSidebarDataOpenImmediately(options: SidebarDataOpenOptions, runner: OpenDataRunner): void;
+export function runSidebarDataOpenImmediately(optionsOrRunner: SidebarDataOpenOptions | OpenDataRunner, maybeRunner?: OpenDataRunner) {
+ if (typeof optionsOrRunner === "function") {
+ enqueueRequest({ connectionKey: DEFAULT_CONNECTION_KEY }, optionsOrRunner);
+ return;
+ }
+ if (maybeRunner) enqueueRequest(optionsOrRunner, maybeRunner);
}
export function cancelPendingSidebarDataOpen() {
- supersedeCurrentRequest();
+ for (const request of [...requests.values()]) cancelRequest(request);
+ latestSupersedingRequestId = 0;
+}
+
+export function sidebarDataOpenDiagnostics() {
+ return {
+ activeTasks: [...activeCountByConnection.values()].reduce((total, count) => total + count, 0),
+ queuedTasks: [...queuedByConnection.values()].reduce((total, queue) => total + queue.length, 0),
+ trackedRequests: requests.size,
+ };
}
diff --git a/apps/desktop/src/lib/tabs/queryResultSize.ts b/apps/desktop/src/lib/tabs/queryResultSize.ts
new file mode 100644
index 0000000000..4a77ffeb08
--- /dev/null
+++ b/apps/desktop/src/lib/tabs/queryResultSize.ts
@@ -0,0 +1,51 @@
+import type { QueryResult } from "@/types/database";
+
+const ARRAY_OVERHEAD_BYTES = 24;
+const OBJECT_OVERHEAD_BYTES = 32;
+const VALUE_SLOT_BYTES = 8;
+
+function estimateValueBytes(value: unknown, seen: WeakSet