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:
parent
cbc7f9b0db
commit
97192297eb
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "已打开的标签页",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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" })]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in New Issue