diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 8a61d1fd3..5142e4ba4 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -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" diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 1c042bba4..307a227c9 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -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 | 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") }} +
- @@ -506,6 +564,11 @@ defineExpose({ focusSearch, refreshData, handleModRTarget }); {{ t("explain.title") }} + + + {{ t("tabs.importResultArchive") }} +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 408e2e60f..282f3283f 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 18c9aaf94..5019bd856 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -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: "已打开的标签页", diff --git a/apps/desktop/src/lib/openTabsPersistence.ts b/apps/desktop/src/lib/openTabsPersistence.ts index 1a86a656d..a7aaa7d86 100644 --- a/apps/desktop/src/lib/openTabsPersistence.ts +++ b/apps/desktop/src/lib/openTabsPersistence.ts @@ -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; diff --git a/apps/desktop/src/lib/queryResultArchive.ts b/apps/desktop/src/lib/queryResultArchive.ts new file mode 100644 index 000000000..fd5fc5637 --- /dev/null +++ b/apps/desktop/src/lib/queryResultArchive.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isBinaryLike(value: unknown): boolean { + return value instanceof ArrayBuffer || ArrayBuffer.isView(value); +} + +function removeUndefinedFields(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 { + 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 { + if (typeof CompressionStream === "undefined") return bytes; + try { + return await transformBytes(bytes, new CompressionStream("gzip")); + } catch { + return bytes; + } +} + +async function gunzipBytes(bytes: Uint8Array): Promise { + 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 { + 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 { + 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; + } +} diff --git a/apps/desktop/src/lib/queryResultArchiveFile.ts b/apps/desktop/src/lib/queryResultArchiveFile.ts new file mode 100644 index 000000000..66540f04d --- /dev/null +++ b/apps/desktop/src/lib/queryResultArchiveFile.ts @@ -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 { + 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 { + 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 { + 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); +} diff --git a/apps/desktop/src/lib/tabPresentation.ts b/apps/desktop/src/lib/tabPresentation.ts index 06c518ce8..4fccaacf9 100644 --- a/apps/desktop/src/lib/tabPresentation.ts +++ b/apps/desktop/src/lib/tabPresentation.ts @@ -3,6 +3,7 @@ import { useSettingsStore } from "@/stores/settingsStore"; import type { ConnectionConfig, QueryResult, QueryTab } from "@/types/database"; type Translate = (key: string, params?: Record) => 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) { + return tab.resultRuns?.find((run) => run.id === tab.activeResultRunId); +} + +export function resultRunItems(tab: Pick): { 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): 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; diff --git a/apps/desktop/src/lib/tabResultCache.ts b/apps/desktop/src/lib/tabResultCache.ts index 42e910800..11f4c379e 100644 --- a/apps/desktop/src/lib/tabResultCache.ts +++ b/apps/desktop/src/lib/tabResultCache.ts @@ -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 { +type QueryResultRunSnapshot = NonNullable[number]; + +interface ColumnarQueryResultRun extends Omit { result?: ColumnarQueryResult; results?: ColumnarQueryResult[]; } +interface TabResultSnapshotPayload extends Omit { + 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, diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 1e8fc4777..64880821a 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -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): NonNullable { + for (const run of resultRuns) { + if (run.result) markQueryResultRowsRaw(run.result); + if (run.results) markQueryResultsRowsRaw(run.results); + } + return resultRuns; +} + async function withFrontendQueryTimeout(promise: Promise, timeoutSecs: number, message: string): Promise { if (timeoutSecs === 0) return promise; @@ -186,6 +195,149 @@ export const useQueryStore = defineStore("query", () => { } } + function projectResultRun(tab: QueryTab, run: NonNullable[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[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[number]): boolean { + return !!run.result || !!run.results?.length; + } + + function resultSnapshotHasPayload(snapshot: NonNullable>): 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 { + 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 { + 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, }; diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 1121036a5..6e627117d 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -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; diff --git a/docs/superpowers/plans/2026-06-14-query-result-archive.md b/docs/superpowers/plans/2026-06-14-query-result-archive.md new file mode 100644 index 000000000..527b00444 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-query-result-archive.md @@ -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 ``. +- [ ] 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. diff --git a/packages/app-tests/openTabsPersistence.test.ts b/packages/app-tests/openTabsPersistence.test.ts index 1bf7c6cbd..d6ee30e74 100644 --- a/packages/app-tests/openTabsPersistence.test.ts +++ b/packages/app-tests/openTabsPersistence.test.ts @@ -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" })]); diff --git a/packages/app-tests/queryResultArchive.test.ts b/packages/app-tests/queryResultArchive.test.ts new file mode 100644 index 000000000..5c2503d32 --- /dev/null +++ b/packages/app-tests/queryResultArchive.test.ts @@ -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 { + 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((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"); + } +}); diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index 4f13b8998..3b3975041 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -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()); diff --git a/packages/app-tests/tabPresentation.test.ts b/packages/app-tests/tabPresentation.test.ts index 7733fac9c..b633d5012 100644 --- a/packages/app-tests/tabPresentation.test.ts +++ b/packages/app-tests/tabPresentation.test.ts @@ -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"); +}); diff --git a/packages/app-tests/tabResultCache.test.ts b/packages/app-tests/tabResultCache.test.ts index cffb80079..87727c016 100644 --- a/packages/app-tests/tabResultCache.test.ts +++ b/packages/app-tests/tabResultCache.test.ts @@ -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({