feat(query): add switchable query result runs and archives

* docs: design query result run tabs

* feat(query): add result run model helpers

* feat(query): persist result run metadata

* feat(query): record switchable result runs

* feat(query): sync active result run state

* feat(query): cache result run payloads

* feat(query): render result run tabs

* docs: design query result archives

* feat(query): add result archive codec

* feat(query): restore result archives

* feat(query): add result archive actions

* fix(query): toggle execution summary view

* fix(query): avoid archive compression backpressure

* feat(query): remove result runs

* chore: remove local spec docs from pr
This commit is contained in:
SuLea-IT 2026-06-14 10:30:50 +08:00 committed by GitHub
parent cbc7f9b0db
commit 97192297eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1430 additions and 11 deletions

View File

@ -37,6 +37,7 @@ import { buildExecutableObjectSourceStatements, objectSourceSaveExecutionMode }
import { resolveExecutableSql, resolveExecutableSqlWithBackend } from "@/lib/sqlExecutionTarget";
import { uuid } from "@/lib/utils";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import { openQueryResultArchiveFile } from "@/lib/queryResultArchiveFile";
import { sqlFileTitleFromPath } from "@/lib/sqlFileOpen";
import type { ConnectionConfig } from "@/types/database";
import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connectionDeepLink";
@ -539,6 +540,22 @@ async function openSqlFile() {
}
}
async function importResultArchive() {
try {
const bytes = await openQueryResultArchiveFile();
if (!bytes) return;
const tabId = await queryStore.importResultArchive(bytes);
if (!tabId) {
toast(t("tabs.resultArchiveImportInvalid"), 5000);
return;
}
activeOutputView.value = "result";
toast(t("tabs.resultArchiveImported"), 2500);
} catch (e: any) {
toast(t("tabs.resultArchiveImportFailed", { message: e?.message || String(e) }), 5000);
}
}
async function openSqlFilePath(path: string) {
if (!isTauriRuntime()) return;
try {
@ -1141,6 +1158,7 @@ onUnmounted(() => {
@format-sql="formatActiveSql"
@save-sql="void openSaveSqlDialog()"
@open-sql="openSqlFile"
@import-result-archive="importResultArchive"
@change-connection="changeActiveConnection"
@change-database="changeActiveDatabase"
@change-schema="changeActiveSchema"

View File

@ -2,7 +2,7 @@
import { computed, ref, defineAsyncComponent, watch, nextTick, onMounted, onUnmounted } from "vue";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { Check, Columns3, Loader2, Search, Square, Bot, GitBranch, BarChart3, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, ListChecks } from "@lucide/vue";
import { Check, Columns3, Loader2, Search, Square, Bot, GitBranch, BarChart3, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, ListChecks, Download, X } from "@lucide/vue";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
@ -40,8 +40,11 @@ const DatabaseUserAdmin = defineAsyncComponent(() => import("@/components/admin/
const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue"));
const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue"));
import { useQueryStore } from "@/stores/queryStore";
import { useToast } from "@/composables/useToast";
import { canCancelQueryExecution, queryExecutionLabelKey } from "@/lib/queryExecutionState";
import { databaseDisplayNameForTab, executionSummaryItems, tabularResultItems } from "@/lib/tabPresentation";
import { databaseDisplayNameForTab, executionSummaryItems, nextExecutionSummaryView, resultGridCacheKey, resultRunItems, tabularResultItems } from "@/lib/tabPresentation";
import { defaultQueryResultArchiveFileName } from "@/lib/queryResultArchive";
import { saveQueryResultArchiveFile } from "@/lib/queryResultArchiveFile";
import { isTableDataEditable } from "@/lib/tableEditing";
import { tableMetaForDataTab } from "@/lib/tableDataTabMeta";
import { formatShortcut } from "@/lib/shortcutRegistry";
@ -113,6 +116,7 @@ const emit = defineEmits<{
const { t } = useI18n();
const queryStore = useQueryStore();
const { toast } = useToast();
onMounted(() => {
const preload = () => preloadDataGridComponent();
@ -214,6 +218,10 @@ const activeQueryError = computed(() => {
});
const hasQueryOutput = computed(() => !!props.activeTab.result || !!props.activeTab.explainPlan || !!props.activeTab.explainError || props.activeTab.isExecuting === true || props.activeTab.isExplaining === true);
const tabularResults = computed(() => tabularResultItems(props.activeTab.results));
const resultRuns = computed(() => resultRunItems(props.activeTab));
const activeResultGridCacheKey = computed(() => resultGridCacheKey(props.activeTab));
const resultArchiveExporting = ref(false);
const canExportResultArchive = computed(() => props.activeTab.mode === "query" && (!!props.activeTab.result || !!props.activeTab.results?.length || !!props.activeTab.resultRuns?.length));
watch(
() => tabularResults.value.map((item) => item.index).join(","),
() => {
@ -226,6 +234,7 @@ const hasTabularResult = computed(() => {
if (props.activeTab.result?.columns.length) return true;
return tabularResults.value.length > 0;
});
const canShowResultOutput = computed(() => hasTabularResult.value || props.activeTab.isExecuting);
const resultsPaneOpen = ref(false);
const queryRunningElapsed = ref(0);
let queryRunningElapsedTimer: ReturnType<typeof setInterval> | undefined;
@ -407,6 +416,34 @@ function refreshData(): boolean {
return true;
}
async function exportResultArchive() {
if (resultArchiveExporting.value) return;
resultArchiveExporting.value = true;
try {
const bytes = await queryStore.exportResultArchive(props.activeTab.id);
if (!bytes) {
toast(t("tabs.resultArchiveUnavailable"), 4000);
return;
}
const saved = await saveQueryResultArchiveFile(defaultQueryResultArchiveFileName(props.activeTab.title), bytes);
if (saved) toast(t("tabs.resultArchiveExported"), 2500);
} catch (error: any) {
toast(t("tabs.resultArchiveExportFailed", { message: error?.message || String(error) }), 5000);
} finally {
resultArchiveExporting.value = false;
}
}
function toggleExecutionSummary() {
emit("update:activeOutputView", nextExecutionSummaryView(props.activeOutputView, canShowResultOutput.value));
}
function removeResultRun(runId: string) {
const removedActiveRun = props.activeTab.activeResultRunId === runId;
const removed = queryStore.removeResultRun(props.activeTab.id, runId);
if (removed && removedActiveRun) emit("update:activeOutputView", "result");
}
function handleModRTarget(target: Element): boolean {
if (target.closest("[data-query-editor-root]")) return queryEditorRef.value?.openReplace() ?? false;
if (target.closest("[data-cell-detail-editor-root]")) return dataGridRef.value?.openCellDetailSearch() ?? false;
@ -469,6 +506,27 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
{{ t("tabs.tableData") }}
</Button>
</div>
<template v-if="resultRuns.length > 0">
<span class="mx-1 h-4 w-px shrink-0 bg-border" />
<div class="flex min-w-0 max-w-[35%] items-center gap-1 overflow-x-auto overflow-y-hidden px-1" :aria-label="t('tabs.resultRuns')">
<div v-for="run in resultRuns" :key="run.id" class="inline-flex shrink-0 items-center">
<Button
size="sm"
:variant="run.active ? 'default' : 'ghost'"
class="h-6 rounded-r-none px-2 text-xs"
@click="
queryStore.setActiveResultRun(activeTab.id, run.id);
emit('update:activeOutputView', 'result');
"
>
{{ t("tabs.runN", { n: run.sequence }) }}
</Button>
<Button size="icon" :variant="run.active ? 'default' : 'ghost'" class="h-6 w-6 rounded-l-none border-l border-border/50 px-0" :title="t('tabs.removeRun', { n: run.sequence })" :aria-label="t('tabs.removeRun', { n: run.sequence })" @click.stop="removeResultRun(run.id)">
<X class="h-3 w-3" />
</Button>
</div>
</div>
</template>
<template v-if="tabularResults.length > 1">
<span class="mx-1 h-4 w-px shrink-0 bg-border" />
<div class="relative min-w-0 flex-1 self-stretch">
@ -493,7 +551,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
</div>
</template>
<div class="ml-auto flex shrink-0 items-center gap-1">
<Button size="sm" :variant="activeOutputView === 'summary' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs gap-1" :disabled="!hasExecutionSummary" @click="emit('update:activeOutputView', 'summary')">
<Button size="sm" :variant="activeOutputView === 'summary' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs gap-1" :disabled="!hasExecutionSummary" @click="toggleExecutionSummary">
<ListChecks class="h-3.5 w-3.5" />
{{ t("tabs.executionSummary") }}
</Button>
@ -506,6 +564,11 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
<GitBranch class="h-3.5 w-3.5" />
{{ t("explain.title") }}
</Button>
<Button v-if="canExportResultArchive" variant="ghost" size="sm" class="h-6 shrink-0 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground" :disabled="resultArchiveExporting" @click="exportResultArchive">
<Loader2 v-if="resultArchiveExporting" class="h-3.5 w-3.5 animate-spin" />
<Download v-else class="h-3.5 w-3.5" />
{{ t("tabs.exportResultArchive") }}
</Button>
<Popover v-if="activeOutputView === 'result' && activeTab.result">
<PopoverTrigger as-child>
<Button
@ -600,8 +663,8 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
<DataGrid
v-if="activeTab.result && hasTabularResult"
ref="dataGridRef"
:key="`${activeTab.id}-${activeTab.activeResultIndex ?? 0}`"
:cache-key="`${activeTab.id}-${activeTab.activeResultIndex ?? 0}`"
:key="activeResultGridCacheKey"
:cache-key="activeResultGridCacheKey"
class="flex-1 min-h-0"
:result="activeTab.result"
:sort-column="activeTab.resultSortColumn"

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, watchEffect } from "vue";
import { useI18n } from "vue-i18n";
import { Play, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, Layers, X, Shield } from "@lucide/vue";
import { Play, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, Layers, X, Shield, Upload } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { SearchableSelect } from "@/components/ui/searchable-select";
@ -34,6 +34,7 @@ const emit = defineEmits<{
formatSql: [];
saveSql: [];
openSql: [];
importResultArchive: [];
changeConnection: [connectionId: string];
changeDatabase: [database: string];
changeSchema: [schema: string | undefined];
@ -191,6 +192,14 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
</TooltipTrigger>
<TooltipContent>{{ t("toolbar.openSql") }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-cyan-600 hover:bg-cyan-500/10 hover:text-cyan-700 dark:text-cyan-300 dark:hover:text-cyan-200" @click="emit('importResultArchive')">
<Upload class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("tabs.importResultArchive") }}</TooltipContent>
</Tooltip>
</div>
<span class="flex-1 min-w-0" />
<div class="flex items-center gap-2 shrink-0">

View File

@ -442,6 +442,19 @@
tooltipCollection: "Collection:",
tooltipSchema: "Schema:",
resultN: "Result {n}",
runN: "Run {n}",
resultRuns: "Result runs",
removeRun: "Remove run {n}",
missingResultRun: "This result is no longer available",
exportResultArchive: "Export Results",
importResultArchive: "Import Results",
importedResultArchive: "Imported results",
resultArchiveExported: "Results exported",
resultArchiveImported: "Results imported",
resultArchiveUnavailable: "No saved results are available to export",
resultArchiveImportInvalid: "This result archive could not be opened",
resultArchiveExportFailed: "Failed to export results: {message}",
resultArchiveImportFailed: "Failed to import results: {message}",
scrollLeft: "Scroll tabs left",
scrollRight: "Scroll tabs right",
openTabs: "Open tabs",

View File

@ -443,6 +443,19 @@
tooltipCollection: "集合:",
tooltipSchema: "Schema",
resultN: "结果 {n}",
runN: "执行 {n}",
resultRuns: "执行结果",
removeRun: "删除执行 {n}",
missingResultRun: "此结果已不可用",
exportResultArchive: "导出结果",
importResultArchive: "导入结果",
importedResultArchive: "导入的结果",
resultArchiveExported: "结果已导出",
resultArchiveImported: "结果已导入",
resultArchiveUnavailable: "没有可导出的已保存结果",
resultArchiveImportInvalid: "无法打开此结果归档",
resultArchiveExportFailed: "导出结果失败:{message}",
resultArchiveImportFailed: "导入结果失败:{message}",
scrollLeft: "向左滚动标签页",
scrollRight: "向右滚动标签页",
openTabs: "已打开的标签页",

View File

@ -1,5 +1,16 @@
import type { QueryTab } from "@/types/database";
export interface SavedQueryResultRun {
id: string;
title: string;
sequence: number;
sql: string;
createdAt: number;
activeResultIndex?: number;
resultCacheKey?: string;
resultEvicted?: boolean;
}
export interface SavedOpenTab {
id: string;
title: string;
@ -27,6 +38,8 @@ export interface SavedOpenTab {
tableMeta?: QueryTab["tableMeta"];
resultEvicted?: boolean;
resultCacheKey?: string;
resultRuns?: SavedQueryResultRun[];
activeResultRunId?: string;
}
export interface RestoredOpenTabs {
@ -62,6 +75,21 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
tableMeta: tab.tableMeta,
...(tab.mode !== "data" && tab.resultEvicted ? { resultEvicted: true } : {}),
...(tab.mode !== "data" && tab.resultEvicted && tab.resultCacheKey !== undefined ? { resultCacheKey: tab.resultCacheKey } : {}),
...(tab.mode === "query" && tab.resultRuns?.length
? {
resultRuns: tab.resultRuns.map((run) => ({
id: run.id,
title: run.title,
sequence: run.sequence,
sql: run.sql,
createdAt: run.createdAt,
activeResultIndex: run.activeResultIndex,
...(run.resultCacheKey !== undefined ? { resultCacheKey: run.resultCacheKey } : {}),
...(run.resultEvicted ? { resultEvicted: true } : {}),
})),
}
: {}),
...(tab.mode === "query" && tab.activeResultRunId !== undefined ? { activeResultRunId: tab.activeResultRunId } : {}),
}));
}
@ -82,6 +110,15 @@ export function restoreOpenTabsState(rawTabs: string | null, rawActiveTabId: str
const filtered = options.queryOnly ? saved.filter((tab) => (tab.mode ?? "query") === "query") : saved;
const tabs: QueryTab[] = filtered.map((tab) => {
const mode = tab.mode ?? "query";
const resultRuns =
mode === "query"
? tab.resultRuns?.map((run) => ({
...run,
result: undefined,
results: undefined,
resultCacheState: run.resultCacheKey ? ("disk" as const) : undefined,
}))
: undefined;
return {
...tab,
mode,
@ -94,6 +131,8 @@ export function restoreOpenTabsState(rawTabs: string | null, rawActiveTabId: str
resultEvicted: mode === "data" ? undefined : tab.resultEvicted,
resultCacheKey: mode === "data" ? undefined : tab.resultCacheKey,
resultCacheState: mode !== "data" && tab.resultCacheKey ? "disk" : undefined,
resultRuns,
activeResultRunId: resultRuns?.some((run) => run.id === tab.activeResultRunId) ? tab.activeResultRunId : resultRuns?.[0]?.id,
};
});
const activeTabId = rawActiveTabId || null;

View File

@ -0,0 +1,140 @@
import { decode, encode } from "@msgpack/msgpack";
import type { QueryTab } from "@/types/database";
import { decodeTabResultSnapshot, encodeTabResultSnapshot, type TabResultSnapshot } from "@/lib/tabResultCache";
const ARCHIVE_MAGIC = "DBX_QUERY_RESULT_ARCHIVE";
const ARCHIVE_VERSION = 1;
const ARCHIVE_CODEC = "msgpack-tab-result-snapshot";
export interface QueryResultArchiveTab {
title: string;
connectionId: string;
database: string;
schema?: string;
sql: string;
lastExecutedSql?: string;
resultBaseSql?: string;
resultSortedSql?: string;
}
export interface DecodedQueryResultArchive {
createdAt: number;
tab: QueryResultArchiveTab;
snapshot: TabResultSnapshot;
}
interface QueryResultArchiveEnvelope {
magic: typeof ARCHIVE_MAGIC;
version: typeof ARCHIVE_VERSION;
codec: typeof ARCHIVE_CODEC;
createdAt: number;
tab: QueryResultArchiveTab;
snapshot: Uint8Array;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isBinaryLike(value: unknown): boolean {
return value instanceof ArrayBuffer || ArrayBuffer.isView(value);
}
function removeUndefinedFields<T>(value: T): T {
if (Array.isArray(value)) return value.map((item) => removeUndefinedFields(item)) as T;
if (isBinaryLike(value)) return value;
if (!isRecord(value)) return value;
return Object.fromEntries(
Object.entries(value)
.filter(([, entryValue]) => entryValue !== undefined)
.map(([key, entryValue]) => [key, removeUndefinedFields(entryValue)]),
) as T;
}
function archiveTabMetadata(tab: QueryTab): QueryResultArchiveTab {
return removeUndefinedFields({
title: tab.title,
connectionId: tab.connectionId,
database: tab.database,
schema: tab.schema,
sql: tab.sql,
lastExecutedSql: tab.lastExecutedSql,
resultBaseSql: tab.resultBaseSql,
resultSortedSql: tab.resultSortedSql,
});
}
function isArchiveTab(value: unknown): value is QueryResultArchiveTab {
if (!isRecord(value)) return false;
return typeof value.title === "string" && typeof value.connectionId === "string" && typeof value.database === "string" && typeof value.sql === "string";
}
function binaryPayload(value: unknown): Uint8Array | undefined {
if (value instanceof Uint8Array) return value;
if (value instanceof ArrayBuffer) return new Uint8Array(value);
return undefined;
}
async function transformBytes(bytes: Uint8Array, stream: CompressionStream | DecompressionStream): Promise<Uint8Array> {
const output = new Response(stream.readable).arrayBuffer();
const writer = stream.writable.getWriter();
await writer.write(bytes.slice());
await writer.close();
return new Uint8Array(await output);
}
async function gzipBytes(bytes: Uint8Array): Promise<Uint8Array> {
if (typeof CompressionStream === "undefined") return bytes;
try {
return await transformBytes(bytes, new CompressionStream("gzip"));
} catch {
return bytes;
}
}
async function gunzipBytes(bytes: Uint8Array): Promise<Uint8Array> {
if (bytes[0] !== 0x1f || bytes[1] !== 0x8b || typeof DecompressionStream === "undefined") return bytes;
return transformBytes(bytes, new DecompressionStream("gzip"));
}
export function defaultQueryResultArchiveFileName(title: string | undefined): string {
const base = (title ?? "")
.trim()
.replace(/[^A-Za-z0-9._-]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 80);
return `${base || "query-results"}.dbxresults`;
}
export async function encodeQueryResultArchive(tab: QueryTab, snapshot: TabResultSnapshot): Promise<Uint8Array> {
const envelope: QueryResultArchiveEnvelope = {
magic: ARCHIVE_MAGIC,
version: ARCHIVE_VERSION,
codec: ARCHIVE_CODEC,
createdAt: Date.now(),
tab: archiveTabMetadata(tab),
snapshot: encodeTabResultSnapshot(snapshot),
};
return gzipBytes(encode(removeUndefinedFields(envelope)));
}
export async function decodeQueryResultArchive(bytes: Uint8Array | ArrayBuffer): Promise<DecodedQueryResultArchive | undefined> {
try {
const rawBytes = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
const decoded = decode(await gunzipBytes(rawBytes));
if (!isRecord(decoded)) return undefined;
if (decoded.magic !== ARCHIVE_MAGIC || decoded.version !== ARCHIVE_VERSION || decoded.codec !== ARCHIVE_CODEC) return undefined;
if (!isArchiveTab(decoded.tab)) return undefined;
const snapshotBytes = binaryPayload(decoded.snapshot);
if (!snapshotBytes) return undefined;
const snapshot = decodeTabResultSnapshot(snapshotBytes);
if (!snapshot) return undefined;
return {
createdAt: typeof decoded.createdAt === "number" ? decoded.createdAt : Date.now(),
tab: decoded.tab,
snapshot,
};
} catch {
return undefined;
}
}

View File

@ -0,0 +1,62 @@
import { isTauriRuntime } from "@/lib/tauriRuntime";
const ARCHIVE_MIME_TYPE = "application/vnd.dbx.results";
const ARCHIVE_EXTENSIONS = ["dbxresults"];
function downloadArchiveFile(fileName: string, bytes: Uint8Array): string {
const blob = new Blob([bytes.slice().buffer], { type: ARCHIVE_MIME_TYPE });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
return fileName;
}
function openArchiveFileInBrowser(): Promise<Uint8Array | undefined> {
return new Promise((resolve, reject) => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".dbxresults,application/octet-stream,application/vnd.dbx.results";
input.onchange = async () => {
try {
const file = input.files?.[0];
if (!file) {
resolve(undefined);
return;
}
resolve(new Uint8Array(await file.arrayBuffer()));
} catch (error) {
reject(error);
}
};
input.click();
});
}
export async function saveQueryResultArchiveFile(fileName: string, bytes: Uint8Array): Promise<string | undefined> {
if (!isTauriRuntime()) return downloadArchiveFile(fileName, bytes);
const [{ save }, { writeFile }] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
const path = await save({
defaultPath: fileName,
filters: [{ name: "DBX Result Archive", extensions: ARCHIVE_EXTENSIONS }],
});
if (!path) return undefined;
await writeFile(path, bytes);
return path;
}
export async function openQueryResultArchiveFile(): Promise<Uint8Array | undefined> {
if (!isTauriRuntime()) return openArchiveFileInBrowser();
const [{ open }, { readFile }] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
const selected = await open({
multiple: false,
filters: [{ name: "DBX Result Archive", extensions: ARCHIVE_EXTENSIONS }],
});
const path = Array.isArray(selected) ? selected[0] : selected;
if (!path) return undefined;
return readFile(path);
}

View File

@ -3,6 +3,7 @@ import { useSettingsStore } from "@/stores/settingsStore";
import type { ConnectionConfig, QueryResult, QueryTab } from "@/types/database";
type Translate = (key: string, params?: Record<string, unknown>) => string;
export type OutputView = "result" | "summary" | "explain" | "chart";
export function connectionDisplayName(connectionId: string): string {
const connectionStore = useConnectionStore();
@ -119,6 +120,28 @@ export function tabularResultItems(results: QueryResult[] | undefined): { result
.map((item, ordinal) => ({ ...item, n: ordinal + 1 }));
}
export function activeResultRun(tab: Pick<QueryTab, "resultRuns" | "activeResultRunId">) {
return tab.resultRuns?.find((run) => run.id === tab.activeResultRunId);
}
export function resultRunItems(tab: Pick<QueryTab, "resultRuns" | "activeResultRunId">): { id: string; title: string; sequence: number; active: boolean }[] {
return (tab.resultRuns ?? []).map((run) => ({
id: run.id,
title: run.title,
sequence: run.sequence,
active: run.id === tab.activeResultRunId,
}));
}
export function resultGridCacheKey(tab: Pick<QueryTab, "id" | "activeResultRunId" | "activeResultIndex">): string {
return `${tab.id}-${tab.activeResultRunId ?? "current"}-${tab.activeResultIndex ?? 0}`;
}
export function nextExecutionSummaryView(currentView: OutputView, canShowResult: boolean): OutputView {
if (currentView === "summary" && canShowResult) return "result";
return "summary";
}
export interface ExecutionSummaryItem {
result: QueryResult;
index: number;

View File

@ -15,6 +15,8 @@ export interface TabResultSnapshot {
result?: QueryResult;
results?: QueryResult[];
activeResultIndex?: number;
resultRuns?: QueryTab["resultRuns"];
activeResultRunId?: string;
queryAnalysis?: QueryTab["queryAnalysis"];
querySourceColumns?: QueryTab["querySourceColumns"];
queryEditabilityReason?: QueryTab["queryEditabilityReason"];
@ -38,11 +40,19 @@ interface ColumnarQueryResult {
has_more?: boolean;
}
interface TabResultSnapshotPayload extends Omit<TabResultSnapshot, "result" | "results"> {
type QueryResultRunSnapshot = NonNullable<QueryTab["resultRuns"]>[number];
interface ColumnarQueryResultRun extends Omit<QueryResultRunSnapshot, "result" | "results"> {
result?: ColumnarQueryResult;
results?: ColumnarQueryResult[];
}
interface TabResultSnapshotPayload extends Omit<TabResultSnapshot, "result" | "results" | "resultRuns"> {
result?: ColumnarQueryResult;
results?: ColumnarQueryResult[];
resultRuns?: ColumnarQueryResultRun[];
}
interface TabResultCacheEnvelope {
magic: typeof PAYLOAD_MAGIC;
version: typeof PAYLOAD_VERSION;
@ -126,6 +136,15 @@ function stripResultSessionIds(results: QueryResult[] | undefined): QueryResult[
return results?.map((result) => stripSessionIds(result)!);
}
function stripResultRunSessionIds(resultRuns: QueryTab["resultRuns"]): QueryTab["resultRuns"] {
return resultRuns?.map((run) => ({
...run,
result: stripSessionIds(run.result),
results: stripResultSessionIds(run.results),
resultSessionId: undefined,
}));
}
function toColumnarResult(result: QueryResult | undefined): ColumnarQueryResult | undefined {
if (!result) return undefined;
const columnValues = result.columns.map((_, colIndex) => result.rows.map((row) => row[colIndex] ?? null));
@ -161,6 +180,13 @@ function snapshotToPayload(snapshot: TabResultSnapshot): TabResultSnapshotPayloa
...snapshot,
result: toColumnarResult(snapshot.result),
results: snapshot.results?.map((result) => toColumnarResult(result)!),
resultRuns: snapshot.resultRuns?.map((run) =>
removeUndefinedFields({
...run,
result: toColumnarResult(run.result),
results: run.results?.map((result) => toColumnarResult(result)!),
}),
),
});
}
@ -169,11 +195,17 @@ function payloadToSnapshot(payload: TabResultSnapshotPayload): TabResultSnapshot
...payload,
result: fromColumnarResult(payload.result),
results: payload.results?.map((result) => fromColumnarResult(result)!),
resultRuns: payload.resultRuns?.map((run) => ({
...run,
result: fromColumnarResult(run.result),
results: run.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];
const activeRun = snapshot.resultRuns?.find((run) => run.id === snapshot.activeResultRunId) ?? snapshot.resultRuns?.[0];
const result = snapshot.result ?? snapshot.results?.[snapshot.activeResultIndex ?? 0] ?? snapshot.results?.[0] ?? activeRun?.result ?? activeRun?.results?.[activeRun.activeResultIndex ?? 0] ?? activeRun?.results?.[0];
return {
rowCount: result?.rows.length ?? 0,
columnCount: result?.columns.length ?? 0,
@ -317,11 +349,13 @@ export function tabResultCacheKey(tabId: string): string {
}
export function buildTabResultSnapshot(tab: QueryTab): TabResultSnapshot | undefined {
if (!tab.result && !tab.results) return undefined;
if (!tab.result && !tab.results && !tab.resultRuns?.length) return undefined;
return {
result: stripSessionIds(tab.result),
results: stripResultSessionIds(tab.results),
activeResultIndex: tab.activeResultIndex,
resultRuns: stripResultRunSessionIds(tab.resultRuns),
activeResultRunId: tab.activeResultRunId,
queryAnalysis: tab.queryAnalysis ? clonePlain(tab.queryAnalysis) : undefined,
querySourceColumns: tab.querySourceColumns ? [...tab.querySourceColumns] : undefined,
queryEditabilityReason: tab.queryEditabilityReason,

View File

@ -32,6 +32,7 @@ import { connectionUsesDatabaseObjectTreeMode, connectionUsesSchemaExecutionCont
import { queryTimeoutSecsForConnection } from "@/lib/queryTimeout";
import { clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor";
import { buildTabResultSnapshot, deleteTabResultSnapshot, readTabResultSnapshot, tabResultCacheKey, writeTabResultSnapshot } from "@/lib/tabResultCache";
import { decodeQueryResultArchive, encodeQueryResultArchive, type DecodedQueryResultArchive } from "@/lib/queryResultArchive";
import * as api from "@/lib/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
@ -51,6 +52,14 @@ function markQueryResultsRowsRaw(results: QueryResult[]): QueryResult[] {
return results;
}
function markQueryResultRunsRowsRaw(resultRuns: NonNullable<QueryTab["resultRuns"]>): NonNullable<QueryTab["resultRuns"]> {
for (const run of resultRuns) {
if (run.result) markQueryResultRowsRaw(run.result);
if (run.results) markQueryResultsRowsRaw(run.results);
}
return resultRuns;
}
async function withFrontendQueryTimeout<T>(promise: Promise<T>, timeoutSecs: number, message: string): Promise<T> {
if (timeoutSecs === 0) return promise;
@ -186,6 +195,149 @@ export const useQueryStore = defineStore("query", () => {
}
}
function projectResultRun(tab: QueryTab, run: NonNullable<QueryTab["resultRuns"]>[number]) {
const activeIndex = run.activeResultIndex ?? 0;
tab.activeResultRunId = run.id;
tab.result = run.result ?? run.results?.[activeIndex];
tab.results = run.results;
tab.activeResultIndex = run.activeResultIndex;
tab.resultBaseSql = run.resultBaseSql;
tab.resultSortedSql = run.resultSortedSql;
tab.resultSortColumn = run.resultSortColumn;
tab.resultSortColumnIndex = run.resultSortColumnIndex;
tab.resultSortDirection = run.resultSortDirection;
tab.orderByInput = run.orderByInput;
tab.resultPageSql = run.resultPageSql;
tab.resultPageLimit = run.resultPageLimit;
tab.resultPageOffset = run.resultPageOffset;
tab.resultCountSql = run.resultCountSql;
tab.resultTotalRowCount = run.resultTotalRowCount;
tab.resultTotalRowCountLoading = run.resultTotalRowCountLoading;
tab.resultSessionId = run.resultSessionId;
tab.resultAccessedAt = run.resultAccessedAt;
tab.resultCacheKey = run.resultCacheKey;
tab.resultCacheState = run.resultCacheState;
tab.resultEvicted = run.resultEvicted;
tab.queryAnalysis = run.queryAnalysis;
tab.querySourceColumns = run.querySourceColumns;
tab.queryEditabilityReason = run.queryEditabilityReason;
tab.tableMeta = run.tableMeta;
touchResult(tab);
}
function setActiveResultRun(id: string, runId: string) {
const tab = tabs.value.find((t) => t.id === id);
const run = tab?.resultRuns?.find((item) => item.id === runId);
if (!tab || !run) return false;
projectResultRun(tab, run);
return true;
}
function removeResultRun(id: string, runId: string) {
const tab = tabs.value.find((t) => t.id === id);
const runIndex = tab?.resultRuns?.findIndex((run) => run.id === runId) ?? -1;
if (!tab || !tab.resultRuns || runIndex < 0) return false;
const wasActive = tab.activeResultRunId === runId;
const remainingRuns = tab.resultRuns.filter((run) => run.id !== runId);
tab.resultRuns = remainingRuns;
if (!wasActive) return true;
const nextRun = remainingRuns[Math.min(runIndex, remainingRuns.length - 1)];
if (nextRun) {
projectResultRun(tab, nextRun);
return true;
}
tab.activeResultRunId = undefined;
clearResultPayload(tab);
return true;
}
function nextResultRunSequence(tab: QueryTab): number {
return (tab.resultRuns?.reduce((max, run) => Math.max(max, run.sequence), 0) ?? 0) + 1;
}
function captureDisplayedResultRun(tab: QueryTab, sql: string, createdAt = Date.now()) {
if (tab.mode !== "query" || !tab.result) return;
const sequence = nextResultRunSequence(tab);
const run: NonNullable<QueryTab["resultRuns"]>[number] = {
id: uuid(),
title: `Run ${sequence}`,
sequence,
sql,
createdAt,
result: tab.result,
results: tab.results,
activeResultIndex: tab.activeResultIndex,
resultBaseSql: tab.resultBaseSql,
resultSortedSql: tab.resultSortedSql,
resultSortColumn: tab.resultSortColumn,
resultSortColumnIndex: tab.resultSortColumnIndex,
resultSortDirection: tab.resultSortDirection,
orderByInput: tab.orderByInput,
resultPageSql: tab.resultPageSql,
resultPageLimit: tab.resultPageLimit,
resultPageOffset: tab.resultPageOffset,
resultCountSql: tab.resultCountSql,
resultTotalRowCount: tab.resultTotalRowCount,
resultTotalRowCountLoading: tab.resultTotalRowCountLoading,
resultSessionId: tab.resultSessionId,
resultAccessedAt: tab.resultAccessedAt,
resultCacheKey: tab.resultCacheKey,
resultCacheState: tab.resultCacheState,
resultEvicted: tab.resultEvicted,
queryAnalysis: tab.queryAnalysis,
querySourceColumns: tab.querySourceColumns,
queryEditabilityReason: tab.queryEditabilityReason,
tableMeta: tab.tableMeta,
};
tab.resultRuns = [...(tab.resultRuns ?? []), run];
tab.activeResultRunId = run.id;
}
function syncActiveResultRunFromDisplayed(tab: QueryTab) {
if (!tab.activeResultRunId || !tab.resultRuns?.length) return;
const index = tab.resultRuns.findIndex((run) => run.id === tab.activeResultRunId);
if (index < 0) return;
tab.resultRuns[index] = {
...tab.resultRuns[index],
result: tab.result,
results: tab.results,
activeResultIndex: tab.activeResultIndex,
resultBaseSql: tab.resultBaseSql,
resultSortedSql: tab.resultSortedSql,
resultSortColumn: tab.resultSortColumn,
resultSortColumnIndex: tab.resultSortColumnIndex,
resultSortDirection: tab.resultSortDirection,
orderByInput: tab.orderByInput,
resultPageSql: tab.resultPageSql,
resultPageLimit: tab.resultPageLimit,
resultPageOffset: tab.resultPageOffset,
resultCountSql: tab.resultCountSql,
resultTotalRowCount: tab.resultTotalRowCount,
resultTotalRowCountLoading: tab.resultTotalRowCountLoading,
resultSessionId: tab.resultSessionId,
resultAccessedAt: tab.resultAccessedAt,
resultCacheKey: tab.resultCacheKey,
resultCacheState: tab.resultCacheState,
resultEvicted: tab.resultEvicted,
queryAnalysis: tab.queryAnalysis,
querySourceColumns: tab.querySourceColumns,
queryEditabilityReason: tab.queryEditabilityReason,
tableMeta: tab.tableMeta,
};
}
function resultRunHasPayload(run: NonNullable<QueryTab["resultRuns"]>[number]): boolean {
return !!run.result || !!run.results?.length;
}
function resultSnapshotHasPayload(snapshot: NonNullable<ReturnType<typeof buildTabResultSnapshot>>): boolean {
return !!snapshot.result || !!snapshot.results?.length || !!snapshot.resultRuns?.some(resultRunHasPayload);
}
async function evictCachedResult(tab: QueryTab) {
await closeResultSession(tab);
const cacheKey = tabResultCacheKey(tab.id);
@ -885,6 +1037,7 @@ export const useQueryStore = defineStore("query", () => {
const current = tabs.value.find((t) => t.id === tabId);
if (patch && current?.result === result) {
applyQueryMetadataPatch(current, patch);
syncActiveResultRunFromDisplayed(current);
console.info("[DBX][executeTabSql:metadata:done]", { traceId, elapsed: elapsed() });
} else {
console.warn("[DBX][executeTabSql:metadata:stale]", { traceId, elapsed: elapsed() });
@ -898,6 +1051,7 @@ export const useQueryStore = defineStore("query", () => {
if (current.executionId !== executionId && current.result !== result) return;
current.resultTotalRowCount = totalRowCount;
current.resultTotalRowCountLoading = false;
syncActiveResultRunFromDisplayed(current);
}
function countQueryTotalRowsInBackground(options: { tabId: string; connectionId: string; database: string; schema?: string; countSql?: string; result: QueryResult; pageLimit?: number; pageOffset?: number; executionId: string; traceId: string; elapsed: () => string; timeoutSecs: number }) {
@ -1019,6 +1173,7 @@ export const useQueryStore = defineStore("query", () => {
current.tableMeta = undefined;
current.resultBaseSql = options?.resultBaseSql ?? sql;
current.resultSortedSql = options?.resultSortedSql;
captureDisplayedResultRun(current, options?.resultBaseSql ?? sql);
}
return;
}
@ -1065,6 +1220,7 @@ export const useQueryStore = defineStore("query", () => {
current.tableMeta = undefined;
current.resultBaseSql = options?.resultBaseSql ?? sql;
current.resultSortedSql = options?.resultSortedSql;
captureDisplayedResultRun(current, options?.resultBaseSql ?? sql);
}
return;
}
@ -1090,6 +1246,7 @@ export const useQueryStore = defineStore("query", () => {
current.tableMeta = undefined;
current.resultBaseSql = options?.resultBaseSql ?? sql;
current.resultSortedSql = options?.resultSortedSql;
captureDisplayedResultRun(current, options?.resultBaseSql ?? sql);
}
return;
}
@ -1121,6 +1278,7 @@ export const useQueryStore = defineStore("query", () => {
current.tableMeta = undefined;
current.resultBaseSql = options?.resultBaseSql ?? sql;
current.resultSortedSql = options?.resultSortedSql;
captureDisplayedResultRun(current, options?.resultBaseSql ?? sql);
}
return;
}
@ -1147,6 +1305,7 @@ export const useQueryStore = defineStore("query", () => {
current.tableMeta = undefined;
current.resultBaseSql = options?.resultBaseSql ?? sql;
current.resultSortedSql = options?.resultSortedSql;
captureDisplayedResultRun(current, options?.resultBaseSql ?? sql);
}
return;
}
@ -1187,6 +1346,7 @@ export const useQueryStore = defineStore("query", () => {
current.tableMeta = undefined;
current.resultBaseSql = options?.resultBaseSql ?? sql;
current.resultSortedSql = options?.resultSortedSql;
captureDisplayedResultRun(current, options?.resultBaseSql ?? sql);
}
return;
}
@ -1251,6 +1411,7 @@ export const useQueryStore = defineStore("query", () => {
current.resultTotalRowCountLoading = false;
}
touchResult(current);
captureDisplayedResultRun(current, queryBaseSql);
if (current.mode === "query" && current.result) {
countQueryTotalRowsInBackground({
tabId: id,
@ -1304,6 +1465,7 @@ export const useQueryStore = defineStore("query", () => {
current.resultTotalRowCount = undefined;
current.resultTotalRowCountLoading = false;
touchResult(current);
captureDisplayedResultRun(current, queryBaseSql);
}
} finally {
const current = tabs.value.find((t) => t.id === id);
@ -1473,6 +1635,7 @@ export const useQueryStore = defineStore("query", () => {
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.queryEditabilityReason = undefined;
syncActiveResultRunFromDisplayed(tab);
}
function notifyConnectionMayBeLost() {
@ -1507,7 +1670,9 @@ export const useQueryStore = defineStore("query", () => {
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.resultRuns = snapshot.resultRuns ? markQueryResultRunsRowsRaw(snapshot.resultRuns) : tab.resultRuns;
tab.activeResultRunId = snapshot.activeResultRunId ?? tab.activeResultRunId;
if (!tab.result && !tab.results && !tab.resultRuns) return false;
tab.queryAnalysis = snapshot.queryAnalysis;
tab.querySourceColumns = snapshot.querySourceColumns;
@ -1526,6 +1691,56 @@ export const useQueryStore = defineStore("query", () => {
return true;
}
async function resultArchiveSnapshotForTab(tab: QueryTab) {
let snapshot = buildTabResultSnapshot(tab);
if (tab.resultCacheKey && (!snapshot || tab.resultEvicted || !resultSnapshotHasPayload(snapshot))) {
snapshot = (await readTabResultSnapshot(tab.resultCacheKey)) ?? snapshot;
}
return snapshot && resultSnapshotHasPayload(snapshot) ? snapshot : undefined;
}
async function exportResultArchive(id: string): Promise<Uint8Array | undefined> {
const tab = tabs.value.find((t) => t.id === id);
if (!tab || tab.mode !== "query") return undefined;
const snapshot = await resultArchiveSnapshotForTab(tab);
if (!snapshot) return undefined;
return encodeQueryResultArchive(tab, snapshot);
}
function openResultArchiveTab(archive: DecodedQueryResultArchive): string | undefined {
const id = uuid();
const title = archive.tab.title.trim() || t("tabs.importedResultArchive");
const tab: QueryTab = {
id,
title,
customTitle: true,
connectionId: archive.tab.connectionId,
database: archive.tab.database,
schema: archive.tab.schema,
sql: archive.tab.sql,
originalSql: archive.tab.sql,
lastExecutedSql: archive.tab.lastExecutedSql,
resultBaseSql: archive.tab.resultBaseSql,
resultSortedSql: archive.tab.resultSortedSql,
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "query",
};
if (!restoreCachedResultPayload(tab, archive.snapshot)) return undefined;
const activeRun = tab.resultRuns?.find((run) => run.id === tab.activeResultRunId) ?? tab.resultRuns?.[0];
if (activeRun) projectResultRun(tab, activeRun);
tabs.value.push(tab);
activeTabId.value = id;
return id;
}
async function importResultArchive(bytes: Uint8Array | ArrayBuffer): Promise<string | undefined> {
const archive = await decodeQueryResultArchive(bytes);
if (!archive) return undefined;
return openResultArchiveTab(archive);
}
async function reloadEvictedTab(id: string) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab || !tab.resultEvicted) return;
@ -1716,6 +1931,8 @@ export const useQueryStore = defineStore("query", () => {
setExecuting,
setExecutingWithId,
setErrorResult,
setActiveResultRun,
removeResultRun,
setActiveResultIndex,
executeCurrentTab,
executeCurrentSql,
@ -1724,6 +1941,8 @@ export const useQueryStore = defineStore("query", () => {
cancelTabExecution,
cancelTabExplain,
reloadEvictedTab,
exportResultArchive,
importResultArchive,
fetchTabResultForExport,
notifyConnectionMayBeLost,
};

View File

@ -324,6 +324,38 @@ export interface QueryResult {
has_more?: boolean;
}
export interface QueryResultRun {
id: string;
title: string;
sequence: number;
sql: string;
createdAt: number;
result?: QueryResult;
results?: QueryResult[];
activeResultIndex?: number;
resultBaseSql?: string;
resultSortedSql?: string;
resultSortColumn?: string;
resultSortColumnIndex?: number;
resultSortDirection?: "asc" | "desc";
orderByInput?: string;
resultPageSql?: string;
resultPageLimit?: number;
resultPageOffset?: number;
resultCountSql?: string;
resultTotalRowCount?: number;
resultTotalRowCountLoading?: boolean;
resultSessionId?: string;
resultAccessedAt?: number;
resultCacheKey?: string;
resultCacheState?: "memory" | "disk" | "missing";
resultEvicted?: boolean;
queryAnalysis?: QueryTab["queryAnalysis"];
querySourceColumns?: QueryTab["querySourceColumns"];
queryEditabilityReason?: QueryTab["queryEditabilityReason"];
tableMeta?: QueryTab["tableMeta"];
}
export interface SqlTextSpan {
start_line: number;
start_column: number;
@ -459,6 +491,8 @@ export interface QueryTab {
result?: QueryResult;
results?: QueryResult[];
activeResultIndex?: number;
resultRuns?: QueryResultRun[];
activeResultRunId?: string;
explainPlan?: import("@/lib/explainPlan").ParsedExplainPlan;
explainError?: string;
explainSql?: string;

View File

@ -0,0 +1,58 @@
# Query Result Archive Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add `.dbxresults` export/import so a query tab's saved execution-result runs can be restored later.
**Architecture:** Add a focused archive codec around the existing tab-result-cache snapshot codec. The query store exposes bytes-in/bytes-out methods, while Vue components handle file dialogs, browser download/upload, and toasts.
**Tech Stack:** Vue 3, Pinia, TypeScript, MessagePack via `@msgpack/msgpack`, existing DBX tab-result-cache snapshot codec, Vitest.
---
### Task 1: Archive Codec
**Files:**
- Create: `apps/desktop/src/lib/queryResultArchive.ts`
- Test: `packages/app-tests/queryResultArchive.test.ts`
- [ ] Write failing tests for encoding/decoding a query result archive with multiple runs, rejecting invalid bytes, and producing a binary payload smaller than equivalent JSON for repeated rows.
- [ ] Run `pnpm vitest run packages/app-tests/queryResultArchive.test.ts` and confirm it fails because the module does not exist.
- [ ] Implement `encodeQueryResultArchive`, `decodeQueryResultArchive`, `defaultQueryResultArchiveFileName`, and archive metadata types.
- [ ] Run `pnpm vitest run packages/app-tests/queryResultArchive.test.ts` and confirm it passes.
- [ ] Commit `feat(query): add result archive codec`.
### Task 2: Query Store Import/Export
**Files:**
- Modify: `apps/desktop/src/stores/queryStore.ts`
- Test: `packages/app-tests/queryStore.test.ts`
- [ ] Write a failing store test that exports a query tab with two result runs and imports the archive into a new tab with the active run restored.
- [ ] Run the focused test and confirm it fails because store archive methods do not exist.
- [ ] Add `exportResultArchive(tabId)` and `importResultArchive(bytes)` to the query store. Export should read evicted cache payloads when needed. Import should create a new query tab and project the active archived run into the result grid.
- [ ] Run the focused store test and confirm it passes.
- [ ] Commit `feat(query): restore result archives`.
### Task 3: UI Actions
**Files:**
- Modify: `apps/desktop/src/components/layout/ContentArea.vue`
- Modify: `apps/desktop/src/components/layout/EditorToolbar.vue`
- Modify: `apps/desktop/src/App.vue`
- Modify: `apps/desktop/src/i18n/locales/en.ts`
- Modify: `apps/desktop/src/i18n/locales/zh-CN.ts`
- [ ] Add an import icon button to the query editor toolbar and wire it to App.
- [ ] Add an export button to the result pane when query output exists.
- [ ] Implement Tauri save/open using `@tauri-apps/plugin-dialog` and `@tauri-apps/plugin-fs`; implement browser fallback using Blob download and an `<input type="file">`.
- [ ] Add English and Simplified Chinese UI strings.
- [ ] Run `pnpm typecheck` and confirm it passes.
- [ ] Commit `feat(query): add result archive actions`.
### Task 4: Final Verification
- [ ] Run `pnpm typecheck`.
- [ ] Run `pnpm vitest run packages/app-tests/queryResultArchive.test.ts packages/app-tests/queryStore.test.ts packages/app-tests/openTabsPersistence.test.ts packages/app-tests/tabResultCache.test.ts packages/app-tests/tabPresentation.test.ts`.
- [ ] Run `pnpm build`.
- [ ] Confirm `git status --short` is clean after commits.

View File

@ -132,6 +132,74 @@ test("restores evicted result cache handles as disk-backed runtime state", () =>
assert.equal(restored.tabs[0]?.resultCacheState, "disk");
});
test("serializes query result run metadata without row payloads", () => {
const saved = serializeOpenTabs([
queryTab({
activeResultRunId: "run-2",
resultRuns: [
{
id: "run-1",
title: "Run 1",
sequence: 1,
sql: "select 1",
createdAt: 100,
result: {
columns: ["id"],
rows: [[1]],
affected_rows: 0,
execution_time_ms: 1,
},
resultCacheKey: "tab:tab-1:run:run-1",
resultCacheState: "disk",
resultEvicted: true,
},
],
}),
]);
assert.deepEqual(saved[0]?.resultRuns, [
{
id: "run-1",
title: "Run 1",
sequence: 1,
sql: "select 1",
createdAt: 100,
activeResultIndex: undefined,
resultCacheKey: "tab:tab-1:run:run-1",
resultEvicted: true,
},
]);
assert.equal(JSON.stringify(saved).includes("[[1]]"), false);
assert.equal(saved[0]?.activeResultRunId, "run-2");
});
test("restores query result run metadata as disk-backed runtime state", () => {
const raw = JSON.stringify([
{
...queryTab(),
activeResultRunId: "run-1",
resultRuns: [
{
id: "run-1",
title: "Run 1",
sequence: 1,
sql: "select 1",
createdAt: 100,
resultCacheKey: "tab:tab-1:run:run-1",
resultEvicted: true,
},
],
},
]);
const restored = restoreOpenTabsState(raw, "tab-1");
assert.equal(restored.tabs[0]?.activeResultRunId, "run-1");
assert.equal(restored.tabs[0]?.resultRuns?.[0]?.id, "run-1");
assert.equal(restored.tabs[0]?.resultRuns?.[0]?.resultCacheState, "disk");
assert.equal(restored.tabs[0]?.resultRuns?.[0]?.result, undefined);
});
test("ignores legacy table data result cache handles on restore", () => {
const raw = JSON.stringify([queryTab({ mode: "data", resultEvicted: true, resultCacheKey: "tab:tab-1:result" })]);

View File

@ -0,0 +1,190 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { buildTabResultSnapshot } from "../../apps/desktop/src/lib/tabResultCache.ts";
import { decodeQueryResultArchive, defaultQueryResultArchiveFileName, encodeQueryResultArchive } from "../../apps/desktop/src/lib/queryResultArchive.ts";
import type { QueryTab } from "../../apps/desktop/src/types/database.ts";
function queryTab(overrides: Partial<QueryTab> = {}): QueryTab {
return {
id: "tab-1",
title: "Revenue / daily check",
connectionId: "conn-1",
database: "warehouse",
schema: "public",
sql: "select * from revenue",
lastExecutedSql: "select * from revenue",
isExecuting: false,
mode: "query",
...overrides,
};
}
test("query result archives round-trip query tab metadata and result runs", async () => {
const tab = queryTab({
resultRuns: [
{
id: "run-1",
title: "Run 1",
sequence: 1,
sql: "select 1",
createdAt: 10,
result: {
columns: ["id", "name"],
rows: [
[1, "Ada"],
[2, "Linus"],
],
affected_rows: 0,
execution_time_ms: 3,
session_id: "live-session",
},
},
{
id: "run-2",
title: "Run 2",
sequence: 2,
sql: "select 2",
createdAt: 20,
result: {
columns: ["id", "status"],
rows: [[2, "paid"]],
affected_rows: 0,
execution_time_ms: 5,
},
},
],
activeResultRunId: "run-2",
result: {
columns: ["id", "status"],
rows: [[2, "paid"]],
affected_rows: 0,
execution_time_ms: 5,
},
});
const snapshot = buildTabResultSnapshot(tab);
assert.ok(snapshot);
const bytes = await encodeQueryResultArchive(tab, snapshot);
const decoded = await decodeQueryResultArchive(bytes);
assert.ok(bytes instanceof Uint8Array);
assert.equal(decoded?.tab.title, "Revenue / daily check");
assert.equal(decoded?.tab.connectionId, "conn-1");
assert.equal(decoded?.tab.database, "warehouse");
assert.equal(decoded?.tab.schema, "public");
assert.equal(decoded?.tab.sql, "select * from revenue");
assert.equal(decoded?.snapshot.activeResultRunId, "run-2");
assert.deepEqual(decoded?.snapshot.resultRuns?.map((run) => run.sequence), [1, 2]);
assert.deepEqual(decoded?.snapshot.resultRuns?.[0]?.result?.rows, [
[1, "Ada"],
[2, "Linus"],
]);
assert.equal(decoded?.snapshot.resultRuns?.[0]?.result?.session_id, undefined);
});
test("query result archives reject invalid files", async () => {
assert.equal(await decodeQueryResultArchive(new Uint8Array([1, 2, 3, 4])), undefined);
});
test("query result archive file names are safe and use dbxresults extension", () => {
assert.equal(defaultQueryResultArchiveFileName("Revenue / daily check"), "Revenue_daily_check.dbxresults");
assert.equal(defaultQueryResultArchiveFileName(""), "query-results.dbxresults");
});
test("query result archives are compact for repeated tabular values", async () => {
const rows = Array.from({ length: 100 }, (_, index) => [index, "same-region", "same-status"]);
const tab = queryTab({
title: "Repeated values",
result: {
columns: ["id", "region", "status"],
rows,
affected_rows: 0,
execution_time_ms: 7,
},
});
const snapshot = buildTabResultSnapshot(tab);
assert.ok(snapshot);
const bytes = await encodeQueryResultArchive(tab, snapshot);
const jsonSize = new TextEncoder().encode(JSON.stringify({ tab, snapshot })).length;
assert.ok(bytes.length < jsonSize, `expected archive ${bytes.length} bytes to be smaller than JSON ${jsonSize} bytes`);
});
test("query result archive compression starts reading before writing to avoid stream backpressure", async () => {
const originalCompressionStream = Object.getOwnPropertyDescriptor(globalThis, "CompressionStream");
const originalResponse = Object.getOwnPropertyDescriptor(globalThis, "Response");
class BackpressureCompressionStream {
private chunk?: Uint8Array;
private readerStarted = false;
private releaseWrite?: () => void;
readable = {
startReading: () => {
this.readerStarted = true;
this.releaseWrite?.();
this.releaseWrite = undefined;
},
arrayBuffer: () => {
const bytes = this.chunk ?? new Uint8Array();
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
},
};
writable = {
getWriter: () => ({
write: async (chunk: Uint8Array | ArrayBuffer) => {
this.chunk = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
if (!this.readerStarted) {
await new Promise<void>((resolve) => {
this.releaseWrite = resolve;
});
}
},
close: async () => {},
}),
};
}
class BackpressureResponse {
constructor(private readonly readable: { startReading?: () => void; arrayBuffer?: () => ArrayBuffer }) {
this.readable.startReading?.();
}
async arrayBuffer() {
return this.readable.arrayBuffer?.() ?? new ArrayBuffer(0);
}
}
Object.defineProperty(globalThis, "CompressionStream", {
configurable: true,
value: BackpressureCompressionStream,
});
Object.defineProperty(globalThis, "Response", {
configurable: true,
value: BackpressureResponse,
});
try {
const tab = queryTab({
result: {
columns: ["id"],
rows: [[1]],
affected_rows: 0,
execution_time_ms: 1,
},
});
const snapshot = buildTabResultSnapshot(tab);
assert.ok(snapshot);
const result = await Promise.race([encodeQueryResultArchive(tab, snapshot), new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 100))]);
assert.notEqual(result, "timeout");
assert.ok(result instanceof Uint8Array);
} finally {
if (originalCompressionStream) Object.defineProperty(globalThis, "CompressionStream", originalCompressionStream);
else Reflect.deleteProperty(globalThis, "CompressionStream");
if (originalResponse) Object.defineProperty(globalThis, "Response", originalResponse);
else Reflect.deleteProperty(globalThis, "Response");
}
});

View File

@ -2,6 +2,7 @@ import { strict as assert } from "node:assert";
import { test } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { isReactive } from "vue";
import { decodeQueryResultArchive } from "../../apps/desktop/src/lib/queryResultArchive.ts";
import { useConnectionStore } from "../../apps/desktop/src/stores/connectionStore.ts";
import { useQueryStore } from "../../apps/desktop/src/stores/queryStore.ts";
import type { ConnectionConfig } from "../../apps/desktop/src/types/database.ts";
@ -132,6 +133,361 @@ test("editing query sql preserves the displayed result editability state", () =>
assert.equal(tab.tableMeta?.tableName, "users");
});
test("selecting a result run restores its displayed result without changing SQL draft", () => {
setActivePinia(createPinia());
const store = useQueryStore();
const tabId = store.createTab("conn-1", "db");
const tab = store.tabs.find((item) => item.id === tabId);
assert.ok(tab);
tab.sql = "select draft";
tab.resultRuns = [
{
id: "run-1",
title: "Run 1",
sequence: 1,
sql: "select 1",
createdAt: 1,
result: { columns: ["one"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 },
resultBaseSql: "select 1",
},
{
id: "run-2",
title: "Run 2",
sequence: 2,
sql: "select 2",
createdAt: 2,
result: { columns: ["two"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 },
resultBaseSql: "select 2",
},
];
tab.activeResultRunId = "run-2";
store.setActiveResultRun(tabId, "run-1");
assert.equal(tab.sql, "select draft");
assert.equal(tab.activeResultRunId, "run-1");
assert.deepEqual(tab.result?.columns, ["one"]);
assert.deepEqual(tab.result?.rows, [[1]]);
assert.equal(tab.resultBaseSql, "select 1");
});
test("removing the active result run selects an adjacent run", () => {
setActivePinia(createPinia());
const store = useQueryStore();
const tabId = store.createTab("conn-1", "db");
const tab = store.tabs.find((item) => item.id === tabId);
assert.ok(tab);
tab.sql = "select draft";
tab.resultRuns = [
{
id: "run-1",
title: "Run 1",
sequence: 1,
sql: "select 1",
createdAt: 1,
result: { columns: ["one"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 },
resultBaseSql: "select 1",
},
{
id: "run-2",
title: "Run 2",
sequence: 2,
sql: "select 2",
createdAt: 2,
result: { columns: ["two"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 },
resultBaseSql: "select 2",
},
{
id: "run-3",
title: "Run 3",
sequence: 3,
sql: "select 3",
createdAt: 3,
result: { columns: ["three"], rows: [[3]], affected_rows: 0, execution_time_ms: 1 },
resultBaseSql: "select 3",
},
];
store.setActiveResultRun(tabId, "run-2");
assert.equal(store.removeResultRun(tabId, "run-2"), true);
assert.deepEqual(tab.resultRuns?.map((run) => run.id), ["run-1", "run-3"]);
assert.equal(tab.activeResultRunId, "run-3");
assert.deepEqual(tab.result?.columns, ["three"]);
assert.deepEqual(tab.result?.rows, [[3]]);
assert.equal(tab.sql, "select draft");
assert.equal(store.removeResultRun(tabId, "run-3"), true);
assert.deepEqual(tab.resultRuns?.map((run) => run.id), ["run-1"]);
assert.equal(tab.activeResultRunId, "run-1");
assert.deepEqual(tab.result?.columns, ["one"]);
});
test("removed result runs are excluded from result archives", async () => {
setActivePinia(createPinia());
const store = useQueryStore();
const tabId = store.createTab("conn-1", "db", "Revenue checks", "query", "public");
const tab = store.tabs.find((item) => item.id === tabId);
assert.ok(tab);
tab.sql = "select draft";
tab.resultRuns = [
{
id: "run-1",
title: "Run 1",
sequence: 1,
sql: "select 1",
createdAt: 1,
result: { columns: ["one"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 },
resultBaseSql: "select 1",
},
{
id: "run-2",
title: "Run 2",
sequence: 2,
sql: "select 2",
createdAt: 2,
result: { columns: ["two"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 },
resultBaseSql: "select 2",
},
];
store.setActiveResultRun(tabId, "run-2");
assert.equal(store.removeResultRun(tabId, "run-1"), true);
const archive = await store.exportResultArchive(tabId);
assert.ok(archive);
const decoded = await decodeQueryResultArchive(archive);
assert.deepEqual(decoded?.snapshot.resultRuns?.map((run) => run.id), ["run-2"]);
assert.deepEqual(decoded?.snapshot.resultRuns?.[0]?.result?.columns, ["two"]);
assert.deepEqual(decoded?.snapshot.resultRuns?.[0]?.result?.rows, [[2]]);
});
test("removing the last result run clears output and makes result archive unavailable", async () => {
setActivePinia(createPinia());
const store = useQueryStore();
const tabId = store.createTab("conn-1", "db");
const tab = store.tabs.find((item) => item.id === tabId);
assert.ok(tab);
tab.resultRuns = [
{
id: "run-1",
title: "Run 1",
sequence: 1,
sql: "select 1",
createdAt: 1,
result: { columns: ["one"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 },
resultBaseSql: "select 1",
},
];
store.setActiveResultRun(tabId, "run-1");
assert.equal(store.removeResultRun(tabId, "run-1"), true);
assert.deepEqual(tab.resultRuns, []);
assert.equal(tab.activeResultRunId, undefined);
assert.equal(tab.result, undefined);
assert.equal(tab.results, undefined);
assert.equal(await store.exportResultArchive(tabId), undefined);
});
test("result archives import into a new query tab with switchable runs", async () => {
setActivePinia(createPinia());
const store = useQueryStore();
const tabId = store.createTab("conn-1", "db", "Revenue checks", "query", "public");
const tab = store.tabs.find((item) => item.id === tabId);
assert.ok(tab);
tab.sql = "select draft";
tab.lastExecutedSql = "select 2";
tab.resultRuns = [
{
id: "run-1",
title: "Run 1",
sequence: 1,
sql: "select 1",
createdAt: 1,
result: { columns: ["one"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 },
resultBaseSql: "select 1",
},
{
id: "run-2",
title: "Run 2",
sequence: 2,
sql: "select 2",
createdAt: 2,
result: { columns: ["two"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 },
resultBaseSql: "select 2",
},
];
tab.activeResultRunId = "run-2";
store.setActiveResultRun(tabId, "run-2");
const archive = await store.exportResultArchive(tabId);
assert.ok(archive);
const importedTabId = await store.importResultArchive(archive);
assert.ok(importedTabId);
assert.notEqual(importedTabId, tabId);
const imported = store.tabs.find((item) => item.id === importedTabId);
assert.equal(imported?.title, "Revenue checks");
assert.equal(imported?.customTitle, true);
assert.equal(imported?.connectionId, "conn-1");
assert.equal(imported?.database, "db");
assert.equal(imported?.schema, "public");
assert.equal(imported?.sql, "select draft");
assert.equal(imported?.activeResultRunId, "run-2");
assert.deepEqual(imported?.result?.columns, ["two"]);
assert.deepEqual(imported?.result?.rows, [[2]]);
store.setActiveResultRun(importedTabId, "run-1");
assert.deepEqual(imported?.result?.columns, ["one"]);
assert.deepEqual(imported?.result?.rows, [[1]]);
});
test("completed query executions append result runs and select the latest run", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const store = useQueryStore();
const originalFetch = globalThis.fetch;
let executeCount = 0;
connectionStore.addEphemeralConnection(conn("conn-1"));
globalThis.fetch = (async (input, init) => {
const url = String(input);
if (url === "/api/query/prepare-pagination-plan") {
const body = JSON.parse(String(init?.body ?? "{}"));
return new Response(JSON.stringify({ sqlToExecute: body.options.sql, useAgentResultSession: false }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (url === "/api/query/execute-multi") {
executeCount++;
return new Response(
JSON.stringify([{ columns: [`run_${executeCount}`], rows: [[executeCount]], affected_rows: 0, execution_time_ms: 1 }]),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (url === "/api/query/analyze-editability") {
return new Response(JSON.stringify({ editable: false, reason: "complex-source" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
try {
const tabId = store.createTab("conn-1", "db", "Query");
await store.executeTabSql(tabId, "select 1");
await store.executeTabSql(tabId, "select 2");
const tab = store.tabs.find((item) => item.id === tabId);
assert.equal(tab?.resultRuns?.length, 2);
assert.deepEqual(tab?.resultRuns?.map((run) => run.title), ["Run 1", "Run 2"]);
assert.equal(tab?.resultRuns?.[0]?.sql, "select 1");
assert.equal(tab?.resultRuns?.[1]?.sql, "select 2");
assert.equal(tab?.activeResultRunId, tab?.resultRuns?.[1]?.id);
assert.deepEqual(tab?.result?.columns, ["run_2"]);
store.setActiveResultRun(tabId, tab!.resultRuns![0]!.id);
assert.deepEqual(tab?.result?.columns, ["run_1"]);
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
}
});
test("failed query executions append switchable error result runs", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const store = useQueryStore();
const originalFetch = globalThis.fetch;
connectionStore.addEphemeralConnection(conn("conn-1"));
globalThis.fetch = (async (input, init) => {
const url = String(input);
if (url === "/api/query/prepare-pagination-plan") {
const body = JSON.parse(String(init?.body ?? "{}"));
return new Response(JSON.stringify({ sqlToExecute: body.options.sql, useAgentResultSession: false }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (url === "/api/query/execute-multi") {
return new Response("backend exploded", { status: 500 });
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
try {
const tabId = store.createTab("conn-1", "db", "Query");
await store.executeTabSql(tabId, "select broken");
const tab = store.tabs.find((item) => item.id === tabId);
assert.equal(tab?.resultRuns?.length, 1);
assert.equal(tab?.activeResultRunId, tab?.resultRuns?.[0]?.id);
assert.deepEqual(tab?.resultRuns?.[0]?.result?.columns, ["Error"]);
assert.deepEqual(tab?.result?.columns, ["Error"]);
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
}
});
test("statement result switching is scoped to the active result run", () => {
setActivePinia(createPinia());
const store = useQueryStore();
const tabId = store.createTab("conn-1", "db");
const tab = store.tabs.find((item) => item.id === tabId);
assert.ok(tab);
tab.resultRuns = [
{
id: "run-1",
title: "Run 1",
sequence: 1,
sql: "select 1; select 10",
createdAt: 1,
results: [
{ columns: ["a"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 },
{ columns: ["b"], rows: [[10]], affected_rows: 0, execution_time_ms: 1 },
],
activeResultIndex: 0,
},
{
id: "run-2",
title: "Run 2",
sequence: 2,
sql: "select 2; select 20",
createdAt: 2,
results: [
{ columns: ["c"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 },
{ columns: ["d"], rows: [[20]], affected_rows: 0, execution_time_ms: 1 },
],
activeResultIndex: 0,
},
];
tab.activeResultRunId = "run-1";
store.setActiveResultRun(tabId, "run-1");
store.setActiveResultIndex(tabId, 1);
assert.deepEqual(tab.result?.columns, ["b"]);
assert.equal(tab.resultRuns[0]?.activeResultIndex, 1);
store.setActiveResultRun(tabId, "run-2");
assert.deepEqual(tab.result?.columns, ["c"]);
assert.equal(tab.activeResultIndex, 0);
});
test("normalizes unquoted Oracle query identifiers before loading editable metadata", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());

View File

@ -1,7 +1,16 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { databaseDisplayNameForTab, executionSummaryItems, tabDisplayTitle, tabularResultItems } from "../../apps/desktop/src/lib/tabPresentation.ts";
import {
activeResultRun,
databaseDisplayNameForTab,
executionSummaryItems,
nextExecutionSummaryView,
resultGridCacheKey,
resultRunItems,
tabDisplayTitle,
tabularResultItems,
} from "../../apps/desktop/src/lib/tabPresentation.ts";
import { useConnectionStore } from "../../apps/desktop/src/stores/connectionStore.ts";
import type { ConnectionConfig, QueryResult, QueryTab } from "../../apps/desktop/src/types/database.ts";
@ -118,6 +127,44 @@ test("tabular result items hide statement results without returned columns", ()
assert.deepEqual(tabularResultItems(undefined), []);
});
test("result run items expose ordered labels and active state", () => {
const tab = queryTab({
activeResultRunId: "run-2",
resultRuns: [
{
id: "run-1",
title: "Run 1",
sequence: 1,
sql: "select 1",
createdAt: 10,
result: result(["one"]),
},
{
id: "run-2",
title: "Run 2",
sequence: 2,
sql: "select 2",
createdAt: 20,
result: result(["two"]),
},
],
});
assert.deepEqual(resultRunItems(tab), [
{ id: "run-1", title: "Run 1", sequence: 1, active: false },
{ id: "run-2", title: "Run 2", sequence: 2, active: true },
]);
assert.equal(activeResultRun(tab)?.id, "run-2");
assert.deepEqual(resultRunItems(queryTab()).map((item) => item.title), []);
});
test("result grid cache key includes result run id and statement result index", () => {
const tab = queryTab({ activeResultRunId: "run-7", activeResultIndex: 3 });
assert.equal(resultGridCacheKey(tab), "tab-1-run-7-3");
assert.equal(resultGridCacheKey(queryTab({ activeResultIndex: undefined })), "tab-1-current-0");
});
test("execution summary items include table and non-table statement results", () => {
const items = executionSummaryItems({
results: [result([]), result(["id"]), { ...result(["Error"]), rows: [["boom"]] }],
@ -138,3 +185,10 @@ test("execution summary items include table and non-table statement results", ()
],
);
});
test("execution summary button toggles back to result only when result view is available", () => {
assert.equal(nextExecutionSummaryView("result", true), "summary");
assert.equal(nextExecutionSummaryView("chart", true), "summary");
assert.equal(nextExecutionSummaryView("summary", true), "result");
assert.equal(nextExecutionSummaryView("summary", false), "summary");
});

View File

@ -46,6 +46,32 @@ test("result snapshots strip live session handles and clone result rows", () =>
assert.deepEqual(snapshot?.result?.rows, [[1]]);
});
test("result snapshots strip session handles from result runs", () => {
const tab = queryTab({
resultRuns: [
{
id: "run-1",
title: "Run 1",
sequence: 1,
sql: "select 1",
createdAt: 1,
result: {
columns: ["id"],
rows: [[1]],
affected_rows: 0,
execution_time_ms: 1,
session_id: "live-run-session",
},
},
],
});
const snapshot = buildTabResultSnapshot(tab);
assert.equal(snapshot?.resultRuns?.[0]?.result?.session_id, undefined);
assert.deepEqual(snapshot?.resultRuns?.[0]?.result?.rows, [[1]]);
});
test("result snapshots encode as binary columnar payloads and decode back to rows", () => {
const snapshot = buildTabResultSnapshot(
queryTab({