feat(editor): merge multi-database query results into one view

This commit is contained in:
goodvecn-dev authored and GitHub committed 2026-09-23 10:36:40 +08:00
1 parent 9d07708617
commit 8b822e974b
31 files changed
+3085 -87

No files matched your search

+5 -1
View File
@@ -18,6 +18,8 @@ import type { ConfigTab } from "@/components/connection/ConnectionDialog.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSqlExecutionDangerStore } from "@/stores/sqlExecutionDangerStore";
import type { MultiDbExecutionContext } from "@/composables/useMultiDbExecution";
import type { MultiDbExecutionTarget } from "@/types/sqlExecution";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { enforceRightSidebarPanelExclusivity, RIGHT_SIDEBAR_PANEL_IDS, transitionRightSidebarPanels, useSettingsStore, type RightSidebarPanelId, type RightSidebarPanelState } from "@/stores/settingsStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
@@ -1005,7 +1007,7 @@ function multiExecuteTargetLabel(target: { connectionId: string; catalog?: strin
return [connection?.name || target.connectionId, target.catalog, target.database, target.schema].filter((value) => value !== undefined && value !== "").join(" / ");
}
async function executeMultiDbTarget(input: { target: { connectionId: string; catalog?: string; database: string; schema?: string }; sourceTabId: string; sql: string; scopeId: string; context: { sourceOffset?: number; manualTransaction?: boolean }; isCancellationRequested: () => boolean }) {
async function executeMultiDbTarget(input: { target: MultiDbExecutionTarget; sourceTabId: string; sql: string; scopeId: string; context: Readonly<MultiDbExecutionContext>; isCancellationRequested: () => boolean }) {
const tab = queryStore.tabs.find((candidate) => candidate.id === input.sourceTabId);
const connection = connectionStore.getConfig(input.target.connectionId);
if (!tab || !connection) return { status: "failed" as const, errorMessage: t("multiDbExecute.targetMissingConnection") };
@@ -1023,6 +1025,8 @@ async function executeMultiDbTarget(input: { target: { connectionId: string; cat
manualTransaction: input.context.manualTransaction,
blockDangerousRedisCommands: blockDangerousRedisCommands.value,
targetLabel: multiExecuteTargetLabel(input.target),
// The confirmation must list the whole fan-out, not just this target.
batchTargetLabels: input.context.targets.map(multiExecuteTargetLabel),
scopeId: input.scopeId,
isCancellationRequested: input.isCancellationRequested,
targetContext: sqlExecutionTargetCapabilities(connection)?.provider.toExecutionContext(input.target, connection),
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { AlertTriangle, Check, CheckSquare, ChevronDown, ChevronRight, FolderOpen, Layers, Loader2, MinusSquare, Pencil, Play, RefreshCw, Search, Settings2, Square, Trash2, X } from "@lucide/vue";
import { AlertTriangle, Check, CheckSquare, ChevronDown, ChevronRight, Download, FolderOpen, Layers, Loader2, MinusSquare, Pencil, Play, RefreshCw, Search, Settings2, Square, Trash2, X } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -9,11 +9,13 @@ import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import MultiSourceMergeView from "@/components/editor/MultiSourceMergeView.vue";
import { useToast } from "@/composables/useToast";
import { useMultiDbExecution, type MultiDbExecutionAdapter, type MultiDbExecutionMode } from "@/composables/useMultiDbExecution";
import { formatDataTransferDuration, useExportTracker } from "@/composables/useExportTracker";
import { useMultiDbTargetSelection, type MultiDbTargetCatalogOption, type SqlExecutionTargetValidationReason } from "@/composables/useMultiDbTargetSelection";
import { useSqlExecutionTargetGroupStore } from "@/stores/sqlExecutionTargetGroupStore";
import { useSqlExecutionDangerStore } from "@/stores/sqlExecutionDangerStore";
import { dedupeMultiDbExecutionTargets, multiDbExecutionTargetKey, type MultiDbExecutionTarget, type MultiDbExecutionItemStatus, type SqlExecutionTargetGroup, type SqlExecutionTargetValidation } from "@/types/sqlExecution";
import type { ConnectionConfig, DatabaseType } from "@/types/database";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
@@ -40,6 +42,7 @@ const { t } = useI18n();
const { toast } = useToast();
const { addMultiDbExecutionTask, updateMultiDbExecutionTask, registerTaskCancelHandler, unregisterTaskCancelHandler } = useExportTracker();
const targetGroupStore = useSqlExecutionTargetGroupStore();
const sqlExecutionDangerStore = useSqlExecutionDangerStore();
const targetSelection = useMultiDbTargetSelection(computed(() => props.databaseType));
const compatibleConnections = targetSelection.compatibleConnections;
@@ -123,11 +126,137 @@ const progressCounts = computed(() => {
pendingCommit: items.filter((item) => item.status === "pending_commit").length,
};
});
const elapsedMs = computed(() => {
/**
* A target parked on the danger confirmation is waiting for the operator, not
* executing: that wait must not count as batch time or as target time.
*/
const awaitingConfirmation = computed(() => {
const pending = sqlExecutionDangerStore.pending;
return pending !== undefined && pending.scopeId !== undefined && pending.scopeId === batch.value?.id;
});
const confirmationWaitMs = ref(0);
/** Value shown while a prompt is on screen, so the clock visibly stands still. */
const frozenElapsedMs = ref<number>();
let confirmationWaitStartedAt: number | undefined;
/** Batch time with every answered prompt already taken out. */
function elapsedWithoutWaits(): number {
const current = batch.value;
if (!current) return 0;
return current.durationMs ?? Math.max(0, (current.completedAt ?? currentTime.value) - current.startedAt);
});
const total = current.durationMs ?? Math.max(0, (current.completedAt ?? currentTime.value) - current.startedAt);
return Math.max(0, total - confirmationWaitMs.value);
}
watch(
() => [batch.value?.id, awaitingConfirmation.value] as const,
([batchId, waiting], [previousBatchId, wasWaiting]) => {
if (batchId !== previousBatchId) {
confirmationWaitMs.value = 0;
confirmationWaitStartedAt = undefined;
frozenElapsedMs.value = undefined;
}
if (waiting) {
confirmationWaitStartedAt = Date.now();
frozenElapsedMs.value = elapsedWithoutWaits();
return;
}
if (wasWaiting && confirmationWaitStartedAt !== undefined) {
confirmationWaitMs.value += Date.now() - confirmationWaitStartedAt;
confirmationWaitStartedAt = undefined;
frozenElapsedMs.value = undefined;
}
},
{ flush: "sync" },
);
/** Label of the target whose confirmation is on screen, when one is parked. */
const awaitingTargetLabel = computed(() => (awaitingConfirmation.value ? sqlExecutionDangerStore.pending?.targetLabel : undefined));
function isAwaitingTarget(item: { target: MultiDbExecutionTarget; status: MultiDbExecutionItemStatus }): boolean {
if (item.status !== "running" || awaitingTargetLabel.value === undefined) return false;
return targetLabel(item.target) === awaitingTargetLabel.value;
}
const elapsedMs = computed(() => (awaitingConfirmation.value && frozenElapsedMs.value !== undefined ? frozenElapsedMs.value : elapsedWithoutWaits()));
/** Merged multi-source view over the results this batch produced. */
const mergeViewOpen = ref(false);
const mergeViewRef = ref<InstanceType<typeof MultiSourceMergeView>>();
const mergeItems = computed(() =>
(batch.value?.items ?? []).map((item) => ({
key: item.id,
label: targetLabel(item.target),
result: item.result,
status: item.status,
durationMs: item.durationMs,
errorMessage: item.errorMessage,
...(item.transaction ? { transaction: { canCommit: item.transaction.canCommit, settling: item.settling === true } } : {}),
})),
);
/** Targets whose result is worth merging, settled or still inside a transaction. */
const mergedResultCount = computed(() => (batch.value?.items ?? []).filter((item) => item.status === "success" || item.status === "pending_commit").length);
/** Merging needs more than one source; the view also reports failed targets. */
const canShowMergedResults = computed(() => !isExecuting.value && (batch.value?.items.length ?? 0) >= 2);
const batchSettled = computed(() => batch.value !== undefined && batch.value.status !== "running" && batch.value.status !== "cancelling");
/** The batch opened a transaction per target, so writes stay reviewable. */
const mergeBatchIsTransactional = computed(() => batch.value?.context.manualTransaction === true || (batch.value?.items ?? []).some((item) => item.transaction !== undefined || item.status === "pending_commit" || item.status === "rolled_back"));
/**
* Settles one target's transaction through the batch's own state machine.
* `commit` writes the changes of that target only; a target that errored has
* nothing to commit and is rolled back by the executor instead.
*/
async function settleMergeTargetTransaction(itemId: string, action: "commit" | "rollback"): Promise<void> {
const item = batch.value?.items.find((candidate) => candidate.id === itemId);
if (!item?.transaction || item.settling) return;
const settled = await execution.finishTransaction(itemId, action);
if (!settled) toast(item.errorMessage ? t("multiDbExecute.txnActionFailed", { message: item.errorMessage }) : t("common.failed"), 5000);
}
/** Commits every target that succeeded, leaving errored ones for a decision. */
async function commitAllMergeTargets(): Promise<void> {
const targets = (batch.value?.items ?? []).filter((item) => item.transaction?.canCommit === true);
for (const item of targets) await settleMergeTargetTransaction(item.id, "commit");
}
/** Discards every still-open transaction of the batch. */
async function rollbackAllMergeTargets(): Promise<void> {
const targets = (batch.value?.items ?? []).filter((item) => item.transaction !== undefined);
for (const item of targets) await settleMergeTargetTransaction(item.id, "rollback");
}
function revealMergedView(): void {
mergeViewOpen.value = true;
}
/**
* Re-runs the batch's statement on one target only — the merged view uses it to
* retry a target that failed without redoing the ones that succeeded.
*/
async function rerunMergeTarget(itemId: string): Promise<void> {
await execution.retry(itemId);
}
/**
* A finished batch that produced data on several targets is worth looking at,
* so reopening the dialog lands directly in the merged view instead of making
* the user click through the progress list again.
*/
function revealMergedViewWhenSettled(): void {
if (mergeViewOpen.value || !canShowMergedResults.value || !batchSettled.value) return;
revealMergedView();
}
/**
* The batch may finish while the dialog is minimized to the background task
* list. Announce it with an action that jumps straight into the merged view.
*/
function announceMergedViewInBackground(current: NonNullable<typeof batch.value>): void {
if (open.value || current.cancelRequested) return;
if (current.status !== "completed" || mergedResultCount.value < 2) return;
toast(t("multiDbExecute.backgroundBatchDone", { count: mergedResultCount.value }), 8000, {
label: t("multiDbExecute.mergeResults"),
onClick: () => {
open.value = true;
revealMergedView();
},
});
}
function syncBackgroundTask(current: NonNullable<typeof batch.value>): void {
const items = current.items;
@@ -169,10 +298,14 @@ watch(
addMultiDbExecutionTask(current.id, t("multiDbExecute.title"), current.sourceTabId, () => {
open.value = true;
});
registerTaskCancelHandler(current.id, () => execution.cancel());
}
syncBackgroundTask(current);
if (current.status === "completed" || current.status === "cancelled") unregisterTaskCancelHandler(current.id);
if (current.status === "running") {
registerTaskCancelHandler(current.id, () => execution.cancel());
} else if (current.status === "completed" || current.status === "cancelled") {
unregisterTaskCancelHandler(current.id);
announceMergedViewInBackground(current);
}
},
{ deep: true, flush: "sync" },
);
@@ -675,6 +808,7 @@ function openForNewBatch(): void {
executionMode.value = "serial";
manualTransaction.value = props.initialManualTransaction === true && supportsManualTransaction.value;
trackedBatchId = undefined;
mergeViewOpen.value = false;
manageGroups.value = false;
selectedGroupId.value = undefined;
searchText.value = "";
@@ -733,10 +867,15 @@ watch(
watch(
() => [open.value, props.launchId] as const,
([value, launchId]) => {
if (value && launchId !== initializedLaunchId) {
if (!value) return;
if (launchId !== initializedLaunchId) {
initializedLaunchId = launchId;
openForNewBatch();
return;
}
// Reopened from the background task list: skip the progress list when the
// batch already has results worth merging.
revealMergedViewWhenSettled();
},
{ immediate: true },
);
@@ -756,22 +895,38 @@ watch(
<DialogHeader class="shrink-0 border-b px-5 py-3">
<DialogTitle class="flex items-center gap-2">
<Layers class="h-5 w-5 text-primary" />
{{ executionStarted ? t("multiDbExecute.progress", { completed: progressCompleted, total: batch?.items.length ?? 0 }) : t("multiDbExecute.title") }}
{{ mergeViewOpen ? t("multiDbExecute.mergeResults") : executionStarted ? t("multiDbExecute.progress", { completed: progressCompleted, total: batch?.items.length ?? 0 }) : t("multiDbExecute.title") }}
</DialogTitle>
</DialogHeader>
<div v-if="executionStarted && batch" class="flex min-h-0 flex-1 flex-col">
<div class="flex shrink-0 flex-wrap items-center gap-2 border-b bg-muted/20 px-5 py-3 text-xs text-muted-foreground tabular-nums">
<div class="flex shrink-0 flex-wrap items-center gap-2 border-b bg-muted/20 px-5 py-3 text-xs text-muted-foreground tabular-nums" :data-multi-db-batch-id="batch.id">
<span>{{ t("multiDbExecute.progress", { completed: progressCompleted, total: batch.items.length }) }}</span>
<Badge variant="secondary">{{ executionModeLabel() }}</Badge>
<span class="tabular-nums">{{ t("multiDbExecute.elapsed", { duration: formatDataTransferDuration(elapsedMs) }) }}</span>
<span class="tabular-nums" data-multi-db-elapsed>{{ t("multiDbExecute.elapsed", { duration: formatDataTransferDuration(elapsedMs) }) }}</span>
<Badge variant="outline">{{ t("multiDbExecute.success") }} {{ progressCounts.success }}</Badge>
<Badge variant="outline">{{ t("multiDbExecute.failed") }} {{ progressCounts.failed }}</Badge>
<Badge variant="outline">{{ t("multiDbExecute.skipped") }} {{ progressCounts.skipped }}</Badge>
<Badge variant="outline">{{ t("multiDbExecute.notExecuted") }} {{ progressCounts.notExecuted }}</Badge>
<Badge v-if="progressCounts.pendingCommit" variant="outline">{{ t("multiDbExecute.pendingCommit") }} {{ progressCounts.pendingCommit }}</Badge>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<MultiSourceMergeView
v-if="mergeViewOpen"
ref="mergeViewRef"
:items="mergeItems"
:sql="batch.sql"
:duration-ms="elapsedMs"
:executed-at="batch.startedAt"
:show-export-actions="false"
:rerun-disabled="isExecuting"
:transactional="mergeBatchIsTransactional"
@rerun-target="rerunMergeTarget"
@commit-target="(key: string) => settleMergeTargetTransaction(key, 'commit')"
@rollback-target="(key: string) => settleMergeTargetTransaction(key, 'rollback')"
@commit-all="commitAllMergeTargets"
@rollback-all="rollbackAllMergeTargets"
/>
<div v-else class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div class="space-y-2">
<div v-for="item in batch.items" :key="item.id" class="flex min-w-0 items-start gap-3 rounded-md border px-3 py-2">
<Loader2 v-if="item.status === 'running'" class="mt-0.5 h-4 w-4 shrink-0 animate-spin text-primary" />
@@ -781,7 +936,7 @@ watch(
<div v-else class="mt-0.5 h-4 w-4 shrink-0 rounded-full border border-muted-foreground/40" />
<div class="min-w-0 flex-1">
<div class="truncate text-sm" :class="statusClass(item.status)">{{ targetLabel(item.target) }}</div>
<div class="text-xs text-muted-foreground">{{ statusLabel(item.status) }}</div>
<div class="text-xs text-muted-foreground">{{ isAwaitingTarget(item) ? t("multiDbExecute.awaitingConfirmation") : statusLabel(item.status) }}</div>
<div v-if="item.durationMs !== undefined" class="text-xs tabular-nums text-muted-foreground">{{ t("multiDbExecute.elapsed", { duration: formatDataTransferDuration(item.durationMs) }) }}</div>
<div v-if="item.errorMessage" class="mt-1 whitespace-pre-wrap break-words text-xs text-destructive">{{ item.errorMessage }}</div>
</div>
@@ -793,12 +948,30 @@ watch(
</div>
</div>
<DialogFooter class="mx-0 mb-0 shrink-0 border-t px-5 py-3">
<Button v-if="isExecuting" variant="destructive" class="gap-1.5" @click="execution.cancel()">
<X class="h-4 w-4" />
{{ t("multiDbExecute.cancelBatch") }}
</Button>
<Button v-if="isExecuting && !batch.context.manualTransaction" variant="outline" @click="requestClose">{{ t("exportProgress.minimize") }}</Button>
<Button v-else @click="requestClose">{{ t("common.close") }}</Button>
<template v-if="mergeViewOpen">
<Button variant="outline" data-multi-db-merge-back @click="mergeViewOpen = false">{{ t("multiDbExecute.mergeResultsBack") }}</Button>
<Button variant="outline" data-multi-db-merge-export-page @click="mergeViewRef?.exportMergedRows('page')">
<Download class="h-4 w-4" />
{{ t("multiDbExecute.exportCurrentPage") }}
</Button>
<Button variant="outline" data-multi-db-merge-export-all @click="mergeViewRef?.exportMergedRows('all')">
<Download class="h-4 w-4" />
{{ t("multiDbExecute.exportAllRows") }}
</Button>
<Button @click="requestClose">{{ t("common.close") }}</Button>
</template>
<template v-else>
<Button v-if="isExecuting" variant="destructive" class="gap-1.5" @click="execution.cancel()">
<X class="h-4 w-4" />
{{ t("multiDbExecute.cancelBatch") }}
</Button>
<Button v-if="isExecuting && !batch.context.manualTransaction" variant="outline" @click="requestClose">{{ t("exportProgress.minimize") }}</Button>
<Button v-if="canShowMergedResults" variant="outline" class="gap-1.5" data-multi-db-merge @click="mergeViewOpen = true">
<Layers class="h-4 w-4" />
{{ t("multiDbExecute.mergeResults") }}
</Button>
<Button v-if="!isExecuting" @click="requestClose">{{ t("common.close") }}</Button>
</template>
</DialogFooter>
</div>
@@ -0,0 +1,385 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { Check, Download, RefreshCw, Undo2 } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useToast } from "@/composables/useToast";
import { translateBackendError } from "@/i18n/backend-errors";
import { formatSelectionAggregate } from "@/lib/dataGrid/gridSelection";
import { formatLocalTimestamp } from "@/lib/backend/debugLog";
import { buildXlsxSqlWorksheet } from "@/lib/export/xlsxSqlSheet";
import { MULTI_SOURCE_MAX_ROWS_PER_SOURCE, MULTI_SOURCE_MERGE_MAX_ROWS, mergeMultiSourceResults } from "@/lib/query/multiSourceResult";
import type { QueryResult } from "@/types/database";
/**
* Quick inspection view for one multi-database execution. When the statement
* returns result sets, every target's result is unioned by column name into one
* table (first column names the row's source, all-numeric columns get a SUM
* footer, rows can be exported as Excel). When it returns no result set — an
* UPDATE/DELETE/DDL batch — the view lists one row per target with its affected
* row count, status and error, and a failed target can be re-run on its own.
*/
const props = withDefaults(
defineProps<{
items: Array<{
key: string;
label: string;
result?: QueryResult;
status?: "pending" | "running" | "pending_commit" | "rolled_back" | "success" | "failed" | "skipped" | "cancelled" | "not_executed";
durationMs?: number;
errorMessage?: string;
/** Present while this target still owns an unsettled transaction session. */
transaction?: { canCommit: boolean; settling?: boolean };
}>;
/** Hidden when the host dialog renders its own export button group. */
showExportActions?: boolean;
/** Set while the owning batch is still running, so re-running is blocked. */
rerunDisabled?: boolean;
/** True when the batch ran inside per-target transactions. */
transactional?: boolean;
/** Statement the batch executed, shown in the header and written into the export. */
sql?: string;
/** How long the batch took, shown in the header and written into the export. */
durationMs?: number;
/** When the batch started (epoch ms), shown in the header and exported. */
executedAt?: number;
}>(),
{ showExportActions: true },
);
const emit = defineEmits<{
rerunTarget: [key: string];
commitTarget: [key: string];
rollbackTarget: [key: string];
commitAll: [];
rollbackAll: [];
}>();
const { t } = useI18n();
const { toast } = useToast();
/** Rendering cap; the merged statistics always cover every merged row. */
const RENDERED_ROW_LIMIT = 1000;
const merged = computed(() =>
mergeMultiSourceResults(
props.items.map((item) => ({ key: item.key, label: item.label, result: item.result })),
{ sourceColumnLabel: t("multiDbExecute.mergedSourceColumn") },
),
);
/** False for write statements: no target returned columns to union. */
const hasTabularResults = computed(() => merged.value.sources.length > 0);
const summaryByColumnIndex = computed(() => new Map(merged.value.summaries.map((summary) => [summary.columnIndex, summary])));
const renderedRows = computed(() => merged.value.rows.slice(0, RENDERED_ROW_LIMIT));
const hiddenRowCount = computed(() => Math.max(0, merged.value.rows.length - renderedRows.value.length));
/** Sources whose row count reached the per-source fetch cap, so more may exist. */
const cappedSourceCount = computed(() => merged.value.sources.filter((source) => source.rowCount >= MULTI_SOURCE_MAX_ROWS_PER_SOURCE).length);
const perSourceCap = MULTI_SOURCE_MAX_ROWS_PER_SOURCE;
const mergeCap = MULTI_SOURCE_MERGE_MAX_ROWS;
function statusLabel(status: (typeof props.items)[number]["status"]): string {
switch (status) {
case "running":
return t("multiDbExecute.running");
case "success":
return t("multiDbExecute.success");
case "pending_commit":
return t("multiDbExecute.pendingCommit");
case "rolled_back":
return t("toolbar.rollback");
case "failed":
return t("multiDbExecute.failed");
case "skipped":
return t("multiDbExecute.skipped");
case "cancelled":
return t("multiDbExecute.cancelled");
case "not_executed":
return t("multiDbExecute.notExecuted");
default:
return t("multiDbExecute.pending");
}
}
function statusClass(status: (typeof props.items)[number]["status"]): string {
if (status === "success") return "text-emerald-600 dark:text-emerald-300";
if (status === "pending_commit") return "text-amber-600 dark:text-amber-300";
if (status === "failed") return "text-destructive";
if (status === "running") return "text-primary";
if (status === "skipped" || status === "cancelled" || status === "not_executed" || status === "rolled_back") return "text-amber-600 dark:text-amber-300";
return "text-muted-foreground";
}
/** A statement that ran, whether or not its transaction is still open. */
function hasWrittenRows(item: (typeof props.items)[number]): boolean {
return item.status === "success" || item.status === "pending_commit";
}
/** Affected rows are only meaningful for a statement that actually ran. The
* column already names them, so the cell carries the bare count. */
function affectedRowsText(item: (typeof props.items)[number]): string {
if (!hasWrittenRows(item)) return "—";
return String(item.result?.affected_rows ?? 0);
}
/** Rows the batch wrote in total, summarized in the header for write statements. */
const totalAffectedRows = computed(() => props.items.reduce((sum, item) => sum + (hasWrittenRows(item) ? (item.result?.affected_rows ?? 0) : 0), 0));
function canRerun(item: (typeof props.items)[number]): boolean {
// A target that still owns a session has to be settled before it is re-run.
if (item.transaction) return false;
return item.status !== undefined && !hasWrittenRows(item) && item.status !== "running" && item.status !== "pending";
}
/** Targets whose changes are still pending in their own transaction. */
const openTransactionCount = computed(() => props.items.filter((item) => item.transaction !== undefined).length);
/** Committing skips the targets that errored: they have nothing to write. */
const committableCount = computed(() => props.items.filter((item) => item.transaction?.canCommit === true).length);
function isTransactionOpen(item: (typeof props.items)[number]): boolean {
return props.transactional === true && item.transaction !== undefined;
}
function txnStateLabel(item: (typeof props.items)[number]): string {
if (item.transaction) return t("multiDbExecute.txnOpen");
if (item.status === "rolled_back") return t("multiDbExecute.txnRolledBack");
if (hasWrittenRows(item)) return t("multiDbExecute.txnCommitted");
return "—";
}
function durationText(durationMs: number | undefined): string {
if (durationMs === undefined) return "";
return durationMs >= 1000 ? `${(durationMs / 1000).toFixed(1)} s` : `${Math.round(durationMs)} ms`;
}
/** Local execution timestamp, sharing the app's timezone-explicit format. */
function timestampText(executedAt: number | undefined): string {
if (executedAt === undefined) return "";
return formatLocalTimestamp(new Date(executedAt)).replace("T", " ").replace(/\.\d+/, "");
}
/** Rows of the write-statement export: one line per target, no result set. */
function writeStatementExport(): { columns: string[]; rows: (string | number)[][] } {
const columns = [t("multiDbExecute.mergedSourceColumn"), t("multiDbExecute.statusColumn")];
if (props.transactional) columns.push(t("multiDbExecute.txnColumn"));
columns.push(t("multiDbExecute.affectedRowsColumn"), t("multiDbExecute.durationColumn"), t("multiDbExecute.errorColumn"));
const rows = props.items.map((item) => {
const row: (string | number)[] = [item.label, statusLabel(item.status)];
if (props.transactional) row.push(txnStateLabel(item));
row.push(hasWrittenRows(item) ? (item.result?.affected_rows ?? 0) : "", durationText(item.durationMs), item.errorMessage ?? "");
return row;
});
return { columns, rows };
}
function cellText(value: string | number | boolean | null): string {
return value === null ? "" : String(value);
}
function summaryCellText(columnIndex: number): string {
const summary = summaryByColumnIndex.value.get(columnIndex);
return summary ? formatSelectionAggregate(summary.sum) : "";
}
function summaryCellTitle(columnIndex: number): string | undefined {
const summary = summaryByColumnIndex.value.get(columnIndex);
return summary ? t("multiDbExecute.mergedSummaryCellHint", { count: summary.count }) : undefined;
}
function exportFileName(scope: "page" | "all"): string {
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
return ["dbx-merged-result", scope === "page" ? "page" : "all", timestamp].join("_") + ".xlsx";
}
/** SQL + timing worksheet: mirrors the grid export's "include SQL sheet" pattern. */
function buildSqlWorksheet() {
const entries: Array<{ resultName: string; sql: string }> = [];
const executedAt = timestampText(props.executedAt);
if (executedAt) entries.push({ resultName: t("multiDbExecute.executedAtColumn"), sql: executedAt });
const duration = durationText(props.durationMs);
if (duration) entries.push({ resultName: t("multiDbExecute.durationColumn"), sql: duration });
if (props.sql?.trim()) entries.push({ resultName: "SQL", sql: props.sql });
return buildXlsxSqlWorksheet(entries);
}
/**
* Writes one Excel workbook for the merged view.
*
* A statement with a result set exports the merged table (`page` = the rows on
* screen, `all` = every merged row). A write statement without a result set
* exports the per-target list instead —?status, transaction state, affected
* rows, elapsed time and error. Both get a second "SQL" sheet carrying the
* executed statement and the batch duration.
*
* The web build assembles the workbook in the browser and downloads it, the
* desktop build asks for a path and writes it through the backend.
*/
async function exportMergedRows(scope: "page" | "all"): Promise<void> {
const tabular = hasTabularResults.value;
const rows = scope === "page" ? renderedRows.value : merged.value.rows;
if (tabular && rows.length === 0) {
toast(t("multiDbExecute.mergedEmpty"), 3000);
return;
}
if (!tabular && props.items.length === 0) {
toast(t("multiDbExecute.mergedEmpty"), 3000);
return;
}
try {
let outputPath = exportFileName(scope);
if (isTauriRuntime()) {
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({ defaultPath: outputPath, filters: [{ name: "Excel", extensions: ["xlsx"] }] });
if (!path) return;
outputPath = String(path);
}
const writeExport = tabular ? undefined : writeStatementExport();
const dataSheet = {
sheetName: t("multiDbExecute.mergedSheetName"),
columns: tabular ? [...merged.value.columns] : (writeExport?.columns ?? []),
columnTypes: (tabular ? merged.value.columns : (writeExport?.columns ?? [])).map(() => ""),
rows: tabular ? rows : (writeExport?.rows ?? []),
numericColumnRightAlign: true,
autoFilter: true,
};
const sqlWorksheet = buildSqlWorksheet();
if (sqlWorksheet) {
await api.exportQueryResultsXlsx(outputPath, [dataSheet, sqlWorksheet], true);
} else {
await api.exportQueryResultXlsx(outputPath, dataSheet.sheetName, dataSheet.columns, dataSheet.columnTypes, undefined, dataSheet.rows, true, true);
}
const exportedRows = tabular ? rows.length : props.items.length;
toast(cappedSourceCount.value > 0 && tabular ? t("multiDbExecute.exportedRowsCapped", { rows: exportedRows, count: cappedSourceCount.value, limit: perSourceCap }) : t("multiDbExecute.exportedRows", { rows: exportedRows }), 4000);
} catch (error) {
toast(t("grid.exportFailed", { message: translateBackendError(t, error) }), 5000);
}
}
defineExpose({ exportMergedRows });
</script>
<template>
<div class="flex min-h-0 flex-1 flex-col" data-multi-source-merge-view>
<div class="flex shrink-0 flex-wrap items-center gap-2 border-b bg-muted/20 px-5 py-2 text-xs text-muted-foreground">
<!-- A write batch has no rows to merge: report what it touched instead of
the merged-source statistics, which would all read zero. -->
<span v-if="hasTabularResults">{{ t("multiDbExecute.mergedSources", { count: merged.sources.length, rows: merged.rowCount }) }}</span>
<span v-if="hasTabularResults">{{ t("multiDbExecute.mergedSourceRowCap", { count: perSourceCap }) }}</span>
<span v-else data-merge-write-summary>{{ t("multiDbExecute.mergedWriteSummary", { targets: items.length, rows: totalAffectedRows }) }}</span>
<span v-if="hasTabularResults && cappedSourceCount > 0" class="font-medium text-amber-600 dark:text-amber-300" data-merge-source-cap-reached>
{{ t("multiDbExecute.mergedSourceCapReached", { count: cappedSourceCount, limit: perSourceCap }) }}
</span>
<span v-if="hasTabularResults && merged.truncated" class="font-medium text-amber-600 dark:text-amber-300" data-merge-truncated>
{{ t("multiDbExecute.mergedTruncated", { count: mergeCap }) }}
</span>
<span v-if="hasTabularResults && hiddenRowCount > 0">{{ t("multiDbExecute.mergedRowLimit", { count: renderedRows.length, rows: merged.rowCount }) }}</span>
<span v-if="executedAt !== undefined" class="tabular-nums" data-merge-executed-at>{{ t("multiDbExecute.executedAtChip", { time: timestampText(executedAt) }) }}</span>
<span v-if="durationMs !== undefined" class="tabular-nums" data-merge-duration>{{ t("multiDbExecute.elapsedColumn", { duration: durationText(durationMs) }) }}</span>
<span v-if="sql" class="min-w-0 max-w-[420px] truncate font-mono" :title="sql" data-merge-sql>{{ sql }}</span>
<span v-if="transactional && openTransactionCount > 0" class="font-medium text-amber-600 dark:text-amber-300" data-merge-txn-open>
{{ t("multiDbExecute.txnOpenHint", { count: openTransactionCount }) }}
</span>
<span v-if="showExportActions || transactional" class="ml-auto flex shrink-0 items-center gap-1">
<template v-if="transactional">
<Button variant="outline" size="sm" class="h-6 gap-1 px-2 text-xs" data-merge-commit-all :disabled="committableCount === 0 || rerunDisabled" @click="emit('commitAll')">
<Check class="h-3.5 w-3.5" />
{{ t("multiDbExecute.commitAllTargets", { count: committableCount }) }}
</Button>
<Button variant="outline" size="sm" class="h-6 gap-1 px-2 text-xs" data-merge-rollback-all :disabled="openTransactionCount === 0 || rerunDisabled" @click="emit('rollbackAll')">
<Undo2 class="h-3.5 w-3.5" />
{{ t("multiDbExecute.rollbackAllTargets", { count: openTransactionCount }) }}
</Button>
</template>
<template v-if="showExportActions">
<Button variant="outline" size="sm" class="h-6 gap-1 px-2 text-xs" data-merge-export-page @click="exportMergedRows('page')">
<Download class="h-3.5 w-3.5" />
{{ t("multiDbExecute.exportCurrentPage") }}
</Button>
<Button variant="outline" size="sm" class="h-6 gap-1 px-2 text-xs" data-merge-export-all @click="exportMergedRows('all')">
<Download class="h-3.5 w-3.5" />
{{ t("multiDbExecute.exportAllRows") }}
</Button>
</template>
</span>
</div>
<div class="min-h-0 flex-1 overflow-auto">
<!-- Write statements (UPDATE / DELETE / DDL) return no result set: report
one row per target instead of an empty merged table. -->
<table v-if="!hasTabularResults" class="w-full border-collapse text-xs" data-merge-target-results>
<thead class="sticky top-0 z-10 bg-muted text-left">
<tr>
<th class="border-r border-b px-2 py-1 font-medium whitespace-nowrap">{{ t("multiDbExecute.mergedSourceColumn") }}</th>
<th class="border-r border-b px-2 py-1 font-medium whitespace-nowrap">{{ t("multiDbExecute.statusColumn") }}</th>
<th v-if="transactional" class="border-r border-b px-2 py-1 font-medium whitespace-nowrap">{{ t("multiDbExecute.txnColumn") }}</th>
<th class="border-r border-b px-2 py-1 font-medium whitespace-nowrap">{{ t("multiDbExecute.affectedRowsColumn") }}</th>
<th class="border-r border-b px-2 py-1 font-medium whitespace-nowrap">{{ t("multiDbExecute.durationColumn") }}</th>
<th class="border-r border-b px-2 py-1 font-medium whitespace-nowrap">{{ t("multiDbExecute.errorColumn") }}</th>
<th class="border-b px-2 py-1" />
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.key" class="border-b align-top hover:bg-muted/40">
<td class="max-w-[280px] truncate border-r px-2 py-1 whitespace-nowrap" :title="item.label">{{ item.label }}</td>
<td class="border-r px-2 py-1 whitespace-nowrap" :class="statusClass(item.status)">{{ statusLabel(item.status) }}</td>
<td v-if="transactional" class="border-r px-2 py-1 whitespace-nowrap" :class="{ 'text-amber-600 dark:text-amber-300': item.transaction !== undefined }" data-merge-txn-state>
{{ txnStateLabel(item) }}
</td>
<td class="border-r px-2 py-1 text-right tabular-nums whitespace-nowrap">{{ affectedRowsText(item) }}</td>
<td class="border-r px-2 py-1 text-right tabular-nums whitespace-nowrap">{{ durationText(item.durationMs) }}</td>
<td class="max-w-[360px] border-r px-2 py-1 text-destructive">
<span class="line-clamp-3 break-words whitespace-pre-wrap">{{ item.errorMessage ?? "" }}</span>
</td>
<td class="px-2 py-1 text-right whitespace-nowrap">
<template v-if="isTransactionOpen(item) && item.transaction">
<Button variant="outline" size="sm" class="h-6 gap-1 px-2 text-xs" data-merge-commit :disabled="rerunDisabled || item.transaction.settling === true || !item.transaction.canCommit" @click="emit('commitTarget', item.key)">
<Check class="h-3.5 w-3.5" />
{{ t("toolbar.commit") }}
</Button>
<Button variant="outline" size="sm" class="ml-1 h-6 gap-1 px-2 text-xs" data-merge-rollback :disabled="rerunDisabled || item.transaction.settling === true" @click="emit('rollbackTarget', item.key)">
<Undo2 class="h-3.5 w-3.5" />
{{ t("toolbar.rollback") }}
</Button>
</template>
<Button v-else-if="canRerun(item)" variant="outline" size="sm" class="h-6 gap-1 px-2 text-xs" data-merge-rerun :disabled="rerunDisabled" @click="emit('rerunTarget', item.key)">
<RefreshCw class="h-3.5 w-3.5" />
{{ t("multiDbExecute.rerunTarget") }}
</Button>
</td>
</tr>
<tr v-if="items.length === 0">
<td :colspan="transactional ? 7 : 6" class="px-2 py-6 text-center text-muted-foreground">{{ t("multiDbExecute.mergedEmpty") }}</td>
</tr>
</tbody>
</table>
<table v-else class="w-full border-collapse text-xs">
<thead class="sticky top-0 z-10 bg-muted text-left">
<tr>
<th v-for="(column, columnIndex) in merged.columns" :key="`head-${columnIndex}`" class="border-r border-b px-2 py-1 font-medium whitespace-nowrap">{{ column }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in renderedRows" :key="`row-${rowIndex}`" class="border-b hover:bg-muted/40">
<td v-for="(cell, columnIndex) in row" :key="`cell-${rowIndex}-${columnIndex}`" class="max-w-[320px] truncate border-r px-2 py-1 whitespace-nowrap" :class="{ 'text-muted-foreground': cell === null }" :title="cellText(cell)">
{{ cellText(cell) }}
</td>
</tr>
<tr v-if="renderedRows.length === 0">
<td :colspan="merged.columns.length" class="px-2 py-6 text-center text-muted-foreground">{{ t("multiDbExecute.mergedEmpty") }}</td>
</tr>
</tbody>
<tfoot v-if="merged.summaries.length > 0" class="sticky bottom-0 z-10 bg-muted">
<tr>
<td class="border-r border-t px-2 py-1 text-center font-medium text-muted-foreground">{{ t("multiDbExecute.mergedSummaryLabel") }}</td>
<td v-for="columnIndex in merged.columns.length - 1" :key="`sum-${columnIndex}`" class="border-r border-t px-2 py-1 text-right tabular-nums" :title="summaryCellTitle(columnIndex)">
{{ summaryCellText(columnIndex) }}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
</template>
@@ -0,0 +1,386 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { computed, createApp, h, nextTick, ref, type App, type Ref } from "vue";
import { createPinia } from "pinia";
import MultiDbExecuteDialog from "../MultiDbExecuteDialog.vue";
import { useSqlExecutionDangerStore } from "@/stores/sqlExecutionDangerStore";
import type { MultiDbExecutionAdapter } from "@/composables/useMultiDbExecution";
import type { QueryResult } from "@/types/database";
const mocks = vi.hoisted(() => ({
toast: vi.fn(),
exportQueryResultsXlsx: vi.fn().mockResolvedValue(undefined),
exportQueryResultXlsx: vi.fn().mockResolvedValue(undefined),
registerTaskCancelHandler: vi.fn(),
unregisterTaskCancelHandler: vi.fn(),
}));
// Parameters are rendered so a call that forgot them is visible in the DOM.
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string, params?: Record<string, unknown>) => (params ? `${key}(${Object.values(params).join("/")})` : key) }),
}));
// Web runtime: exports skip the save dialog and manual transactions are unavailable.
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.mock("@/lib/backend/api", () => ({
exportQueryResultsXlsx: mocks.exportQueryResultsXlsx,
exportQueryResultXlsx: mocks.exportQueryResultXlsx,
}));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/stores/sqlExecutionTargetGroupStore", () => ({ useSqlExecutionTargetGroupStore: () => ({ getGroupsByDatabaseType: () => [], getGroup: () => undefined }) }));
vi.mock("@/composables/useExportTracker", () => ({
formatDataTransferDuration: String,
useExportTracker: () => ({ addMultiDbExecutionTask: vi.fn(), updateMultiDbExecutionTask: vi.fn(), registerTaskCancelHandler: mocks.registerTaskCancelHandler, unregisterTaskCancelHandler: mocks.unregisterTaskCancelHandler }),
}));
vi.mock("@/composables/useMultiDbTargetSelection", () => ({
useMultiDbTargetSelection: () => ({
compatibleConnections: computed(() => []),
connection: () => ({ id: "test", name: "Test", db_type: "oceanbase-oracle" }),
loadConnection: vi.fn().mockResolvedValue(undefined),
validateTarget: async () => ({ state: "valid" }),
validateTargets: async (targets: unknown[]) => targets.map((target) => ({ target, state: "valid" })),
}),
}));
const SQL = "SELECT id, amount FROM t";
const TWO_TARGETS = [
{ connectionId: "test", database: "db-a" },
{ connectionId: "test", database: "db-b" },
];
function table(rows: (string | number)[][]): QueryResult {
return { columns: ["id", "amount"], rows, affected_rows: rows.length, execution_time_ms: 1 };
}
function write(affectedRows: number): QueryResult {
return { columns: [], rows: [], affected_rows: affectedRows, execution_time_ms: 1 };
}
function resultFor(database: string): QueryResult {
return database === "db-a" ? table([[1, 10]]) : table([[2, 20]]);
}
let app: App | undefined;
afterEach(() => {
app?.unmount();
app = undefined;
document.body.innerHTML = "";
mocks.toast.mockClear();
mocks.exportQueryResultsXlsx.mockClear();
mocks.registerTaskCancelHandler.mockClear();
mocks.unregisterTaskCancelHandler.mockClear();
vi.restoreAllMocks();
});
async function flushPromises() {
await new Promise((resolve) => setTimeout(resolve, 0));
await nextTick();
}
function button(label: string): HTMLButtonElement {
const found = [...document.querySelectorAll<HTMLButtonElement>("button")].find((candidate) => candidate.textContent?.trim() === label);
if (!found) throw new Error(`Missing button ${label}`);
return found;
}
/** Mounts with a reactive `open` so minimizing and reopening can be observed. */
interface DialogOptions {
open: Ref<boolean>;
targets?: Array<{ connectionId: string; database: string }>;
launchId?: Ref<number>;
cancelTarget?: MultiDbExecutionAdapter["cancelTarget"];
cancelPending?: MultiDbExecutionAdapter["cancelPending"];
}
function mountDialog(executeTarget: MultiDbExecutionAdapter["executeTarget"], options: DialogOptions) {
const root = document.createElement("div");
document.body.append(root);
app = createApp({
render: () =>
h(MultiDbExecuteDialog, {
open: options.open.value,
"onUpdate:open": (value: boolean) => (options.open.value = value),
sql: SQL,
sourceTabId: "source",
databaseType: "oceanbase-oracle",
initialTargets: options.targets ?? TWO_TARGETS,
launchId: options.launchId?.value ?? 1,
executeTarget,
cancelTarget: options.cancelTarget,
cancelPending: options.cancelPending,
}),
});
app.use(createPinia());
app.mount(root);
}
async function executeBatch(executeTarget: MultiDbExecutionAdapter["executeTarget"], options: DialogOptions) {
mountDialog(executeTarget, options);
await flushPromises();
button("multiDbExecute.execute").click();
await flushPromises();
}
describe("multi-database merged view reveal paths", () => {
it("unions every source and labels the rows, the statement and the batch timings", async () => {
const open = ref(true);
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async (input) => ({ status: "success" as const, durationMs: 3, result: resultFor(input.target.database) }));
await executeBatch(executeTarget, { open });
button("multiDbExecute.mergeResults").click();
await nextTick();
const view = document.querySelector("[data-multi-source-merge-view]");
expect(view).not.toBeNull();
const rows = [...(view?.querySelectorAll("tbody tr") ?? [])].map((row) => [...row.querySelectorAll("td")].map((cell) => cell.textContent?.trim()));
expect(rows).toEqual([
["Test / db-a", "1", "10"],
["Test / db-b", "2", "20"],
]);
// The statement, the elapsed time and the start time come from the batch.
expect(document.querySelector("[data-merge-sql]")?.textContent?.trim()).toBe(SQL);
expect(document.querySelector("[data-merge-duration]")?.textContent?.trim()).not.toBe("");
expect(document.querySelector("[data-merge-executed-at]")?.textContent?.trim()).not.toBe("");
});
it("names the merged view in the title instead of the bar's parametrized progress", async () => {
const open = ref(true);
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async (input) => ({ status: "success" as const, durationMs: 3, result: resultFor(input.target.database) }));
await executeBatch(executeTarget, { open });
const title = () => document.querySelector('[data-slot="dialog-title"]')?.textContent?.trim() ?? "";
expect(title()).toContain("multiDbExecute.progress(");
button("multiDbExecute.mergeResults").click();
await nextTick();
expect(title()).toBe("multiDbExecute.mergeResults");
expect(document.body.textContent).toContain("multiDbExecute.progress(2/2)");
});
it("exports the merged rows and the statement through the dialog footer", async () => {
const open = ref(true);
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async (input) => ({ status: "success" as const, durationMs: 3, result: resultFor(input.target.database) }));
await executeBatch(executeTarget, { open });
button("multiDbExecute.mergeResults").click();
await nextTick();
(document.querySelector("[data-multi-db-merge-export-all]") as HTMLButtonElement).click();
await vi.waitFor(() => expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledTimes(1));
const sheets = (mocks.exportQueryResultsXlsx.mock.calls[0] as [string, Array<{ sheetName: string; rows: unknown[][] }>])[1];
expect(sheets[0]?.rows).toHaveLength(2);
expect(sheets[1]?.rows.flat().join("\n")).toContain(SQL);
});
it("re-runs only the failed target from the per-target list", async () => {
const open = ref(true);
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async (input) => (input.target.database === "db-a" ? { status: "success" as const, durationMs: 4, result: write(3) } : { status: "failed" as const, durationMs: 2, errorMessage: "boom" }));
await executeBatch(executeTarget, { open });
button("multiDbExecute.mergeResults").click();
await nextTick();
expect(document.body.textContent).toContain("boom");
executeTarget.mockImplementation(async (input) => (input.target.database === "db-a" ? { status: "success" as const, durationMs: 4, result: write(3) } : { status: "success" as const, durationMs: 6, result: write(1) }));
(document.querySelector("[data-merge-rerun]") as HTMLButtonElement).click();
await flushPromises();
// The adapter ran for the retried target only, and the row shows the retry's own outcome.
expect(executeTarget).toHaveBeenCalledTimes(3);
expect(executeTarget.mock.calls[2]?.[0].target.database).toBe("db-b");
expect(document.querySelector("[data-merge-rerun]")).toBeNull();
expect(document.body.textContent).not.toContain("boom");
});
it("keeps deferred retries cancellable across reopen and rolls back their late transactions on unmount", async () => {
const open = ref(true);
const launchId = ref(1);
const cancelTarget = vi.fn().mockResolvedValue(undefined);
const cancelPending = vi.fn();
const finish = vi.fn().mockResolvedValue(undefined);
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>().mockResolvedValue({ status: "failed", errorMessage: "boom" });
await executeBatch(executeTarget, { open, launchId, cancelTarget, cancelPending });
const batchId = document.querySelector("[data-multi-db-batch-id]")?.getAttribute("data-multi-db-batch-id");
button("multiDbExecute.mergeResults").click();
await nextTick();
mocks.registerTaskCancelHandler.mockClear();
executeTarget.mockImplementationOnce(async () => {
await gate;
return { status: "pending_commit", transaction: { canCommit: true, finish } };
});
(document.querySelector("[data-merge-rerun]") as HTMLButtonElement).click();
await flushPromises();
expect(document.querySelector<HTMLButtonElement>("[data-merge-rerun]")?.disabled).toBe(true);
expect(mocks.registerTaskCancelHandler).toHaveBeenLastCalledWith(batchId, expect.any(Function));
const cancel = mocks.registerTaskCancelHandler.mock.calls.at(-1)![1] as () => Promise<void>;
open.value = false;
await flushPromises();
launchId.value += 1;
open.value = true;
await flushPromises();
expect(document.querySelector("[data-multi-db-batch-id]")?.getAttribute("data-multi-db-batch-id")).toBe(batchId);
expect(executeTarget).toHaveBeenCalledTimes(3);
await cancel();
expect(cancelTarget).toHaveBeenCalledExactlyOnceWith("source", batchId);
expect(cancelPending).toHaveBeenCalledExactlyOnceWith(batchId);
expect(executeTarget.mock.calls[2]![0].isCancellationRequested()).toBe(true);
app?.unmount();
app = undefined;
expect(finish).not.toHaveBeenCalled();
release();
await vi.waitFor(() => expect(finish).toHaveBeenCalledExactlyOnceWith("rollback"));
});
it.each([0, 5000])("resets confirmation timing for a new batch with %i ms wait but preserves same-batch reopen", async (secondWaitMs) => {
let now = 1000;
vi.spyOn(Date, "now").mockImplementation(() => now);
const open = ref(true);
const launchId = ref(1);
let release!: () => void;
let gate = new Promise<void>((resolve) => {
release = resolve;
});
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async (input) => {
if (input.target.database === "db-a") await gate;
return { status: "success", result: resultFor(input.target.database) };
});
await executeBatch(executeTarget, { open, launchId });
const firstBatchId = document.querySelector("[data-multi-db-batch-id]")?.getAttribute("data-multi-db-batch-id");
const dangerStore = useSqlExecutionDangerStore();
dangerStore.pending = { sql: SQL, kind: "sql", scopeId: firstBatchId ?? undefined, targetLabel: "Test / db-a" };
await nextTick();
now = 11_000;
dangerStore.pending = undefined;
await nextTick();
now = 11_100;
release();
await flushPromises();
expect(document.querySelector("[data-multi-db-elapsed]")?.textContent).toBe("multiDbExecute.elapsed(100)");
open.value = false;
await flushPromises();
open.value = true;
await flushPromises();
expect(document.querySelector("[data-multi-db-elapsed]")?.textContent).toBe("multiDbExecute.elapsed(100)");
expect(executeTarget).toHaveBeenCalledTimes(2);
now = 20_000;
gate = new Promise<void>((resolve) => {
release = resolve;
});
launchId.value += 1;
await flushPromises();
button("multiDbExecute.execute").click();
await flushPromises();
const secondBatchId = document.querySelector("[data-multi-db-batch-id]")?.getAttribute("data-multi-db-batch-id");
expect(secondBatchId).not.toBe(firstBatchId);
if (secondWaitMs > 0) {
dangerStore.pending = { sql: SQL, kind: "sql", scopeId: secondBatchId ?? undefined, targetLabel: "Test / db-a" };
await nextTick();
now += secondWaitMs;
dangerStore.pending = undefined;
await nextTick();
}
now += 250;
release();
await flushPromises();
expect(document.querySelector("[data-multi-db-elapsed]")?.textContent).toBe("multiDbExecute.elapsed(250)");
button("multiDbExecute.mergeResults").click();
await nextTick();
button("multiDbExecute.exportAllRows").click();
await flushPromises();
const sheets = mocks.exportQueryResultsXlsx.mock.calls[0]?.[1] as Array<{ sheetName: string; rows: string[][] }>;
expect(
sheets
.find((sheet) => sheet.sheetName === "SQL")
?.rows.flat()
.join("\n"),
).toContain("250 ms");
});
it("keeps merging out of a single-target batch", async () => {
const open = ref(true);
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async (input) => ({ status: "success" as const, durationMs: 3, result: resultFor(input.target.database) }));
await executeBatch(executeTarget, { open, targets: [{ connectionId: "test", database: "db-a" }] });
expect(document.querySelector("[data-multi-db-merge]")).toBeNull();
});
it("parks the clock and labels the target while its confirmation is on screen", async () => {
const open = ref(true);
const releases: Array<() => void> = [];
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async (input) => {
await new Promise<void>((resolve) => releases.push(resolve));
return { status: "success" as const, durationMs: 3, result: resultFor(input.target.database) };
});
await executeBatch(executeTarget, { open });
const batchId = document.querySelector("[data-multi-db-batch-id]")?.getAttribute("data-multi-db-batch-id");
expect(batchId).toBeTruthy();
// The prompt the batch is waiting for, as the danger store publishes it.
useSqlExecutionDangerStore().pending = { sql: SQL, kind: "sql", scopeId: batchId ?? undefined, targetLabel: "Test / db-a" };
await nextTick();
// The parked target is waiting for the operator, not running.
expect(document.body.textContent).toContain("multiDbExecute.awaitingConfirmation");
const elapsedBefore = document.querySelector("[data-multi-db-elapsed]")?.textContent;
expect(elapsedBefore).toBeTruthy();
await new Promise((resolve) => setTimeout(resolve, 1_200));
await nextTick();
expect(document.querySelector("[data-multi-db-elapsed]")?.textContent).toBe(elapsedBefore);
releases.splice(0).forEach((release) => release());
});
it("announces a batch that finished while minimized with an action into the merged view", async () => {
const open = ref(true);
const releases: Array<() => void> = [];
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async (input) => {
await new Promise<void>((resolve) => releases.push(resolve));
return { status: "success" as const, durationMs: 3, result: resultFor(input.target.database) };
});
await executeBatch(executeTarget, { open });
// Minimize while the targets are still running, then let them finish.
open.value = false;
await nextTick();
for (let round = 0; round < 4; round += 1) {
releases.splice(0).forEach((release) => release());
await flushPromises();
}
const announcement = mocks.toast.mock.calls.at(-1) as [string, number, { label: string; onClick: () => void }];
expect(announcement[0]).toBe("multiDbExecute.backgroundBatchDone(2)");
expect(announcement[1]).toBe(8000);
expect(announcement[2].label).toBe("multiDbExecute.mergeResults");
announcement[2].onClick();
await nextTick();
expect(open.value).toBe(true);
expect(document.querySelector("[data-multi-source-merge-view]")).not.toBeNull();
});
it("reopens a settled batch straight in the merged view without re-running it", async () => {
const open = ref(true);
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async (input) => ({ status: "success" as const, durationMs: 3, result: resultFor(input.target.database) }));
await executeBatch(executeTarget, { open });
button("common.close").click();
await flushPromises();
expect(open.value).toBe(false);
open.value = true;
await flushPromises();
expect(document.querySelector("[data-multi-source-merge-view]")).not.toBeNull();
expect(executeTarget).toHaveBeenCalledTimes(2);
});
});
@@ -1,6 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { computed, createApp, nextTick, type App } from "vue";
import { createPinia } from "pinia";
import MultiDbExecuteDialog from "../MultiDbExecuteDialog.vue";
import type { MultiDbExecutionAdapter } from "@/composables/useMultiDbExecution";
@@ -37,6 +38,7 @@ async function openDialog(executeTarget: MultiDbExecutionAdapter["executeTarget"
const root = document.createElement("div");
document.body.append(root);
app = createApp(MultiDbExecuteDialog, { open: true, "onUpdate:open": onOpenChange, sql: "INSERT INTO t VALUES (1)", sourceTabId: "source", databaseType: "oceanbase-oracle", initialTargets: [{ connectionId: "test", database: "test" }], launchId: 1, initialManualTransaction: true, executeTarget });
app.use(createPinia());
app.mount(root);
await flushPromises();
const checkbox = document.querySelector<HTMLInputElement>('input[type="checkbox"]');
@@ -0,0 +1,209 @@
// @vitest-environment happy-dom
import { createApp, h, nextTick, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import MultiSourceMergeView from "@/components/editor/MultiSourceMergeView.vue";
import type { QueryResult } from "@/types/database";
const mocks = vi.hoisted(() => ({
exportQueryResultsXlsx: vi.fn().mockResolvedValue(undefined),
exportQueryResultXlsx: vi.fn().mockResolvedValue(undefined),
toast: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => ({
exportQueryResultsXlsx: mocks.exportQueryResultsXlsx,
exportQueryResultXlsx: mocks.exportQueryResultXlsx,
}));
// The web build assembles the workbook in the browser; the desktop build would
// first ask for a path, which is covered by the shared export code instead.
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
interface Sheet {
sheetName: string;
columns: string[];
rows: Array<Array<string | number>>;
}
function result(columns: string[], rows: (string | number | null)[][], affectedRows = rows.length): QueryResult {
return { columns, rows, affected_rows: affectedRows, execution_time_ms: 1 };
}
function mountView(props: Record<string, unknown>) {
const host = document.createElement("div");
document.body.appendChild(host);
const app = createApp({ render: () => h(MultiSourceMergeView, props) });
app.use(i18n);
app.mount(host);
return { host, app };
}
let mounted: { host: HTMLElement; app: App } | undefined;
beforeEach(() => {
mocks.exportQueryResultsXlsx.mockClear();
mocks.exportQueryResultXlsx.mockClear();
mocks.toast.mockClear();
});
afterEach(() => {
mounted?.app.unmount();
mounted?.host.remove();
mounted = undefined;
});
function exportButton(selector: string): HTMLButtonElement {
const button = mounted?.host.querySelector<HTMLButtonElement>(selector);
if (!button) throw new Error(`Missing export button ${selector}`);
return button;
}
describe("merged view export", () => {
it.each(["page", "all"])("omits hidden middle columns from the display, summaries and %s export", async (scope) => {
mounted = mountView({
items: [
{ key: "a", label: "conn-a", status: "success", result: { ...result(["name", "__DBX_PK_0", "amount"], [["Alice", 987654, 10]]), hidden_column_indexes: [1] } },
{ key: "b", label: "conn-b", status: "success", result: result(["amount", "name"], [[20, "Bob"]]) },
],
sql: "SELECT name, amount FROM t",
});
await nextTick();
expect([...mounted.host.querySelectorAll("thead th")].map((cell) => cell.textContent?.trim())).toEqual([i18n.global.t("multiDbExecute.mergedSourceColumn"), "name", "amount"]);
expect([...mounted.host.querySelectorAll("tbody tr")].map((row) => [...row.querySelectorAll("td")].map((cell) => cell.textContent?.trim()))).toEqual([
["conn-a", "Alice", "10"],
["conn-b", "Bob", "20"],
]);
expect(mounted.host.querySelector("tfoot")?.textContent).toContain("30");
expect(mounted.host.textContent).not.toContain("__DBX_PK_0");
expect(mounted.host.textContent).not.toContain("987654");
exportButton(`[data-merge-export-${scope}]`).click();
await vi.waitFor(() => expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledTimes(1));
const sheet = (mocks.exportQueryResultsXlsx.mock.calls[0] as [string, Sheet[]])[1][0];
expect(sheet?.columns).toEqual([i18n.global.t("multiDbExecute.mergedSourceColumn"), "name", "amount"]);
expect(sheet?.rows).toEqual([
["conn-a", "Alice", 10],
["conn-b", "Bob", 20],
]);
});
it("exports every merged row plus a SQL sheet carrying the statement, the start time and the duration", async () => {
mounted = mountView({
items: [
{ key: "a", label: "conn-a", status: "success", result: result(["id", "amount"], [[1, 10]]) },
{ key: "b", label: "conn-b", status: "success", result: result(["id", "amount"], [[2, 20]]) },
],
sql: "SELECT id, amount FROM t",
durationMs: 1234,
executedAt: Date.UTC(2026, 0, 2, 3, 4, 5),
});
await nextTick();
exportButton("[data-merge-export-all]").click();
await vi.waitFor(() => expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledTimes(1));
const [outputPath, sheets, ...rest] = mocks.exportQueryResultsXlsx.mock.calls[0] as [string, Sheet[], boolean];
expect(outputPath).toMatch(/^dbx-merged-result_all_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.xlsx$/);
expect(rest).toEqual([true]);
const [dataSheet, sqlSheet] = sheets;
expect(dataSheet?.columns).toEqual([i18n.global.t("multiDbExecute.mergedSourceColumn"), "id", "amount"]);
expect(dataSheet?.rows).toEqual([
["conn-a", 1, 10],
["conn-b", 2, 20],
]);
// The statement and the batch timings travel in their own sheet.
expect(sqlSheet?.sheetName).toBe("SQL");
const sqlCells = (sqlSheet?.rows ?? []).flat().join("\n");
expect(sqlCells).toContain("SELECT id, amount FROM t");
expect(sqlCells).toContain(i18n.global.t("multiDbExecute.durationColumn"));
expect(sqlCells).toContain("1.2 s");
expect(String(sqlCells)).toMatch(/20\d\d-\d\d-\d\d/);
});
it("exports only the rows on screen for the current page", async () => {
const rows = Array.from({ length: 1200 }, (_, index) => [index, index * 2]);
mounted = mountView({
items: [{ key: "a", label: "conn-a", status: "success", result: result(["id", "amount"], rows) }],
sql: "SELECT id, amount FROM big",
durationMs: 10,
});
await nextTick();
exportButton("[data-merge-export-page]").click();
await vi.waitFor(() => expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledTimes(1));
const pageSheet = (mocks.exportQueryResultsXlsx.mock.calls[0] as [string, Sheet[]])[1][0];
expect(pageSheet?.rows).toHaveLength(1000);
exportButton("[data-merge-export-all]").click();
await vi.waitFor(() => expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledTimes(2));
const allSheet = (mocks.exportQueryResultsXlsx.mock.calls[1] as [string, Sheet[]])[1][0];
expect(allSheet?.rows).toHaveLength(1200);
});
it("exports the per-target list when the batch returned no result set", async () => {
mounted = mountView({
items: [
{ key: "a", label: "conn-a", status: "success", durationMs: 12, result: result([], [], 3) },
{ key: "b", label: "conn-b", status: "failed", durationMs: 9, errorMessage: "syntax error" },
{ key: "c", label: "conn-c", status: "pending_commit", durationMs: 5, result: result([], [], 7), transaction: { canCommit: true } },
],
transactional: true,
sql: "UPDATE t SET amount = 0",
durationMs: 40,
});
await nextTick();
exportButton("[data-merge-export-all]").click();
await vi.waitFor(() => expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledTimes(1));
const sheet = (mocks.exportQueryResultsXlsx.mock.calls[0] as [string, Sheet[]])[1][0];
expect(sheet?.columns).toEqual([
i18n.global.t("multiDbExecute.mergedSourceColumn"),
i18n.global.t("multiDbExecute.statusColumn"),
i18n.global.t("multiDbExecute.txnColumn"),
i18n.global.t("multiDbExecute.affectedRowsColumn"),
i18n.global.t("multiDbExecute.durationColumn"),
i18n.global.t("multiDbExecute.errorColumn"),
]);
expect(sheet?.rows.map((row) => row[0])).toEqual(["conn-a", "conn-b", "conn-c"]);
// A statement that ran inside a transaction still reports its row count.
expect(sheet?.rows[0]?.[3]).toBe(3);
expect(sheet?.rows[2]?.[3]).toBe(7);
expect(sheet?.rows[1]?.[3]).toBe("");
expect(String(sheet?.rows[1]?.[5])).toBe("syntax error");
expect(sheet?.rows[0]?.[2]).toBe(i18n.global.t("multiDbExecute.txnCommitted"));
expect(sheet?.rows[2]?.[2]).toBe(i18n.global.t("multiDbExecute.txnOpen"));
});
it("keeps a single-sheet workbook when the batch carries no SQL context", async () => {
mounted = mountView({
items: [{ key: "a", label: "conn-a", status: "success", result: result(["id"], [[1]]) }],
});
await nextTick();
exportButton("[data-merge-export-all]").click();
await vi.waitFor(() => expect(mocks.exportQueryResultXlsx).toHaveBeenCalledTimes(1));
const [outputPath, sheetName, columns, , , rows] = mocks.exportQueryResultXlsx.mock.calls[0] as [string, string, string[], string[], unknown, unknown[][]];
expect(outputPath).toMatch(/\.xlsx$/);
expect(sheetName).toBe(i18n.global.t("multiDbExecute.mergedSheetName"));
expect(columns).toEqual([i18n.global.t("multiDbExecute.mergedSourceColumn"), "id"]);
expect(rows).toEqual([["conn-a", 1]]);
expect(mocks.exportQueryResultsXlsx).not.toHaveBeenCalled();
});
it("refuses to export an empty merged table", async () => {
mounted = mountView({ items: [{ key: "a", label: "conn-a", status: "success", result: result(["id"], []) }] });
await nextTick();
exportButton("[data-merge-export-all]").click();
await nextTick();
expect(mocks.exportQueryResultsXlsx).not.toHaveBeenCalled();
expect(mocks.exportQueryResultXlsx).not.toHaveBeenCalled();
expect(mocks.toast).toHaveBeenCalledWith(i18n.global.t("multiDbExecute.mergedEmpty"), 3000);
});
});
@@ -0,0 +1,225 @@
// @vitest-environment happy-dom
import { createApp, h, nextTick } from "vue";
import { afterEach, describe, expect, it } from "vitest";
import i18n from "@/i18n";
import MultiSourceMergeView from "@/components/editor/MultiSourceMergeView.vue";
import { buildXlsxWorkbook } from "@/lib/export/xlsxExport";
import { MULTI_SOURCE_MAX_ROWS_PER_SOURCE, mergeMultiSourceResults } from "@/lib/query/multiSourceResult";
import type { QueryResult } from "@/types/database";
function result(columns: string[], rows: (string | number | null)[][]): QueryResult {
return { columns, rows, affected_rows: rows.length, execution_time_ms: 1 };
}
function mountView(items: Array<{ key: string; label: string; result?: QueryResult; status?: "success" | "failed" | "skipped" | "cancelled" | "not_executed" | "running" | "pending"; durationMs?: number; errorMessage?: string }>, props: Record<string, unknown> = {}) {
const host = document.createElement("div");
document.body.appendChild(host);
const app = createApp({ render: () => h(MultiSourceMergeView, { items, ...props }) });
app.use(i18n);
app.mount(host);
return { host, app };
}
let mounted: ReturnType<typeof mountView> | undefined;
afterEach(() => {
mounted?.app.unmount();
mounted?.host.remove();
mounted = undefined;
});
describe("MultiSourceMergeView", () => {
it("unions source columns, labels every row with its source and pads missing columns", async () => {
mounted = mountView([
{ key: "a", label: "conn-a", result: result(["id", "amount"], [[1, 10]]) },
{ key: "b", label: "conn-b", result: result(["amount", "region"], [[20, "north"]]) },
]);
await nextTick();
const headers = [...mounted.host.querySelectorAll("thead th")].map((cell) => cell.textContent?.trim());
expect(headers.slice(1)).toEqual(["id", "amount", "region"]);
const rows = [...mounted.host.querySelectorAll("tbody tr")].map((row) => [...row.querySelectorAll("td")].map((cell) => cell.textContent?.trim()));
expect(rows).toHaveLength(2);
expect(rows[0]?.slice(1)).toEqual(["1", "10", ""]);
expect(rows[1]?.slice(1)).toEqual(["", "20", "north"]);
expect(rows[0]?.[0]).toContain("conn-a");
expect(rows[1]?.[0]).toContain("conn-b");
});
it("sums only all-numeric columns in the footer", async () => {
mounted = mountView([
{ key: "a", label: "conn-a", result: result(["id", "amount", "note"], [[1, 10, "x"]]) },
{ key: "b", label: "conn-b", result: result(["id", "amount", "note"], [[2, "n/a", "y"]]) },
]);
await nextTick();
const footer = [...(mounted.host.querySelector("tfoot")?.querySelectorAll("td") ?? [])].map((cell) => cell.textContent?.trim());
// First cell is the SUM label, then one cell per data column.
expect(footer).toHaveLength(4);
expect(footer[1]).toBe("3");
// `amount` mixes 10 and "n/a", so its column is not a total.
expect(footer[2]).toBe("");
expect(footer[3]).toBe("");
});
it("reports the merged source count and row count", async () => {
mounted = mountView([
{ key: "a", label: "conn-a", result: result(["id"], [[1]]) },
{ key: "b", label: "conn-b", result: result(["id"], [[2]]) },
]);
await nextTick();
const legend = mounted.host.querySelector("[data-multi-source-merge-view]")?.textContent ?? "";
expect(legend).toContain("2");
});
it("states the per-source row cap and warns explicitly when a source hit it", async () => {
const cappedRows = Array.from({ length: MULTI_SOURCE_MAX_ROWS_PER_SOURCE }, (_, index) => [index, index]);
mounted = mountView([
{ key: "a", label: "conn-a", result: result(["id", "amount"], cappedRows) },
{ key: "b", label: "conn-b", result: result(["id", "amount"], [[1, 2]]) },
]);
await nextTick();
const legend = mounted.host.querySelector("[data-multi-source-merge-view]")?.textContent ?? "";
// The cap is always stated, and reaching it is called out in amber.
expect(legend).toContain(String(MULTI_SOURCE_MAX_ROWS_PER_SOURCE));
expect(mounted.host.querySelector("[data-merge-source-cap-reached]")?.textContent).toContain(String(MULTI_SOURCE_MAX_ROWS_PER_SOURCE));
});
it("does not warn about the per-source cap while every source stays below it", async () => {
mounted = mountView([
{ key: "a", label: "conn-a", result: result(["id"], [[1], [2]]) },
{ key: "b", label: "conn-b", result: result(["id"], [[3]]) },
]);
await nextTick();
expect(mounted.host.querySelector("[data-merge-source-cap-reached]")).toBeNull();
expect(mounted.host.querySelector("[data-merge-truncated]")).toBeNull();
});
it("feeds the Excel writer a workbook-ready payload", () => {
const merged = mergeMultiSourceResults(
[
{ key: "a", label: "conn-a", result: result(["id", "amount", "note"], [[1, 10, "x"]]) },
{ key: "b", label: "conn-b", result: result(["amount"], [[2.5]]) },
],
{ sourceColumnLabel: "来源" },
);
// Same call shape the export action uses (no column types available).
const workbook = buildXlsxWorkbook({
sheetName: "合并结果",
columns: merged.columns,
columnTypes: merged.columns.map(() => ""),
rows: merged.rows,
numericColumnRightAlign: true,
autoFilter: true,
});
expect(workbook.byteLength).toBeGreaterThan(0);
// XLSX is a zip container; the local file header magic proves the writer
// accepted the merged rows (nulls and mixed types included).
expect(String.fromCharCode(...workbook.subarray(0, 2))).toBe("PK");
});
it("shows the executed statement and the batch duration in the header", async () => {
mounted = mountView([{ key: "a", label: "conn-a", result: result(["id"], [[1]]) }], { sql: "SELECT id FROM sales", durationMs: 1250 });
await nextTick();
const header = mounted.host.querySelector("[data-multi-source-merge-view]")?.textContent ?? "";
expect(header).toContain("SELECT id FROM sales");
expect(header).toContain("1.3 s");
expect(mounted.host.querySelector("[data-merge-sql]")?.getAttribute("title")).toBe("SELECT id FROM sales");
});
it("shows the local execution timestamp of the batch", async () => {
const executedAt = new Date(2026, 8, 21, 14, 32, 5).getTime();
mounted = mountView([{ key: "a", label: "conn-a", result: result(["id"], [[1]]) }], { executedAt });
await nextTick();
const chip = mounted.host.querySelector("[data-merge-executed-at]")?.textContent ?? "";
expect(chip).toContain("2026-09-21 14:32:05");
// Timezone stays explicit, like the app's other local timestamps.
expect(chip).toMatch(/[+-]\d{2}:\d{2}/);
});
it("omits the SQL and duration chips when the host does not provide them", async () => {
mounted = mountView([{ key: "a", label: "conn-a", result: result(["id"], [[1]]) }]);
await nextTick();
expect(mounted.host.querySelector("[data-merge-sql]")).toBeNull();
expect(mounted.host.querySelector("[data-merge-duration]")).toBeNull();
expect(mounted.host.querySelector("[data-merge-executed-at]")).toBeNull();
});
describe("write statements without a result set", () => {
function writeItems() {
return [
{
key: "a",
label: "conn-a",
status: "success" as const,
durationMs: 41,
result: { columns: [], rows: [], affected_rows: 12, execution_time_ms: 41 },
},
{
key: "b",
label: "conn-b",
status: "failed" as const,
durationMs: 17,
errorMessage: "no such table: t_order",
result: { columns: ["Error"], rows: [["no such table: t_order"]], affected_rows: 0, execution_time_ms: 17, execution_error: true as const },
},
];
}
it("lists one row per target with status, affected rows and the error", async () => {
mounted = mountView(writeItems());
await nextTick();
const table = mounted.host.querySelector("[data-merge-target-results]");
expect(table).not.toBeNull();
const rows = [...(table?.querySelectorAll("tbody tr") ?? [])].map((row) => [...row.querySelectorAll("td")].map((cell) => cell.textContent?.replace(/\s+/g, " ").trim()));
expect(rows).toHaveLength(2);
expect(rows[0]?.[0]).toBe("conn-a");
expect(rows[0]?.[2]).toContain("12");
expect(rows[1]?.[0]).toBe("conn-b");
expect(rows[1]?.[2]).toBe("—");
expect(rows[1]?.[4]).toContain("no such table");
});
it("summarizes the batch by targets and affected rows instead of read statistics", async () => {
mounted = mountView(writeItems());
await nextTick();
expect(mounted.host.querySelector("[data-merge-write-summary]")?.textContent?.trim()).toBe(i18n.global.t("multiDbExecute.mergedWriteSummary", { targets: 2, rows: 12 }));
// A write batch must not advertise the per-source read cap it never uses.
expect(mounted.host.textContent).not.toContain(i18n.global.t("multiDbExecute.mergedSourceRowCap", { count: MULTI_SOURCE_MAX_ROWS_PER_SOURCE }));
// The column already names the count, so the cell carries the bare number.
const affected = [...(mounted.host.querySelectorAll("[data-merge-target-results] tbody tr") ?? [])].map((row) => [...row.querySelectorAll("td")][2]?.textContent?.trim());
expect(affected).toEqual(["12", "—"]);
});
it("offers a re-run only for targets that did not succeed", async () => {
const rerun: string[] = [];
mounted = mountView(writeItems(), { onRerunTarget: (key: string) => rerun.push(key) });
await nextTick();
const buttons = [...mounted.host.querySelectorAll("[data-merge-rerun]")];
expect(buttons).toHaveLength(1);
(buttons[0] as HTMLButtonElement).click();
await nextTick();
expect(rerun).toEqual(["b"]);
});
it("blocks re-running while the batch is still busy", async () => {
mounted = mountView(writeItems(), { rerunDisabled: true });
await nextTick();
const rerunButton = mounted.host.querySelector("[data-merge-rerun]") as HTMLButtonElement | null;
expect(rerunButton?.disabled).toBe(true);
});
});
});
@@ -0,0 +1,110 @@
// @vitest-environment happy-dom
import { createApp, h, nextTick } from "vue";
import { afterEach, describe, expect, it } from "vitest";
import i18n from "@/i18n";
import MultiSourceMergeView from "@/components/editor/MultiSourceMergeView.vue";
import type { QueryResult } from "@/types/database";
function writeResult(affectedRows: number): QueryResult {
return { columns: [], rows: [], affected_rows: affectedRows, execution_time_ms: 7 };
}
/** One batch that ran inside per-target transactions. */
function items() {
return [
{ key: "a", label: "conn-a", status: "pending_commit" as const, durationMs: 12, result: writeResult(3), transaction: { canCommit: true } },
// A target whose session survived a rollback failure: it can still be rolled back.
{ key: "b", label: "conn-b", status: "failed" as const, durationMs: 9, errorMessage: "syntax error", transaction: { canCommit: false } },
{ key: "c", label: "conn-c", status: "success" as const, durationMs: 5, result: writeResult(0) },
{ key: "d", label: "conn-d", status: "rolled_back" as const, durationMs: 4 },
];
}
function mountView(props: Record<string, unknown>) {
const host = document.createElement("div");
document.body.appendChild(host);
const app = createApp({ render: () => h(MultiSourceMergeView, props) });
app.use(i18n);
app.mount(host);
return { host, app };
}
let mounted: ReturnType<typeof mountView> | undefined;
afterEach(() => {
mounted?.app.unmount();
mounted?.host.remove();
mounted = undefined;
});
describe("MultiSourceMergeView transactions", () => {
it("shows the transaction state per target and offers commit/rollback only while open", async () => {
mounted = mountView({ items: items(), transactional: true });
await nextTick();
const states = [...mounted.host.querySelectorAll("[data-merge-txn-state]")].map((cell) => cell.textContent?.trim());
expect(states).toEqual([i18n.global.t("multiDbExecute.txnOpen"), i18n.global.t("multiDbExecute.txnOpen"), i18n.global.t("multiDbExecute.txnCommitted"), i18n.global.t("multiDbExecute.txnRolledBack")]);
// Two open sessions: each offers one commit and one rollback button.
expect(mounted.host.querySelectorAll("[data-merge-commit]")).toHaveLength(2);
expect(mounted.host.querySelectorAll("[data-merge-rollback]")).toHaveLength(2);
});
it("blocks committing a target that errored and counts only committable targets", async () => {
mounted = mountView({ items: items(), transactional: true });
await nextTick();
const commitButtons = [...mounted.host.querySelectorAll("[data-merge-commit]")] as HTMLButtonElement[];
expect(commitButtons[0]?.disabled).toBe(false);
// conn-b failed, so its transaction has nothing to write but can be discarded.
expect(commitButtons[1]?.disabled).toBe(true);
const commitAll = mounted.host.querySelector("[data-merge-commit-all]") as HTMLButtonElement;
expect(commitAll.textContent).toContain("1");
});
it("blocks every transaction control while one of them is settling", async () => {
const settling = items();
settling[0] = { ...settling[0]!, transaction: { canCommit: true, settling: true } } as (typeof settling)[number];
mounted = mountView({ items: settling, transactional: true });
await nextTick();
const commitButtons = [...mounted.host.querySelectorAll("[data-merge-commit]")] as HTMLButtonElement[];
const rollbackButtons = [...mounted.host.querySelectorAll("[data-merge-rollback]")] as HTMLButtonElement[];
expect(commitButtons[0]?.disabled).toBe(true);
expect(rollbackButtons[0]?.disabled).toBe(true);
});
it("emits per-target and batch transaction actions", async () => {
const events: string[] = [];
mounted = mountView({
items: items(),
transactional: true,
onCommitTarget: (key: string) => events.push(`commit:${key}`),
onRollbackTarget: (key: string) => events.push(`rollback:${key}`),
onCommitAll: () => events.push("commit-all"),
onRollbackAll: () => events.push("rollback-all"),
});
await nextTick();
(mounted.host.querySelectorAll("[data-merge-commit]")[0] as HTMLButtonElement).click();
(mounted.host.querySelectorAll("[data-merge-rollback]")[1] as HTMLButtonElement).click();
(mounted.host.querySelector("[data-merge-commit-all]") as HTMLButtonElement).click();
(mounted.host.querySelector("[data-merge-rollback-all]") as HTMLButtonElement).click();
await nextTick();
expect(events).toEqual(["commit:a", "rollback:b", "commit-all", "rollback-all"]);
});
it("hides every transaction control for an auto-commit batch", async () => {
const autoCommit = items().map(({ transaction: _transaction, ...item }) => ({ ...item, status: item.status === "pending_commit" ? ("success" as const) : item.status }));
mounted = mountView({ items: autoCommit, transactional: false });
await nextTick();
expect(mounted.host.querySelector("[data-merge-txn-state]")).toBeNull();
expect(mounted.host.querySelector("[data-merge-commit-all]")).toBeNull();
expect(mounted.host.querySelector("[data-merge-rollback-all]")).toBeNull();
// A failed target without a session keeps its re-run affordance instead.
expect(mounted.host.querySelector("[data-merge-rerun]")).not.toBeNull();
});
});
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
const ConnectionDialog = defineAsyncComponent(() => import("@/components/connection/ConnectionDialog.vue"));
const DangerConfirmDialog = defineAsyncComponent(() => import("@/components/editor/DangerConfirmDialog.vue"));
const MultiDbDangerConfirmDialog = defineAsyncComponent(() => import("@/components/layout/MultiDbDangerConfirmDialog.vue"));
const SqlParameterDialog = defineAsyncComponent(() => import("@/components/editor/SqlParameterDialog.vue"));
const DataTransferDialog = defineAsyncComponent(() => import("@/components/transfer/DataTransferDialog.vue"));
const SchemaDiffDialog = defineAsyncComponent(() => import("@/components/diff/SchemaDiffDialog.vue"));
@@ -126,16 +127,6 @@ watch(
if (pending) unlockDuration.value = WRITE_UNLOCK_ONE_MINUTE_SECS;
},
);
const multiDbDangerDetails = computed(() => {
const request = sqlExecutionDangerStore.pending;
if (!request) return "";
return [request.targetLabel || request.connectionName, request.database].filter(Boolean).join("\n");
});
const multiDbDangerMessage = computed(() => {
const request = sqlExecutionDangerStore.pending;
return request?.kind === "redis" ? t("dangerDialog.redisCommandMessage") : t("dangerDialog.message");
});
const editConfig = computed(() => {
const id = connectionStore.editingConnectionId;
if (!id) return undefined;
@@ -230,19 +221,7 @@ watch(
</fieldset>
</template>
</DangerConfirmDialog>
<DangerConfirmDialog
v-if="sqlExecutionDangerStore.pending"
:open="true"
:title="t('multiDbExecute.dangerTitle')"
:message="multiDbDangerMessage"
:details-text="multiDbDangerDetails"
:sql="sqlExecutionDangerStore.pending.sql"
:confirm-label="t('multiDbExecute.dangerConfirm')"
:show-suppress-toggle="false"
:close-on-confirm="false"
@update:open="(open) => !open && sqlExecutionDangerStore.cancel()"
@confirm="sqlExecutionDangerStore.confirm()"
/>
<MultiDbDangerConfirmDialog v-if="sqlExecutionDangerStore.pending" :request="sqlExecutionDangerStore.pending" @confirm="sqlExecutionDangerStore.confirm()" @cancel="sqlExecutionDangerStore.cancel()" />
<SqlParameterDialog
v-if="showSqlParameterDialog"
:open="showSqlParameterDialog"
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Check, Copy } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import { copyToClipboard } from "@/lib/common/clipboard";
import { createExecutionConfirmCode, matchesExecutionConfirmCode } from "@/lib/query/executionConfirmCode";
import type { SqlExecutionDangerRequest } from "@/stores/sqlExecutionDangerStore";
/**
* Confirmation for one multi-database batch. A batch runs the same statement
* against every data source, so the operator retypes a code that is generated
* per prompt: a stray click or a held Enter cannot fan a destructive statement
* out to all connections.
*/
const props = defineProps<{ request: SqlExecutionDangerRequest }>();
const emit = defineEmits<{ confirm: []; cancel: [] }>();
const { t } = useI18n();
const details = computed(() => {
const headline = [props.request.targetLabel || props.request.connectionName, props.request.database].filter(Boolean).join(" · ");
const targets = props.request.targets ?? [];
if (targets.length <= 1) return headline;
return [headline, "", t("multiDbExecute.dangerTargets", { count: targets.length }), ...targets.map((target) => `• ${target}`)].join("\n");
});
const message = computed(() => (props.request.kind === "redis" ? t("dangerDialog.redisCommandMessage") : t("dangerDialog.message")));
const code = ref("");
const codeInput = ref("");
const codeCopied = ref(false);
const confirmDisabled = computed(() => !matchesExecutionConfirmCode(codeInput.value, code.value));
// Every prompt gets its own code, and the previous attempt is cleared.
watch(
() => props.request,
() => {
code.value = createExecutionConfirmCode();
codeInput.value = "";
codeCopied.value = false;
},
{ immediate: true },
);
async function copyCode(): Promise<void> {
await copyToClipboard(code.value);
codeCopied.value = true;
window.setTimeout(() => {
codeCopied.value = false;
}, 1500);
}
</script>
<template>
<DangerConfirmDialog
:open="true"
:title="t('multiDbExecute.dangerTitle')"
:message="message"
:details-text="details"
:sql="request.sql"
:confirm-label="t('multiDbExecute.dangerConfirm')"
:confirm-disabled="confirmDisabled"
:show-suppress-toggle="false"
:close-on-confirm="false"
@update:open="(open) => !open && emit('cancel')"
@confirm="emit('confirm')"
>
<template #options>
<div class="mb-3 space-y-1.5 rounded-md border bg-muted/20 px-3 py-2.5">
<div class="flex items-center justify-between gap-2">
<Label for="multi-db-danger-code" class="text-xs leading-5 text-muted-foreground">{{ t("multiDbExecute.dangerConfirmCodePrompt") }}</Label>
<Button variant="ghost" size="icon-xs" class="h-6 w-6 text-muted-foreground" :title="t('multiDbExecute.dangerConfirmCodeCopy')" data-multi-db-danger-code-copy @click="copyCode">
<Check v-if="codeCopied" class="h-3.5 w-3.5 text-emerald-600" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
</div>
<p class="font-mono text-lg leading-6 tracking-[0.3em] text-foreground" data-multi-db-danger-code-value>{{ code }}</p>
<Input id="multi-db-danger-code" v-model="codeInput" data-multi-db-danger-code class="h-8 font-mono tracking-widest" autocomplete="off" spellcheck="false" :placeholder="t('multiDbExecute.dangerConfirmCodePlaceholder')" />
<!-- The whole batch is answered by this one code, so the instruction
has to stand out next to the input instead of blending into it. -->
<p class="text-xs leading-4 font-semibold text-destructive" data-multi-db-danger-code-once>{{ t("multiDbExecute.dangerConfirmCodeOnce") }}</p>
</div>
</template>
</DangerConfirmDialog>
</template>
@@ -0,0 +1,171 @@
// @vitest-environment happy-dom
import { createApp, h, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import MultiDbDangerConfirmDialog from "@/components/layout/MultiDbDangerConfirmDialog.vue";
import type { SqlExecutionDangerRequest } from "@/stores/sqlExecutionDangerStore";
const mocks = vi.hoisted(() => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) }));
vi.mock("@/lib/common/clipboard", () => ({ copyToClipboard: mocks.copyToClipboard }));
function request(overrides: Partial<SqlExecutionDangerRequest> = {}): SqlExecutionDangerRequest {
return {
sql: "UPDATE accounts SET balance = 0",
kind: "sql",
connectionName: "prod-a",
database: "shop",
targetLabel: "prod-a / shop",
...overrides,
};
}
function mountDialog(props: Record<string, unknown>) {
const host = document.createElement("div");
document.body.appendChild(host);
const app = createApp({ render: () => h(MultiDbDangerConfirmDialog, props) });
app.use(i18n);
app.mount(host);
return { host, app };
}
let mounted: { host: HTMLElement; app: App } | undefined;
afterEach(() => {
mounted?.app.unmount();
mounted?.host.remove();
mounted = undefined;
mocks.copyToClipboard.mockClear();
});
/** The dialog content is portaled to the document body. */
function codeValue(): string {
return document.body.querySelector("[data-multi-db-danger-code-value]")?.textContent?.trim() ?? "";
}
function codeInput(): HTMLInputElement {
const input = document.body.querySelector<HTMLInputElement>("[data-multi-db-danger-code]");
if (!input) throw new Error("Missing code input");
return input;
}
function confirmButton(): HTMLButtonElement {
const button = [...document.body.querySelectorAll("button")].find((candidate) => candidate.textContent?.trim() === i18n.global.t("multiDbExecute.dangerConfirm"));
if (!button) throw new Error("Missing confirm button");
return button;
}
async function typeCode(value: string) {
const input = codeInput();
input.value = value;
input.dispatchEvent(new Event("input"));
await nextTick();
}
describe("multi-database batch confirmation", () => {
it("lists every data source of the batch next to the target that prompted", async () => {
mounted = mountDialog({ request: request({ targets: ["prod-a / shop", "prod-b / shop", "prod-c / shop"] }) });
await nextTick();
const text = document.body.textContent ?? "";
expect(text).toContain("prod-a / shop");
expect(text).toContain(i18n.global.t("multiDbExecute.dangerTargets", { count: 3 }));
expect(text).toContain("• prod-b / shop");
expect(text).toContain("• prod-c / shop");
expect(document.body.textContent).toContain("UPDATE accounts SET balance = 0");
});
it("keeps the headline only when the statement touches a single source", async () => {
mounted = mountDialog({ request: request({ targets: ["prod-a / shop"] }) });
await nextTick();
expect(document.body.textContent).not.toContain(i18n.global.t("multiDbExecute.dangerTargets", { count: 1 }));
expect(document.body.textContent).toContain("prod-a / shop");
});
it("unlocks the confirm button only for the code of this prompt", async () => {
mounted = mountDialog({ request: request() });
await nextTick();
const code = codeValue();
expect(code).toHaveLength(6);
expect(confirmButton().disabled).toBe(true);
await typeCode("WRONG1");
expect(confirmButton().disabled).toBe(true);
await typeCode(code);
expect(confirmButton().disabled).toBe(false);
});
it("accepts a code typed in lower case", async () => {
mounted = mountDialog({ request: request() });
await nextTick();
await typeCode(codeValue().toLowerCase());
expect(confirmButton().disabled).toBe(false);
});
it("copies the displayed code and clears the typed attempt for a new prompt", async () => {
mounted = mountDialog({ request: request() });
await nextTick();
const firstCode = codeValue();
await typeCode(firstCode);
(document.body.querySelector("[data-multi-db-danger-code-copy]") as HTMLButtonElement).click();
await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenCalledWith(firstCode));
// The next prompt is a fresh confirmation, not the previous one re-armed.
mounted.app.unmount();
mounted.host.remove();
mounted = mountDialog({ request: request({ sql: "DELETE FROM accounts" }) });
await nextTick();
const nextCode = codeValue();
expect(nextCode).toHaveLength(6);
expect(codeInput().value).toBe("");
expect(confirmButton().disabled).toBe(true);
});
it("reports confirm and cancel back to the batch", async () => {
const events: string[] = [];
mounted = mountDialog({
request: request(),
onConfirm: () => events.push("confirm"),
onCancel: () => events.push("cancel"),
});
await nextTick();
// Cancelling the prompt answers the batch without running it.
const cancel = [...document.body.querySelectorAll("button")].find((candidate) => candidate.textContent?.trim() === i18n.global.t("dangerDialog.cancel"));
cancel?.click();
await nextTick();
expect(events).toEqual(["cancel"]);
await typeCode(codeValue());
confirmButton().click();
await nextTick();
expect(events).toEqual(["cancel", "confirm"]);
});
it("still confirms the single-connection danger dialog without any code", async () => {
const { default: DangerConfirmDialog } = await import("@/components/editor/DangerConfirmDialog.vue");
const host = document.createElement("div");
document.body.appendChild(host);
const confirms: number[] = [];
const app = createApp({ render: () => h(DangerConfirmDialog, { open: true, title: "Drop table", message: "This cannot be undone", onConfirm: () => confirms.push(1) }) });
app.use(i18n);
app.mount(host);
try {
await nextTick();
expect(document.body.querySelector("[data-multi-db-danger-code]")).toBeNull();
const confirm = [...document.body.querySelectorAll("button")].find((candidate) => candidate.textContent?.trim() === i18n.global.t("dangerDialog.confirm"));
expect(confirm).toBeDefined();
confirm?.click();
await nextTick();
expect(confirms).toEqual([1]);
} finally {
app.unmount();
host.remove();
}
});
});
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { ref } from "vue";
import { useMultiDbExecution } from "@/composables/useMultiDbExecution";
import { useMultiDbExecution, type MultiDbExecutionAdapter } from "@/composables/useMultiDbExecution";
import type { MultiDbTargetExecutionResult } from "@/types/sqlExecution";
const targets = [
{ connectionId: "conn-1", database: "app" },
@@ -9,6 +10,181 @@ const targets = [
] as const;
describe("useMultiDbExecution", () => {
it.each(["serial", "parallel"] as const)("tracks a single-target retry in a %s batch while preserving successful targets and manual transactions", async (mode) => {
let release!: (result: MultiDbTargetExecutionResult) => void;
const retryResult = new Promise<MultiDbTargetExecutionResult>((resolve) => {
release = resolve;
});
const finish = vi.fn().mockResolvedValue(undefined);
const validateTarget = vi.fn().mockResolvedValue({ valid: true });
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async ({ target }) => (target.connectionId === "conn-1" ? { status: "pending_commit", transaction: { canCommit: true, finish } } : { status: "failed", durationMs: 999, errorMessage: "original failure" }));
const executor = useMultiDbExecution({ executeTarget, validateTarget }, { sourceTabId: "source" });
const batch = (await executor.start("UPDATE t SET n = 1", targets.slice(0, 2), { manualTransaction: true, sourceOffset: 17 }, mode))!;
const [successful, failed] = batch.items;
const originalContext = executeTarget.mock.calls[1]![0].context;
const originalStartedAt = batch.startedAt;
executeTarget.mockImplementationOnce(() => retryResult);
const retry = executor.retry(failed!.id);
expect(executor.isRunning.value).toBe(true);
expect(batch.status).toBe("running");
expect(batch.completedAt).toBeUndefined();
expect(batch.durationMs).toBeUndefined();
expect(failed).toMatchObject({ status: "running", errorMessage: undefined, durationMs: undefined, completedAt: undefined });
executor.reset();
expect(executor.batch.value?.id).toBe(batch.id);
expect(await executor.start("SELECT 2", targets)).toBeUndefined();
await executor.retry(failed!.id);
expect(await executor.finishTransaction(successful!.id, "commit")).toBe(false);
await Promise.resolve();
expect(executeTarget).toHaveBeenCalledTimes(3);
expect(validateTarget).toHaveBeenLastCalledWith(targets[1]);
expect(executeTarget.mock.calls[2]![0]).toMatchObject({ target: targets[1], sourceTabId: "source", sql: batch.sql, scopeId: batch.id });
expect(executeTarget.mock.calls[2]![0].context).toBe(originalContext);
expect(finish).not.toHaveBeenCalled();
release({ status: "pending_commit", durationMs: 12, transaction: { canCommit: true, finish } });
await retry;
expect(batch.startedAt).toBe(originalStartedAt);
expect(batch.status).toBe("completed");
expect(batch.items.map((item) => item.status)).toEqual(["pending_commit", "pending_commit"]);
expect(failed!.durationMs).toBe(12);
await executor.retry(failed!.id);
expect(executeTarget).toHaveBeenCalledTimes(3);
expect(await executor.finishTransaction(failed!.id, "commit")).toBe(true);
expect(await executor.finishTransaction(successful!.id, "rollback")).toBe(true);
expect(finish.mock.calls.map(([action]) => action)).toEqual(["commit", "rollback"]);
executor.reset();
expect(executor.batch.value).toBeUndefined();
});
it.each(["cancelAndRollback", "dispose"] as const)("%s waits for a deferred retry and rolls back its late transaction", async (cleanup) => {
let release!: (result: MultiDbTargetExecutionResult) => void;
const retryResult = new Promise<MultiDbTargetExecutionResult>((resolve) => {
release = resolve;
});
const finish = vi.fn().mockResolvedValue(undefined);
const executeTarget = vi
.fn<MultiDbExecutionAdapter["executeTarget"]>()
.mockResolvedValueOnce({ status: "failed" })
.mockImplementationOnce(() => retryResult);
const cancelPending = vi.fn().mockRejectedValue(new Error("confirmation already closed"));
const cancelTarget = vi.fn().mockRejectedValue(new Error("cancel unavailable"));
const executor = useMultiDbExecution({ executeTarget, cancelPending, cancelTarget }, { sourceTabId: "source" });
const batch = (await executor.start("UPDATE t SET n = 1", [targets[0]], { manualTransaction: true }))!;
const retry = executor.retry(batch.items[0]!.id);
const isCancellationRequested = executeTarget.mock.calls[1]![0].isCancellationRequested;
expect(isCancellationRequested()).toBe(false);
let cleaned = false;
const cleaning = executor[cleanup]().then((result) => {
cleaned = true;
return result;
});
await vi.waitFor(() => expect(cancelTarget).toHaveBeenCalledExactlyOnceWith("source", batch.id));
expect(cancelPending).toHaveBeenCalledExactlyOnceWith(batch.id);
expect(isCancellationRequested()).toBe(true);
expect(batch.status).toBe("cancelling");
executor.reset();
expect(executor.batch.value?.id).toBe(batch.id);
expect(await executor.start("SELECT 2", targets)).toBeUndefined();
expect(cleaned).toBe(false);
expect(finish).not.toHaveBeenCalled();
release({ status: "pending_commit", transaction: { canCommit: true, finish } });
await retry;
expect(await cleaning).toBe(true);
expect(finish).toHaveBeenCalledExactlyOnceWith("rollback");
expect(batch.status).toBe("cancelled");
expect(batch.items[0]!.status).toBe("rolled_back");
expect(executor.hasTransactions.value).toBe(false);
if (cleanup === "dispose") {
await executor.retry(batch.items[0]!.id);
expect(await executor.start("SELECT 2", targets)).toBeUndefined();
expect(executeTarget).toHaveBeenCalledTimes(2);
}
});
it("retains a retry transaction when disposal cannot roll it back", async () => {
let release!: (result: MultiDbTargetExecutionResult) => void;
const retryResult = new Promise<MultiDbTargetExecutionResult>((resolve) => {
release = resolve;
});
const finish = vi.fn().mockRejectedValue(new Error("rollback unavailable"));
const executeTarget = vi
.fn<MultiDbExecutionAdapter["executeTarget"]>()
.mockResolvedValueOnce({ status: "failed" })
.mockImplementationOnce(() => retryResult);
const executor = useMultiDbExecution({ executeTarget }, { sourceTabId: "source" });
const batch = (await executor.start("UPDATE t SET n = 1", [targets[0]], { manualTransaction: true }))!;
const retry = executor.retry(batch.items[0]!.id);
const disposing = executor.dispose();
release({ status: "failed", transaction: { canCommit: false, finish } });
await retry;
expect(await disposing).toBe(false);
expect(batch.items[0]!.errorMessage).toBe("rollback unavailable");
expect(executor.hasTransactions.value).toBe(true);
executor.reset();
expect(executor.batch.value?.id).toBe(batch.id);
await executor.retry(batch.items[0]!.id);
expect(executeTarget).toHaveBeenCalledTimes(2);
expect(await executor.finishTransaction(batch.items[0]!.id, "commit")).toBe(false);
finish.mockResolvedValue(undefined);
expect(await executor.rollbackPending()).toBe(true);
});
it("clears cancellation when retrying one target without executing the cancelled queue", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async () => {
await gate;
return { status: "failed" };
});
const executor = useMultiDbExecution({ executeTarget }, { sourceTabId: "source" });
const run = executor.start("SELECT 1", targets);
await executor.cancel();
release();
const batch = (await run)!;
expect(batch.status).toBe("cancelled");
executeTarget.mockImplementationOnce(async ({ isCancellationRequested }) => {
expect(isCancellationRequested()).toBe(false);
return { status: "success" };
});
await executor.retry(batch.items[0]!.id);
expect(batch.cancelRequested).toBe(false);
expect(batch.status).toBe("completed");
expect(batch.items.map((item) => item.status)).toEqual(["success", "not_executed", "not_executed"]);
await executor.retry(batch.items[0]!.id);
await executor.retry("missing");
expect(executeTarget).toHaveBeenCalledTimes(2);
});
it.each(["invalid", "throws", "cancelled"] as const)("revalidates retries and does not execute when validation is %s", async (outcome) => {
let release!: (result: { valid: boolean; errorMessage?: string }) => void;
const validation = new Promise<{ valid: boolean; errorMessage?: string }>((resolve) => {
release = resolve;
});
const validateTarget = vi.fn().mockResolvedValueOnce({ valid: true });
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>().mockResolvedValue({ status: "failed", durationMs: 999, errorMessage: "old failure" });
const executor = useMultiDbExecution({ executeTarget, validateTarget }, { sourceTabId: "source" });
const batch = (await executor.start("SELECT 1", [targets[0]]))!;
if (outcome === "throws") validateTarget.mockRejectedValueOnce(new Error("validation unavailable"));
else validateTarget.mockImplementationOnce(() => validation);
const retry = executor.retry(batch.items[0]!.id);
if (outcome === "cancelled") await executor.cancel();
release({ valid: outcome === "cancelled", errorMessage: "target unavailable" });
await retry;
expect(executeTarget).toHaveBeenCalledTimes(1);
expect(validateTarget).toHaveBeenCalledTimes(2);
expect(batch.items[0]!.status).toBe(outcome === "cancelled" ? "cancelled" : "failed");
expect(batch.items[0]!.durationMs).not.toBe(999);
expect(batch.items[0]!.errorMessage).toBe(outcome === "throws" ? "validation unavailable" : outcome === "invalid" ? "target unavailable" : undefined);
expect(executor.isRunning.value).toBe(false);
executor.reset();
expect(executor.batch.value).toBeUndefined();
});
it("retains separate transactions until each target is explicitly settled", async () => {
const commits = targets.map(() => vi.fn().mockResolvedValue(undefined));
const executor = useMultiDbExecution(
@@ -1,7 +1,7 @@
import { computed, nextTick, ref } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { isDangerousSql, requiresDatabaseSelection, supportsSqlTemplateParameters, useSqlExecution } from "../useSqlExecution";
import { isDangerousSql, requiresDatabaseSelection, snapshotResultForMerge, supportsSqlTemplateParameters, useSqlExecution } from "../useSqlExecution";
import { useConnectionStore } from "@/stores/connectionStore";
import { useHistoryStore } from "@/stores/historyStore";
import { useQueryStore } from "@/stores/queryStore";
@@ -1612,3 +1612,35 @@ SELECT @value AS Message;`;
expect(executeCurrentSql).toHaveBeenCalledTimes(1);
});
});
describe("snapshotResultForMerge", () => {
it("stays intact after the store releases the live payload in place", () => {
const live = {
columns: ["id", "amount"],
rows: [
[1, 10],
[2, 20],
],
column_types: ["INT", "DECIMAL"],
affected_rows: 2,
execution_time_ms: 5,
};
const snapshot = snapshotResultForMerge(live as never)!;
// Mirrors queryStore.releaseResultObjectPayload: the arrays the merged view
// reads are detached, so clearing the live result must not empty them.
live.columns = [];
live.rows = [];
expect(snapshot.columns).toEqual(["id", "amount"]);
expect(snapshot.rows).toEqual([
[1, 10],
[2, 20],
]);
expect(snapshot.column_types).toEqual(["INT", "DECIMAL"]);
});
it("passes undefined through", () => {
expect(snapshotResultForMerge(undefined)).toBeUndefined();
});
});
@@ -15,6 +15,7 @@
import { computed, ref } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MULTI_SOURCE_MAX_ROWS_PER_SOURCE } from "@/lib/query/multiSourceResult";
import { useSqlExecution } from "../useSqlExecution";
import { useHistoryStore } from "@/stores/historyStore";
import { useQueryStore } from "@/stores/queryStore";
@@ -313,4 +314,67 @@ describe("preservedResultIndex staleness theory", () => {
expect(options).not.toHaveProperty("preserveActiveResultIndex");
expect(tab.activeResultIndex).toBe(0);
});
it("reads one multi-database target up to the per-source row cap, not just the editor page", async () => {
const sql = "SELECT 1 AS value";
const tab = { ...queryTab(), sql };
const connection = sqlServerConnection();
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const queryStore = useQueryStore();
const executeTabSql = vi.spyOn(queryStore, "executeTabSql").mockImplementation(async () => true);
vi.spyOn(queryStore, "getExecutionTab").mockReturnValue(tab);
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => tab as QueryTab | undefined),
activeConnection: computed(() => connection),
executableSql: computed(() => sql),
activeOutputView,
});
await execution.executeTargetSql({ tab, connection, sql });
expect(MULTI_SOURCE_MAX_ROWS_PER_SOURCE).toBe(10_000);
expect(executeTabSql.mock.calls[0]?.[2]).toMatchObject({ pagination: { limit: MULTI_SOURCE_MAX_ROWS_PER_SOURCE, offset: 0 } });
});
it("does not count the time an operator spends on the danger confirmation", async () => {
vi.useFakeTimers();
try {
const sql = "UPDATE t_order SET shard_no = 999";
const tab = { ...queryTab(), sql };
const connection = sqlServerConnection();
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const queryStore = useQueryStore();
vi.spyOn(queryStore, "executeTabSql").mockImplementation(async () => {
tab.result = { columns: [], rows: [], affected_rows: 2, execution_time_ms: 1 };
return true;
});
vi.spyOn(queryStore, "getExecutionTab").mockReturnValue(tab);
const history = vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
let answer: ((confirmed: boolean) => void) | undefined;
const execution = useSqlExecution({
activeTab: computed(() => tab as QueryTab | undefined),
activeConnection: computed(() => connection),
executableSql: computed(() => sql),
activeOutputView,
requestDangerConfirmation: () => new Promise<boolean>((resolve) => (answer = resolve)),
});
const running = execution.executeTargetSql({ tab, connection, sql });
await vi.advanceTimersByTimeAsync(9_000);
answer?.(true);
const result = await running;
// The batch is parked on the prompt, so neither the target nor the history
// row may report those nine seconds as execution time.
expect(result.status).toBe("success");
expect(result.durationMs).toBeLessThan(1_000);
expect(history.mock.calls[0]?.[0]).toMatchObject({ execution_time_ms: expect.any(Number) });
expect((history.mock.calls[0]?.[0] as { execution_time_ms: number }).execution_time_ms).toBeLessThan(1_000);
} finally {
vi.useRealTimers();
}
});
});
@@ -1,5 +1,6 @@
import { computed, reactive, ref, type ComputedRef, type Ref } from "vue";
import type { MultiDbExecutionTarget, MultiDbExecutionItemStatus, MultiDbTargetExecutionResult, MultiDbManualTransaction } from "@/types/sqlExecution";
import type { QueryResult } from "@/types/database";
export interface MultiDbExecutionItem {
id: string;
@@ -11,6 +12,8 @@ export interface MultiDbExecutionItem {
durationMs?: number;
transaction?: MultiDbManualTransaction;
settling?: boolean;
/** Result produced by this target, kept for the merged multi-source view. */
result?: QueryResult;
}
export interface MultiDbExecutionBatch {
@@ -158,6 +161,7 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M
item.status = current.cancelRequested && result.status === "failed" ? "cancelled" : result.status;
item.errorMessage = result.errorMessage;
item.durationMs = result.durationMs;
item.result = result.result;
item.transaction = result.transaction;
} catch (error) {
// One target is intentionally isolated from the queue. Adapter errors
@@ -170,11 +174,11 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M
}
}
async function executeBatch(current: MultiDbExecutionBatch): Promise<void> {
async function executeBatch(current: MultiDbExecutionBatch, items = current.items): Promise<void> {
if (current.mode === "parallel") {
await Promise.all(current.items.map((item) => executeItem(current, item)));
await Promise.all(items.map((item) => executeItem(current, item)));
} else {
for (const item of current.items) {
for (const item of items) {
await executeItem(current, item);
if (current.cancelRequested) break;
}
@@ -189,8 +193,17 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M
current.durationMs = current.completedAt - current.startedAt;
}
async function runBatch(current: MultiDbExecutionBatch, items = current.items): Promise<void> {
activeRun = executeBatch(current, items);
try {
await activeRun;
} finally {
activeRun = undefined;
}
}
async function start(sql: string, targets: readonly MultiDbExecutionTarget[], context: MultiDbExecutionContextOverrides = {}, mode: MultiDbExecutionMode = "serial"): Promise<MultiDbExecutionBatch | undefined> {
if (disposed || isRunning.value || hasTransactions.value || !sql.trim() || targets.length === 0) return undefined;
if (disposed || activeRun || isRunning.value || hasTransactions.value || !sql.trim() || targets.length === 0) return undefined;
const sourceId = sourceTabId();
const id = executionId();
const targetSnapshot = targets.map((target) => Object.freeze({ ...target }));
@@ -216,12 +229,25 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M
startedAt: Date.now(),
});
batch.value = current;
activeRun = executeBatch(current);
await activeRun;
activeRun = undefined;
await runBatch(current);
return current;
}
async function retry(itemId: string): Promise<void> {
const current = batch.value;
const item = current?.items.find((candidate) => candidate.id === itemId);
if (disposed || activeRun || isRunning.value || !current || !item || item.transaction || item.settling || !["failed", "cancelled", "not_executed", "skipped", "rolled_back"].includes(item.status)) return;
current.status = "running";
current.cancelRequested = false;
current.completedAt = undefined;
current.durationMs = undefined;
item.errorMessage = undefined;
item.completedAt = undefined;
item.durationMs = undefined;
item.result = undefined;
await runBatch(current, [item]);
}
async function cancel(): Promise<void> {
const current = batch.value;
if (!current || current.status !== "running") return;
@@ -250,7 +276,7 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M
}
function reset(): void {
if (isRunning.value || hasTransactions.value) return;
if (activeRun || isRunning.value || hasTransactions.value) return;
batch.value = undefined;
}
@@ -275,6 +301,7 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M
dispose,
cancelAndRollback,
start,
retry,
cancel,
reset,
};
+89 -32
View File
@@ -28,10 +28,11 @@ import { assessProductionSql } from "@/lib/database/productionSafety";
import { ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import type { SqlExecutionDangerRequest } from "@/stores/sqlExecutionDangerStore";
import type { ConnectionConfig, DatabaseType, QueryTab } from "@/types/database";
import type { ConnectionConfig, DatabaseType, QueryResult, QueryTab } from "@/types/database";
import type { MultiDbExecutionTarget, MultiDbResultRunExecution, MultiDbTargetExecutionResult, MultiDbManualTransaction } from "@/types/sqlExecution";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import type { SqlExecutionTargetContext } from "@/lib/database/sqlExecutionTargetRegistry";
import { MULTI_SOURCE_MAX_ROWS_PER_SOURCE } from "@/lib/query/multiSourceResult";
import { translateBackendError } from "@/i18n/backend-errors";
const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i;
@@ -66,6 +67,8 @@ interface TargetSqlExecutionInput {
sourceOffset?: number;
blockDangerousRedisCommands?: boolean;
targetLabel?: string;
/** Labels of every target in the batch, shown in the confirmation prompt. */
batchTargetLabels?: string[];
scopeId?: string;
isCancellationRequested?: () => boolean;
targetContext?: SqlExecutionTargetContext;
@@ -79,6 +82,27 @@ export function stripSqlComments(sql: string): string {
.replace(/#.*$/gm, " ");
}
/**
* Detached copy of a result for the merged multi-source view.
*
* The store releases a result payload by clearing its columns/rows *in place*
* (see `releaseResultObjectPayload`) as soon as the run loses focus, and a
* multi-db worker's tab is removed right after it finishes. Holding the live
* result would therefore let the merged view lose earlier sources, so the
* arrays it reads are detached here (cells themselves are never mutated).
*/
export function snapshotResultForMerge(result: QueryResult | undefined): QueryResult | undefined {
if (!result) return undefined;
return {
...result,
columns: [...result.columns],
rows: result.rows.slice(),
column_types: result.column_types ? [...result.column_types] : undefined,
local_column_filters: undefined,
local_hidden_column_keys: undefined,
};
}
const ELASTICSEARCH_TRANSIENT_DELETE_PATHS = [/^\/_search\/scroll\/?$/i, /^\/_pit\/?$/i, /^\/_async_search\/[^/?]+\/?$/i];
const ELASTICSEARCH_DESTRUCTIVE_POST_PATHS = [/(?:^|\/)_(?:delete_by_query|update_by_query|bulk)(?:\/|$)/i, /^\/_reindex(?:\/|$)/i, /^\/_aliases(?:\/|$)/i, /\/_restore(?:\/|$)/i];
@@ -466,6 +490,21 @@ export function useSqlExecution(deps: {
async function executeTargetSql(input: TargetSqlExecutionInput): Promise<MultiDbTargetExecutionResult> {
const { tab, connection, sql, sourceOffset, targetLabel } = input;
const startedAt = Date.now();
let confirmationWaitMs = 0;
/**
* A danger or production prompt parks the target until the operator answers.
* Reading and typing that answer is not execution time, so it is excluded
* from the duration this target reports.
*/
const waitForConfirmation = async <T>(prompt: () => Promise<T> | undefined): Promise<T | undefined> => {
const waitStartedAt = Date.now();
try {
return await prompt();
} finally {
confirmationWaitMs += Date.now() - waitStartedAt;
}
};
const elapsedMs = () => Math.max(0, Date.now() - startedAt - confirmationWaitMs);
const executionTab = input.executionTarget
? {
...tab,
@@ -477,7 +516,7 @@ export function useSqlExecution(deps: {
: tab;
const finish = (result: MultiDbTargetExecutionResult): MultiDbTargetExecutionResult => ({
...result,
durationMs: Date.now() - startedAt,
durationMs: elapsedMs(),
});
const cancelRequested = () => input.isCancellationRequested?.() === true;
const tabCancelRequested = (count: number) => (tab.cancelRequestCount ?? 0) !== count;
@@ -509,15 +548,18 @@ export function useSqlExecution(deps: {
return finish({ status: "skipped", errorMessage: t("redis.blockedCommand", { command: "Redis" }) });
}
if (highestSafety === "confirm") {
const confirmed = await deps.requestDangerConfirmation?.({
sql,
kind: "redis",
connectionName: connection.name,
database: executionTab.database,
targetLabel,
databaseType: connection.db_type,
scopeId: input.scopeId,
});
const confirmed = await waitForConfirmation(() =>
deps.requestDangerConfirmation?.({
sql,
kind: "redis",
connectionName: connection.name,
database: executionTab.database,
targetLabel,
targets: input.batchTargetLabels,
databaseType: connection.db_type,
scopeId: input.scopeId,
}),
);
if (cancelRequested()) return finish({ status: "cancelled" });
if (!confirmed) return finish({ status: "skipped", errorMessage: t("dangerDialog.cancel") });
}
@@ -525,28 +567,33 @@ export function useSqlExecution(deps: {
const productionAssessment = assessProductionSql(sql, connection, executionTab.database);
if (productionAssessment.active && productionAssessment.isMutation) {
const confirmed = await productionSafetyStore.requestConfirmation({
sql,
connectionName: connection.name,
database: executionTab.database,
productionDatabases: productionAssessment.databases,
source: t("production.sourceMultiDbSql"),
scopeId: input.scopeId,
});
const confirmed = await waitForConfirmation(() =>
productionSafetyStore.requestConfirmation({
sql,
connectionName: connection.name,
database: executionTab.database,
productionDatabases: productionAssessment.databases,
source: t("production.sourceMultiDbSql"),
scopeId: input.scopeId,
}),
);
if (cancelRequested()) return finish({ status: "cancelled" });
if (!confirmed) return finish({ status: "skipped", errorMessage: t("dangerDialog.cancel") });
}
if (isDangerousSql(sql, connection.db_type) && settingsStore.editorSettings.confirmDangerousSqlExecution) {
const confirmed = await deps.requestDangerConfirmation?.({
sql,
kind: "sql",
connectionName: connection.name,
database: executionTab.database,
targetLabel,
databaseType: connection.db_type,
scopeId: input.scopeId,
});
const confirmed = await waitForConfirmation(() =>
deps.requestDangerConfirmation?.({
sql,
kind: "sql",
connectionName: connection.name,
database: executionTab.database,
targetLabel,
targets: input.batchTargetLabels,
databaseType: connection.db_type,
scopeId: input.scopeId,
}),
);
if (cancelRequested()) return finish({ status: "cancelled" });
if (!confirmed) return finish({ status: "skipped", errorMessage: t("dangerDialog.cancel") });
}
@@ -567,7 +614,7 @@ export function useSqlExecution(deps: {
target: input.resultRun.target,
title: input.resultRun.title,
status,
durationMs: Date.now() - startedAt,
durationMs: elapsedMs(),
errorMessage,
});
};
@@ -668,6 +715,10 @@ export function useSqlExecution(deps: {
} else
await queryStore.executeTabSql(executionTabId, sql, {
resultBaseSql: sql,
// A multi-database run is read to be inspected and exported as one
// merged table, so every source fetches a real body of rows instead of
// the editor's first page. The user's own result-row limit still wins.
pagination: { limit: MULTI_SOURCE_MAX_ROWS_PER_SOURCE, offset: 0 },
...(input.targetContext ? { targetContext: input.targetContext } : {}),
...(sourceOffset !== undefined ? { sourceOffset } : {}),
...(connection.db_type === "redis" ? { skipRedisSafetyCheck: !blockRedisCommands } : {}),
@@ -680,9 +731,13 @@ export function useSqlExecution(deps: {
const failure = firstQueryExecutionError(latest);
const errorMessage = failure ? (failure.error ? translateBackendError(t, failure.error, failure.rows?.[0]?.[0]) : String(failure.rows?.[0]?.[0] ?? t("common.failed"))) : undefined;
const success = !failure;
// The produced result travels back with the target status so the dialog can
// union every target's rows into the merged multi-source view. Snapshot it
// before recording the run: later cleanup releases the worker payload.
const mergeResult = snapshotResultForMerge(latest.result);
const resultStatus = success ? (input.manualTransaction ? "pending_commit" : "success") : "failed";
recordedRunId = captureWorkerResult(resultStatus, errorMessage);
const executionDuration = Date.now() - startedAt;
const executionDuration = elapsedMs();
const recordOutcome = async () => {
await historyStore.add({
connection_id: executionTab.connectionId,
@@ -712,7 +767,7 @@ export function useSqlExecution(deps: {
transaction.canCommit = true;
retainedWorker = true;
recordCommittedOutcome = recordOutcome;
return finish({ status: "pending_commit", transaction });
return finish({ status: "pending_commit", transaction, result: mergeResult });
}
await recordOutcome();
if (input.manualTransaction) return await failedManualResult("failed", errorMessage);
@@ -723,7 +778,9 @@ export function useSqlExecution(deps: {
if (!workerId && deps.activeTab.value?.id === tab.id) {
deps.activeOutputView.value = success && latest.result?.server_message === true ? "messages" : success && (latest.result?.columns.length || latest.results?.some((result) => result.columns.length)) ? "result" : "summary";
}
return finish(success ? { status: "success", errorMessage } : { status: "failed", errorMessage });
// A target that ran inside a manual transaction keeps its own worker and
// session alive, and reports the transaction the merged view settles.
return finish(success ? { status: "success", errorMessage, result: mergeResult } : { status: "failed", errorMessage, result: mergeResult });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
captureWorkerResult("failed", errorMessage);
+40
View File
@@ -478,6 +478,7 @@ export default {
switchToSerial: "Switch to serial",
elapsed: "Elapsed: {duration}",
pending: "Pending",
awaitingConfirmation: "Waiting for confirmation",
running: "Running",
success: "Success",
failed: "Failed",
@@ -489,6 +490,45 @@ export default {
dangerTitle: "Confirm target execution",
dangerConfirm: "Execute on this target",
resultTabPrefix: "Multi-db",
mergeResults: "Merged result view",
mergeResultsBack: "Back to progress",
mergedSourceColumn: "Source",
mergedSources: "{count} data sources · {rows} rows",
mergedSourceRowCap: "Up to {count} rows per data source",
mergedWriteSummary: "{targets} targets · {rows} rows affected",
mergedSourceCapReached: "{count} data source(s) hit the per-source limit ({limit} rows); more rows may exist",
mergedSummaryLabel: "SUM",
mergedSummaryCellHint: "Sum of {count} numeric values",
backgroundBatchDone: "{count} data source(s) finished; the merged view is ready",
statusColumn: "Status",
affectedRowsColumn: "Affected rows",
durationColumn: "Elapsed",
errorColumn: "Error",
rerunTarget: "Re-run here",
dangerTargets: "Data sources ({count})",
dangerConfirmCodePrompt: "Type this code to confirm",
dangerConfirmCodeCopy: "Copy code",
dangerConfirmCodeOnce: "Asked once per batch; paste it if you copied it.",
dangerConfirmCodePlaceholder: "Verification code",
txnColumn: "Transaction",
txnOpen: "Uncommitted",
txnCommitted: "Committed",
txnRolledBack: "Rolled back",
txnOpenHint: "{count} target(s) not committed yet",
commitAllTargets: "Commit all ({count})",
rollbackAllTargets: "Roll back all ({count})",
txnActionFailed: "Transaction action failed: {message}",
elapsedColumn: "Elapsed: {duration}",
executedAtColumn: "Executed at",
executedAtChip: "Executed at: {time}",
mergedRowLimit: "Showing the first {count} rows; totals cover all {rows} rows",
mergedTruncated: "Merged result truncated (limit {count} rows)",
mergedEmpty: "This batch produced no rows to merge.",
mergedSheetName: "Merged result",
exportCurrentPage: "Export current page",
exportAllRows: "Export all (Excel)",
exportedRows: "Exported {rows} rows",
exportedRowsCapped: "Exported {rows} rows; {count} data source(s) hit the {limit}-row limit",
},
externalSqlFile: {
modifiedTitle: "File changed outside DBX",
+40
View File
@@ -470,6 +470,7 @@ export default withEnglishFallback({
switchToSerial: "Cambiar a secuencial",
elapsed: "Tiempo transcurrido: {duration}",
pending: "Pendiente",
awaitingConfirmation: "Esperando confirmación",
running: "En ejecución",
success: "Correcto",
failed: "Fallido",
@@ -481,6 +482,45 @@ export default withEnglishFallback({
dangerTitle: "Confirmar ejecución de destinos",
dangerConfirm: "Ejecutar en este destino",
resultTabPrefix: "Varias bases de datos",
mergeResults: "Vista de resultados combinados",
mergeResultsBack: "Volver al progreso",
mergedSourceColumn: "Origen",
mergedSources: "{count} orígenes de datos · {rows} filas",
mergedSourceRowCap: "Máximo {count} filas por origen de datos",
mergedWriteSummary: "{targets} destinos · {rows} filas afectadas",
mergedSourceCapReached: "{count} origen(es) alcanzaron el límite por origen ({limit} filas); puede haber más datos",
mergedSummaryLabel: "SUMA",
mergedSummaryCellHint: "Suma de {count} valores numéricos",
backgroundBatchDone: "{count} origen(es) de datos terminaron; el resultado combinado está listo",
statusColumn: "Estado",
affectedRowsColumn: "Filas afectadas",
durationColumn: "Tiempo",
errorColumn: "Error",
rerunTarget: "Reejecutar aquí",
dangerTargets: "Orígenes de datos ({count})",
dangerConfirmCodePrompt: "Escribe este código para confirmar",
dangerConfirmCodeCopy: "Copiar código",
dangerConfirmCodeOnce: "Se pide una vez por lote; puedes copiarlo y pegarlo.",
dangerConfirmCodePlaceholder: "Código de verificación",
txnColumn: "Transacción",
txnOpen: "Sin confirmar",
txnCommitted: "Confirmada",
txnRolledBack: "Revertida",
txnOpenHint: "{count} destino(s) aún sin confirmar",
commitAllTargets: "Confirmar todo ({count})",
rollbackAllTargets: "Revertir todo ({count})",
txnActionFailed: "Falló la operación de transacción: {message}",
elapsedColumn: "Tiempo: {duration}",
executedAtColumn: "Ejecutado el",
executedAtChip: "Ejecutado el: {time}",
mergedRowLimit: "Mostrando las primeras {count} filas; los totales abarcan las {rows} filas",
mergedTruncated: "Resultado combinado truncado (límite {count} filas)",
mergedEmpty: "Esta ejecución no produjo filas que combinar.",
mergedSheetName: "Resultado combinado",
exportCurrentPage: "Exportar página actual",
exportAllRows: "Exportar todo (Excel)",
exportedRows: "Se exportaron {rows} filas",
exportedRowsCapped: "Se exportaron {rows} filas; {count} origen(es) alcanzaron el límite de {limit} filas",
},
externalSqlFile: {
modifiedTitle: "El archivo cambió fuera de DBX",
+40
View File
@@ -468,6 +468,7 @@ export default withEnglishFallback({
switchToSerial: "Passa a seriale",
elapsed: "Tempo trascorso: {duration}",
pending: "In attesa",
awaitingConfirmation: "In attesa di conferma",
running: "In esecuzione",
success: "Riuscita",
failed: "Non riuscita",
@@ -479,6 +480,45 @@ export default withEnglishFallback({
dangerTitle: "Conferma esecuzione destinazioni",
dangerConfirm: "Esegui su questa destinazione",
resultTabPrefix: "Multi-database",
mergeResults: "Vista risultati uniti",
mergeResultsBack: "Torna al progresso",
mergedSourceColumn: "Origine",
mergedSources: "{count} origini dati · {rows} righe",
mergedSourceRowCap: "Massimo {count} righe per origine dati",
mergedWriteSummary: "{targets} destinazioni · {rows} righe interessate",
mergedSourceCapReached: "{count} origine/i ha/hanno raggiunto il limite per origine ({limit} righe); potrebbero esserci altri dati",
mergedSummaryLabel: "SOMMA",
mergedSummaryCellHint: "Somma di {count} valori numerici",
backgroundBatchDone: "{count} origine/i dati ha/hanno terminato; il risultato unito è pronto",
statusColumn: "Stato",
affectedRowsColumn: "Righe interessate",
durationColumn: "Tempo",
errorColumn: "Errore",
rerunTarget: "Riesegui qui",
dangerTargets: "Origini dati ({count})",
dangerConfirmCodePrompt: "Digita questo codice per confermare",
dangerConfirmCodeCopy: "Copia codice",
dangerConfirmCodeOnce: "Richiesto una volta per lotto; puoi copiarlo e incollarlo.",
dangerConfirmCodePlaceholder: "Codice di verifica",
txnColumn: "Transazione",
txnOpen: "Non confermata",
txnCommitted: "Confermata",
txnRolledBack: "Annullata",
txnOpenHint: "{count} destinazione/i non ancora confermate",
commitAllTargets: "Conferma tutto ({count})",
rollbackAllTargets: "Annulla tutto ({count})",
txnActionFailed: "Operazione di transazione non riuscita: {message}",
elapsedColumn: "Tempo: {duration}",
executedAtColumn: "Eseguito il",
executedAtChip: "Eseguito il: {time}",
mergedRowLimit: "Mostrate le prime {count} righe; i totali coprono tutte le {rows} righe",
mergedTruncated: "Risultato unito troncato (limite {count} righe)",
mergedEmpty: "Questa esecuzione non ha prodotto righe da unire.",
mergedSheetName: "Risultato unito",
exportCurrentPage: "Esporta pagina corrente",
exportAllRows: "Esporta tutto (Excel)",
exportedRows: "Esportate {rows} righe",
exportedRowsCapped: "Esportate {rows} righe; {count} origine/i ha/hanno raggiunto il limite di {limit} righe",
},
externalSqlFile: {
modifiedTitle: "File modificato esternamente a DBX",
+40
View File
@@ -469,6 +469,7 @@ export default withEnglishFallback({
switchToSerial: "直列に切り替え",
elapsed: "経過時間:{duration}",
pending: "待機中",
awaitingConfirmation: "確認待ち",
running: "実行中",
success: "成功",
failed: "失敗",
@@ -480,6 +481,45 @@ export default withEnglishFallback({
dangerTitle: "ターゲットでの実行を確認",
dangerConfirm: "このターゲットで実行",
resultTabPrefix: "複数DB",
mergeResults: "マージ結果ビュー",
mergeResultsBack: "実行状況に戻る",
mergedSourceColumn: "取得元",
mergedSources: "{count} 個のデータソース · {rows} 行",
mergedSourceRowCap: "1 つのデータソースにつき最大 {count} 行",
mergedWriteSummary: "{targets} 個のターゲット · {rows} 行に影響",
mergedSourceCapReached: "{count} 個のデータソースが単一ソース上限({limit} 行)に達しました。さらにデータがある可能性があります",
mergedSummaryLabel: "合計",
mergedSummaryCellHint: "{count} 個の数値の合計",
backgroundBatchDone: "{count} 個のデータソースの実行が完了し、マージ結果を表示できます",
statusColumn: "状態",
affectedRowsColumn: "影響行数",
durationColumn: "所要時間",
errorColumn: "エラー",
rerunTarget: "この対象だけ再実行",
dangerTargets: "実行するデータソース({count})",
dangerConfirmCodePrompt: "次の確認コードを入力してください",
dangerConfirmCodeCopy: "コードをコピー",
dangerConfirmCodeOnce: "確認はバッチごとに 1 回だけです。コピーして貼り付けできます。",
dangerConfirmCodePlaceholder: "確認コード",
txnColumn: "トランザクション",
txnOpen: "未コミット",
txnCommitted: "コミット済み",
txnRolledBack: "ロールバック済み",
txnOpenHint: "{count} 件の対象が未コミットです",
commitAllTargets: "まとめてコミット({count})",
rollbackAllTargets: "まとめてロールバック({count})",
txnActionFailed: "トランザクション操作に失敗しました:{message}",
elapsedColumn: "実行時間:{duration}",
executedAtColumn: "実行日時",
executedAtChip: "実行日時:{time}",
mergedRowLimit: "先頭 {count} 行のみ表示中。合計は全 {rows} 行が対象です",
mergedTruncated: "マージ結果は切り詰められました(上限 {count} 行)",
mergedEmpty: "この実行にはマージできる行がありません。",
mergedSheetName: "マージ結果",
exportCurrentPage: "現在のページを書き出す",
exportAllRows: "すべて書き出す(Excel)",
exportedRows: "{rows} 行を書き出しました",
exportedRowsCapped: "{rows} 行を書き出しました。{count} 個のデータソースが {limit} 行の単一ソース上限に達しています",
},
externalSqlFile: {
modifiedTitle: "ファイルがDBXの外部で変更されました",
+40
View File
@@ -476,6 +476,7 @@ export default withEnglishFallback({
switchToSerial: "직렬로 전환",
elapsed: "경과 시간: {duration}",
pending: "대기 중",
awaitingConfirmation: "확인 대기 중",
running: "실행 중",
success: "성공",
failed: "실패",
@@ -487,6 +488,45 @@ export default withEnglishFallback({
dangerTitle: "대상 실행 확인",
dangerConfirm: "이 대상에서 실행",
resultTabPrefix: "다중 DB",
mergeResults: "병합 결과 보기",
mergeResultsBack: "실행 진행으로 돌아가기",
mergedSourceColumn: "원본",
mergedSources: "데이터 원본 {count}개 · {rows}행",
mergedSourceRowCap: "데이터 원본당 최대 {count}행",
mergedWriteSummary: "대상 {targets}개 · {rows}행 영향",
mergedSourceCapReached: "{count}개 데이터 원본이 단일 원본 한도({limit}행)에 도달했습니다. 더 많은 데이터가 있을 수 있습니다",
mergedSummaryLabel: "합계",
mergedSummaryCellHint: "숫자 {count}개의 합계",
backgroundBatchDone: "데이터 원본 {count}개 실행이 완료되어 병합 결과를 볼 수 있습니다",
statusColumn: "상태",
affectedRowsColumn: "영향 행 수",
durationColumn: "소요 시간",
errorColumn: "오류",
rerunTarget: "이 대상만 다시 실행",
dangerTargets: "실행할 데이터 원본({count})",
dangerConfirmCodePrompt: "아래 확인 코드를 입력하세요",
dangerConfirmCodeCopy: "코드 복사",
dangerConfirmCodeOnce: "배치당 한 번만 확인합니다. 복사해서 붙여넣을 수 있습니다.",
dangerConfirmCodePlaceholder: "확인 코드",
txnColumn: "트랜잭션",
txnOpen: "미커밋",
txnCommitted: "커밋됨",
txnRolledBack: "롤백됨",
txnOpenHint: "{count}개 대상이 아직 커밋되지 않았습니다",
commitAllTargets: "모두 커밋({count})",
rollbackAllTargets: "모두 롤백({count})",
txnActionFailed: "트랜잭션 작업 실패: {message}",
elapsedColumn: "실행 시간: {duration}",
executedAtColumn: "실행 시각",
executedAtChip: "실행 시각: {time}",
mergedRowLimit: "처음 {count}행만 표시하며 합계는 전체 {rows}행 기준입니다",
mergedTruncated: "병합 결과가 잘렸습니다(한도 {count}행)",
mergedEmpty: "이번 실행에는 병합할 결과 행이 없습니다.",
mergedSheetName: "병합 결과",
exportCurrentPage: "현재 페이지 내보내기",
exportAllRows: "전체 내보내기(Excel)",
exportedRows: "{rows}행을 내보냈습니다",
exportedRowsCapped: "{rows}행을 내보냈습니다. {count}개 데이터 원본이 {limit}행 단일 원본 한도에 도달했습니다",
},
externalSqlFile: {
modifiedTitle: "DBX 외부에서 파일이 변경됨",
+40
View File
@@ -469,6 +469,7 @@ export default withEnglishFallback({
switchToSerial: "Mudar para serial",
elapsed: "Tempo decorrido: {duration}",
pending: "Pendente",
awaitingConfirmation: "Aguardando confirmação",
running: "Em execução",
success: "Sucesso",
failed: "Falha",
@@ -480,6 +481,45 @@ export default withEnglishFallback({
dangerTitle: "Confirmar execução dos destinos",
dangerConfirm: "Executar neste destino",
resultTabPrefix: "Vários bancos",
mergeResults: "Visão de resultados combinados",
mergeResultsBack: "Voltar ao progresso",
mergedSourceColumn: "Origem",
mergedSources: "{count} origens de dados · {rows} linhas",
mergedSourceRowCap: "Máximo de {count} linhas por origem de dados",
mergedWriteSummary: "{targets} destinos · {rows} linhas afetadas",
mergedSourceCapReached: "{count} origem(ns) atingiram o limite por origem ({limit} linhas); pode haver mais dados",
mergedSummaryLabel: "SOMA",
mergedSummaryCellHint: "Soma de {count} valores numéricos",
backgroundBatchDone: "{count} origem(ns) de dados concluíram; o resultado combinado está pronto",
statusColumn: "Status",
affectedRowsColumn: "Linhas afetadas",
durationColumn: "Tempo",
errorColumn: "Erro",
rerunTarget: "Executar novamente aqui",
dangerTargets: "Origens de dados ({count})",
dangerConfirmCodePrompt: "Digite este código para confirmar",
dangerConfirmCodeCopy: "Copiar código",
dangerConfirmCodeOnce: "Pedido uma vez por lote; você pode copiar e colar.",
dangerConfirmCodePlaceholder: "Código de verificação",
txnColumn: "Transação",
txnOpen: "Não confirmada",
txnCommitted: "Confirmada",
txnRolledBack: "Revertida",
txnOpenHint: "{count} destino(s) ainda não confirmados",
commitAllTargets: "Confirmar tudo ({count})",
rollbackAllTargets: "Reverter tudo ({count})",
txnActionFailed: "Falha na operação de transação: {message}",
elapsedColumn: "Tempo: {duration}",
executedAtColumn: "Executado em",
executedAtChip: "Executado em: {time}",
mergedRowLimit: "Mostrando as primeiras {count} linhas; os totais cobrem todas as {rows} linhas",
mergedTruncated: "Resultado combinado truncado (limite {count} linhas)",
mergedEmpty: "Esta execução não produziu linhas para combinar.",
mergedSheetName: "Resultado combinado",
exportCurrentPage: "Exportar página atual",
exportAllRows: "Exportar tudo (Excel)",
exportedRows: "{rows} linhas exportadas",
exportedRowsCapped: "{rows} linhas exportadas; {count} origem(ns) atingiram o limite de {limit} linhas",
},
externalSqlFile: {
modifiedTitle: "Arquivo alterado fora do DBX",
+40
View File
@@ -394,6 +394,7 @@ export default withEnglishFallback({
switchToSerial: "切换为串行",
elapsed: "耗时:{duration}",
pending: "等待中",
awaitingConfirmation: "等待确认",
running: "执行中",
success: "成功",
failed: "失败",
@@ -405,6 +406,45 @@ export default withEnglishFallback({
dangerTitle: "确认目标执行",
dangerConfirm: "在此目标上执行",
resultTabPrefix: "多库",
mergeResults: "合并结果视图",
mergeResultsBack: "返回执行进度",
mergedSourceColumn: "来源",
mergedSources: "{count} 个数据源 · {rows} 行",
mergedSourceRowCap: "单个数据源最多读取 {count} 行",
mergedWriteSummary: "{targets} 个目标 · 影响 {rows} 行",
mergedSourceCapReached: "有 {count} 个数据源已达单源上限({limit} 行),可能还有更多数据",
mergedSummaryLabel: "合计",
mergedSummaryCellHint: "{count} 个数字之和",
backgroundBatchDone: "{count} 个数据源执行完成,合并结果已就绪",
statusColumn: "状态",
affectedRowsColumn: "影响行数",
durationColumn: "耗时",
errorColumn: "错误",
rerunTarget: "单独重跑",
dangerTargets: "将执行的数据源({count})",
dangerConfirmCodePrompt: "输入下面的确认码",
dangerConfirmCodeCopy: "复制确认码",
dangerConfirmCodeOnce: "整批只需确认一次;复制后可直接粘贴。",
dangerConfirmCodePlaceholder: "确认码",
txnColumn: "事务",
txnOpen: "未提交",
txnCommitted: "已提交",
txnRolledBack: "已回滚",
txnOpenHint: "有 {count} 个目标尚未提交",
commitAllTargets: "批量提交({count})",
rollbackAllTargets: "批量回滚({count})",
txnActionFailed: "事务操作失败:{message}",
elapsedColumn: "执行耗时:{duration}",
executedAtColumn: "执行时间",
executedAtChip: "执行时间:{time}",
mergedRowLimit: "仅显示前 {count} 行,合计基于全部 {rows} 行",
mergedTruncated: "合并结果已截断(上限 {count} 行)",
mergedEmpty: "本次执行没有可合并的结果行。",
mergedSheetName: "合并结果",
exportCurrentPage: "导出当前页",
exportAllRows: "导出全部(Excel)",
exportedRows: "已导出 {rows} 行",
exportedRowsCapped: "已导出 {rows} 行;{count} 个数据源达到了 {limit} 行的单源上限",
},
externalSqlFile: {
modifiedTitle: "文件已在外部发生修改",
+40
View File
@@ -472,6 +472,7 @@ export default withEnglishFallback({
switchToSerial: "切換為串行",
elapsed: "耗時:{duration}",
pending: "等待中",
awaitingConfirmation: "等待確認",
running: "執行中",
success: "成功",
failed: "失敗",
@@ -483,6 +484,45 @@ export default withEnglishFallback({
dangerTitle: "確認執行目標",
dangerConfirm: "在此目標上執行",
resultTabPrefix: "多資料庫",
mergeResults: "合併結果檢視",
mergeResultsBack: "返回執行進度",
mergedSourceColumn: "來源",
mergedSources: "{count} 個資料來源 · {rows} 列",
mergedSourceRowCap: "單一資料來源最多讀取 {count} 列",
mergedWriteSummary: "{targets} 個目標 · 影響 {rows} 筆",
mergedSourceCapReached: "有 {count} 個資料來源已達單源上限({limit} 列),可能還有更多資料",
mergedSummaryLabel: "合計",
mergedSummaryCellHint: "{count} 個數字之和",
backgroundBatchDone: "{count} 個資料來源執行完成,合併結果已就緒",
statusColumn: "狀態",
affectedRowsColumn: "影響列數",
durationColumn: "耗時",
errorColumn: "錯誤",
rerunTarget: "單獨重跑",
dangerTargets: "將執行的資料來源({count})",
dangerConfirmCodePrompt: "輸入下面的確認碼",
dangerConfirmCodeCopy: "複製確認碼",
dangerConfirmCodeOnce: "整批只需確認一次;複製後可直接貼上。",
dangerConfirmCodePlaceholder: "確認碼",
txnColumn: "交易",
txnOpen: "未提交",
txnCommitted: "已提交",
txnRolledBack: "已回復",
txnOpenHint: "有 {count} 個目標尚未提交",
commitAllTargets: "批次提交({count})",
rollbackAllTargets: "批次回復({count})",
txnActionFailed: "交易操作失敗:{message}",
elapsedColumn: "執行耗時:{duration}",
executedAtColumn: "執行時間",
executedAtChip: "執行時間:{time}",
mergedRowLimit: "僅顯示前 {count} 列,合計涵蓋全部 {rows} 列",
mergedTruncated: "合併結果已截斷(上限 {count} 列)",
mergedEmpty: "本次執行沒有可合併的結果列。",
mergedSheetName: "合併結果",
exportCurrentPage: "匯出目前頁面",
exportAllRows: "匯出全部(Excel)",
exportedRows: "已匯出 {rows} 列",
exportedRowsCapped: "已匯出 {rows} 列;{count} 個資料來源達到 {limit} 列的單源上限",
},
externalSqlFile: {
modifiedTitle: "檔案已在 DBX 外部變更",
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { EXECUTION_CONFIRM_CODE_LENGTH, createExecutionConfirmCode, matchesExecutionConfirmCode } from "@/lib/query/executionConfirmCode";
describe("executionConfirmCode", () => {
it("generates a code of the documented length from an unambiguous alphabet", () => {
for (let run = 0; run < 50; run += 1) {
const code = createExecutionConfirmCode();
expect(code).toHaveLength(EXECUTION_CONFIRM_CODE_LENGTH);
// No 0/O/1/I look-alikes: the operator has to retype it from the screen.
expect(code).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]+$/);
}
});
it("is deterministic for a given random source", () => {
const sequence = () => {
const values = [0, 0.5, 0.999, 0.25, 0.75, 0.1];
let index = 0;
return () => values[index++ % values.length]!;
};
expect(createExecutionConfirmCode(sequence())).toBe(createExecutionConfirmCode(sequence()));
expect(createExecutionConfirmCode(() => 0)).toBe("AAAAAA");
});
it("tolerates case and surrounding spaces when matching", () => {
expect(matchesExecutionConfirmCode("ab12cd", "AB12CD")).toBe(true);
expect(matchesExecutionConfirmCode(" AB12CD ", "ab12cd")).toBe(true);
expect(matchesExecutionConfirmCode("", "AB12CD")).toBe(false);
expect(matchesExecutionConfirmCode("AB12CE", "AB12CD")).toBe(false);
});
it("never matches an empty code", () => {
expect(matchesExecutionConfirmCode("", "")).toBe(false);
expect(matchesExecutionConfirmCode("anything", "")).toBe(false);
});
});
@@ -0,0 +1,199 @@
import { describe, expect, it } from "vitest";
import { MULTI_SOURCE_MERGE_MAX_ROWS, isNumericSummaryCellValue, mergeMultiSourceResults, summarizeNumericColumns } from "@/lib/query/multiSourceResult";
import type { QueryResult } from "@/types/database";
function result(columns: string[], rows: (string | number | boolean | null)[][], overrides: Partial<QueryResult> = {}): QueryResult {
return {
columns,
rows,
affected_rows: rows.length,
execution_time_ms: 1,
...overrides,
};
}
describe("mergeMultiSourceResults", () => {
it("unions columns by name and injects the source column first", () => {
const merged = mergeMultiSourceResults(
[
{ key: "a", label: "conn-a / sales", result: result(["id", "amount"], [[1, 10]]) },
{ key: "b", label: "conn-b / sales", result: result(["amount", "region"], [[20, "north"]]) },
],
{ sourceColumnLabel: "Source" },
);
expect(merged.columns).toEqual(["Source", "id", "amount", "region"]);
expect(merged.rows).toEqual([
["conn-a / sales", 1, 10, null],
["conn-b / sales", null, 20, "north"],
]);
expect(merged.rowCount).toBe(2);
expect(merged.truncated).toBe(false);
expect(merged.skippedLabels).toEqual([]);
expect(merged.sources).toEqual([
{ key: "a", label: "conn-a / sales", rowCount: 1, columnCount: 2 },
{ key: "b", label: "conn-b / sales", rowCount: 1, columnCount: 2 },
]);
});
it("keeps duplicate column names of one source as separate value streams", () => {
const merged = mergeMultiSourceResults(
[
{ key: "a", label: "a", result: result(["id", "id"], [[1, 2]]) },
{ key: "b", label: "b", result: result(["id"], [[9]]) },
],
{ sourceColumnLabel: "Source" },
);
expect(merged.columns).toEqual(["Source", "id", "id"]);
expect(merged.rows).toEqual([
["a", 1, 2],
["b", 9, null],
]);
});
it("excludes hidden middle columns without shifting source values or numeric summaries", () => {
const first = result(["name", "__DBX_PK_0", "amount", "region"], [["Alice", 17, 10, "north"]], { hidden_column_indexes: [1] });
const second = result(["amount", "name", "__DBX_PK_0"], [[20, "Bob", 23]], { hidden_column_indexes: [2] });
const merged = mergeMultiSourceResults(
[
{ key: "a", label: "a", result: first },
{ key: "b", label: "b", result: second },
],
{ sourceColumnLabel: "Source" },
);
expect(merged.columns).toEqual(["Source", "name", "amount", "region"]);
expect(merged.rows).toEqual([
["a", "Alice", 10, "north"],
["b", "Bob", 20, null],
]);
expect(merged.summaries).toEqual([{ columnIndex: 2, column: "amount", sum: 30, count: 2 }]);
expect(merged.sources.map((source) => source.columnCount)).toEqual([3, 2]);
expect(first.columns).toEqual(["name", "__DBX_PK_0", "amount", "region"]);
expect(first.rows).toEqual([["Alice", 17, 10, "north"]]);
});
it("matches visible duplicate names and keeps unnamed columns at their source ordinals", () => {
const merged = mergeMultiSourceResults(
[
{ key: "a", label: "a", result: result(["id", "id", "id", ""], [[99, 1, 2, 3]], { hidden_column_indexes: [0] }) },
{ key: "b", label: "b", result: result(["id", "id", "__DBX_PK_0", ""], [[4, 5, 88, 6]], { hidden_column_indexes: [2] }) },
],
{ sourceColumnLabel: "Source" },
);
expect(merged.columns).toEqual(["Source", "id", "id", "column_4"]);
expect(merged.rows).toEqual([
["a", 1, 2, 3],
["b", 4, 5, 6],
]);
expect(merged.summaries.map((summary) => summary.sum)).toEqual([5, 7, 9]);
});
it("skips sources without a tabular payload instead of failing", () => {
const merged = mergeMultiSourceResults(
[
{ key: "a", label: "empty", result: result([], []) },
{ key: "b", label: "failed", result: result(["Error"], [["boom"]], { execution_error: true }) },
{ key: "c", label: "missing", result: undefined },
{ key: "d", label: "ok", result: result(["id"], [[7]]) },
],
{ sourceColumnLabel: "Source" },
);
expect(merged.sources.map((source) => source.label)).toEqual(["ok"]);
expect(merged.skippedLabels).toEqual(["empty", "failed", "missing"]);
expect(merged.rows).toEqual([["ok", 7]]);
});
it("caps merged rows and reports truncation", () => {
const rows = Array.from({ length: 5 }, (_, index) => [index]);
const merged = mergeMultiSourceResults(
[
{ key: "a", label: "a", result: result(["n"], rows) },
{ key: "b", label: "b", result: result(["n"], rows) },
],
{ sourceColumnLabel: "Source", maxRows: 4 },
);
expect(merged.rowCount).toBe(4);
expect(merged.truncated).toBe(true);
expect(merged.rows.map((row) => row[0])).toEqual(["a", "a", "a", "a"]);
});
it("names unnamed columns positionally and tolerates ragged rows", () => {
const merged = mergeMultiSourceResults(
[
{ key: "a", label: "a", result: result(["", "b"], [[1]]) },
{ key: "b", label: "b", result: result(["b"], [[2, 3, 4]]) },
],
{ sourceColumnLabel: "Source" },
);
expect(merged.columns).toEqual(["Source", "column_1", "b"]);
expect(merged.rows).toEqual([
["a", 1, null],
["b", null, 2],
]);
});
it("summarizes every all-numeric column of the merged table and never the source column", () => {
const merged = mergeMultiSourceResults(
[
{ key: "a", label: "conn-a", result: result(["id", "amount", "label"], [[1, 10, "x"]]) },
{ key: "b", label: "conn-b", result: result(["id", "amount", "label"], [[2, 32.5, "y"]]) },
],
{ sourceColumnLabel: "Source" },
);
expect(merged.summaries).toEqual([
{ columnIndex: 1, column: "id", sum: 3, count: 2 },
{ columnIndex: 2, column: "amount", sum: 42.5, count: 2 },
]);
});
it("uses the documented row cap by default", () => {
expect(MULTI_SOURCE_MERGE_MAX_ROWS).toBe(50_000);
});
it("handles no usable input with an empty table", () => {
const merged = mergeMultiSourceResults([{ key: "a", label: "a", result: undefined }], { sourceColumnLabel: "Source" });
expect(merged.columns).toEqual(["Source"]);
expect(merged.rows).toEqual([]);
expect(merged.sources).toEqual([]);
expect(merged.summaries).toEqual([]);
expect(merged.skippedLabels).toEqual(["a"]);
});
});
describe("summarizeNumericColumns", () => {
it("classifies numeric cells and rejects everything else", () => {
expect(isNumericSummaryCellValue(1)).toBe(true);
expect(isNumericSummaryCellValue(" 3.5 ")).toBe(true);
expect(isNumericSummaryCellValue("")).toBe(false);
expect(isNumericSummaryCellValue("12abc")).toBe(false);
expect(isNumericSummaryCellValue("2026-07-24")).toBe(false);
expect(isNumericSummaryCellValue(true)).toBe(false);
expect(isNumericSummaryCellValue(null)).toBe(false);
expect(isNumericSummaryCellValue(Number.NaN)).toBe(false);
});
it("ignores empty cells and drops a column once one value is not numeric", () => {
expect(
summarizeNumericColumns(
["a", "b"],
[
[1, null],
[null, "n/a"],
["", 3],
],
),
).toEqual([{ columnIndex: 0, column: "a", sum: 1, count: 1 }]);
});
it("never sums boolean or all-empty columns", () => {
expect(summarizeNumericColumns(["flag"], [[true], [false]])).toEqual([]);
expect(summarizeNumericColumns(["a"], [[null], [null]])).toEqual([]);
});
});
@@ -0,0 +1,26 @@
/**
* Confirmation code shown in the "confirm target execution" prompt of a
* multi-database batch: the operator has to type it back before the batch is
* allowed to run, so a mis-click cannot fire the same statement at every
* connection.
*/
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
export const EXECUTION_CONFIRM_CODE_LENGTH = 6;
export function createExecutionConfirmCode(random: () => number = Math.random, length = EXECUTION_CONFIRM_CODE_LENGTH): string {
const size = Math.max(1, Math.trunc(length));
let code = "";
for (let index = 0; index < size; index += 1) {
const value = random();
const normalized = Number.isFinite(value) ? Math.min(Math.max(value, 0), 0.999_999) : 0;
code += CODE_ALPHABET[Math.floor(normalized * CODE_ALPHABET.length)] ?? CODE_ALPHABET[0];
}
return code;
}
/** Typed input matches the displayed code, ignoring case and surrounding spaces. */
export function matchesExecutionConfirmCode(input: string, code: string): boolean {
if (!code) return false;
return input.trim().toUpperCase() === code.trim().toUpperCase();
}
@@ -0,0 +1,209 @@
import type { QueryResult } from "@/types/database";
/**
* Rows read from a single target of a multi-database run. The merged view and
* its Excel export need the query's body rather than just the editor's first
* page, so every source is fetched up to this cap; the user's global result-row
* limit still applies on top of it.
*/
export const MULTI_SOURCE_MAX_ROWS_PER_SOURCE = 10_000;
/**
* Upper bound on merged rows. The merged view is a quick inspection surface, so
* a fan-out over many connections must not be able to build an unbounded table;
* hitting the cap is reported instead of silently dropping rows.
*/
export const MULTI_SOURCE_MERGE_MAX_ROWS = 50_000;
export type MultiSourceCellValue = string | number | boolean | null;
export interface MultiSourceResultInput {
/** Stable identity of the contributing result (used as the row's origin key). */
key: string;
/** Human label written into the injected source column for every row. */
label: string;
result: QueryResult | undefined;
}
export interface MultiSourceMergeOptions {
/** Header of the injected first column; localized by the caller. */
sourceColumnLabel: string;
maxRows?: number;
}
export interface MultiSourceColumnSummary {
columnIndex: number;
column: string;
sum: number;
/** Number of contributing values; empty cells never count. */
count: number;
}
export interface MultiSourceMergeSource {
key: string;
label: string;
rowCount: number;
columnCount: number;
}
export interface MultiSourceMergeOutput {
columns: string[];
rows: MultiSourceCellValue[][];
/** One entry per all-numeric column, aligned by column ordinal. */
summaries: MultiSourceColumnSummary[];
sources: MultiSourceMergeSource[];
rowCount: number;
truncated: boolean;
/** Labels of inputs that carried no tabular payload and were left out. */
skippedLabels: string[];
}
interface MergePlan {
input: MultiSourceResultInput;
result: QueryResult;
/** Merged column ordinal for each source column ordinal. */
targetIndexes: Map<number, number>;
}
function usableInput(input: MultiSourceResultInput): boolean {
if (!input.result) return false;
if (input.result.execution_error === true) return false;
return (input.result.columns?.length ?? 0) > 0;
}
function mergedColumnName(column: string | undefined, sourceIndex: number): string {
const name = typeof column === "string" ? column.trim() : "";
return name === "" ? `column_${sourceIndex + 1}` : name;
}
function isEmptyCell(value: MultiSourceCellValue | undefined): boolean {
if (value === null || value === undefined) return true;
return typeof value === "string" && value.trim() === "";
}
/**
* A cell counts as numeric when it is a finite number or a non-empty string that
* parses to one (drivers return DECIMAL/BIGINT as strings). Booleans are not
* numeric, so a flag column never turns into a total.
*/
export function isNumericSummaryCellValue(value: MultiSourceCellValue | undefined): boolean {
if (typeof value === "number") return Number.isFinite(value);
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed !== "" && Number.isFinite(Number(trimmed));
}
return false;
}
/**
* Simple result-level aggregates: sums every column whose non-empty cells are
* all numeric. A single non-numeric cell disqualifies the whole column, and a
* column without values produces no total.
*/
export function summarizeNumericColumns(columns: readonly string[], rows: readonly (readonly MultiSourceCellValue[])[], options: { skipColumnIndexes?: readonly number[] } = {}): MultiSourceColumnSummary[] {
const skipped = new Set(options.skipColumnIndexes ?? []);
const sums: number[] = Array.from({ length: columns.length }, () => 0);
const counts: number[] = Array.from({ length: columns.length }, () => 0);
const stillNumeric: boolean[] = Array.from({ length: columns.length }, () => true);
for (const row of rows) {
for (let columnIndex = 0; columnIndex < columns.length; columnIndex += 1) {
if (skipped.has(columnIndex) || !stillNumeric[columnIndex]) continue;
const value = row[columnIndex];
if (isEmptyCell(value)) continue;
if (!isNumericSummaryCellValue(value)) {
stillNumeric[columnIndex] = false;
continue;
}
sums[columnIndex] += typeof value === "number" ? value : Number(value);
counts[columnIndex] += 1;
}
}
const summaries: MultiSourceColumnSummary[] = [];
for (let columnIndex = 0; columnIndex < columns.length; columnIndex += 1) {
if (skipped.has(columnIndex) || !stillNumeric[columnIndex] || counts[columnIndex] === 0) continue;
summaries.push({ columnIndex, column: columns[columnIndex] ?? "", sum: sums[columnIndex]!, count: counts[columnIndex]! });
}
return summaries;
}
/**
* Merges the results of one query executed against several connections /
* databases into a single table.
*
* Columns are aligned by name (union, first-seen order) and every row keeps a
* leading provenance column naming the source it came from. A column a source
* does not have is padded with null. Duplicate column names inside one result
* keep their own value streams by matching the Nth occurrence of a name.
*/
export function mergeMultiSourceResults(inputs: readonly MultiSourceResultInput[], options: MultiSourceMergeOptions): MultiSourceMergeOutput {
const maxRows = Math.max(1, Math.trunc(options.maxRows ?? MULTI_SOURCE_MERGE_MAX_ROWS));
const columns: string[] = [options.sourceColumnLabel];
// A merged column is identified by (name, occurrence-in-source) so duplicate
// names in one result still keep both of their value streams.
const indexByLayoutKey = new Map<string, number>();
const plans: MergePlan[] = [];
const skippedLabels: string[] = [];
for (const input of inputs) {
if (!usableInput(input)) {
skippedLabels.push(input.label);
continue;
}
const result = input.result!;
const occurrences = new Map<string, number>();
const targetIndexes = new Map<number, number>();
const hiddenColumnIndexes = new Set(result.hidden_column_indexes ?? []);
result.columns.forEach((column, sourceIndex) => {
if (hiddenColumnIndexes.has(sourceIndex)) return;
const name = mergedColumnName(column, sourceIndex);
const occurrence = occurrences.get(name) ?? 0;
occurrences.set(name, occurrence + 1);
const layoutKey = `${name}\u0000${occurrence}`;
let targetIndex = indexByLayoutKey.get(layoutKey);
if (targetIndex === undefined) {
targetIndex = columns.length;
indexByLayoutKey.set(layoutKey, targetIndex);
columns.push(name);
}
targetIndexes.set(sourceIndex, targetIndex);
});
plans.push({ input, result, targetIndexes });
}
const rows: MultiSourceCellValue[][] = [];
let truncated = false;
for (const plan of plans) {
for (const sourceRow of plan.result.rows) {
if (rows.length >= maxRows) {
truncated = true;
break;
}
const row: MultiSourceCellValue[] = Array.from({ length: columns.length }, () => null);
row[0] = plan.input.label;
plan.targetIndexes.forEach((targetIndex, sourceIndex) => {
row[targetIndex] = sourceRow[sourceIndex] ?? null;
});
rows.push(row);
}
if (truncated) break;
}
return {
columns,
rows,
// The injected provenance column is text by construction, but skip it
// explicitly so a numeric-looking label can never become a total.
summaries: summarizeNumericColumns(columns, rows, { skipColumnIndexes: [0] }),
sources: plans.map((plan) => ({
key: plan.input.key,
label: plan.input.label,
rowCount: plan.result.rows.length,
columnCount: plan.targetIndexes.size,
})),
rowCount: rows.length,
truncated,
skippedLabels,
};
}
@@ -20,4 +20,71 @@ describe("sqlExecutionDangerStore", () => {
expect(await unrelated).toBe(true);
expect(store.pending).toBeUndefined();
});
it("asks a batch once and reuses the answer for its remaining targets", async () => {
const store = useSqlExecutionDangerStore();
const first = store.requestConfirmation({ sql: "UPDATE t", kind: "sql", scopeId: "batch-1" });
expect(store.pending?.scopeId).toBe("batch-1");
store.confirm();
expect(await first).toBe(true);
// The second target of the same batch must not prompt (nor ask for the
// confirmation code) again.
const second = store.requestConfirmation({ sql: "UPDATE t", kind: "sql", scopeId: "batch-1" });
expect(store.pending).toBeUndefined();
expect(await second).toBe(true);
});
it("keeps a declined batch from prompting the rest of its targets", async () => {
const store = useSqlExecutionDangerStore();
const first = store.requestConfirmation({ sql: "DROP TABLE a", kind: "sql", scopeId: "batch-2" });
store.cancel();
expect(await first).toBe(false);
const second = store.requestConfirmation({ sql: "DROP TABLE a", kind: "sql", scopeId: "batch-2" });
expect(store.pending).toBeUndefined();
expect(await second).toBe(false);
});
it("resolves already-queued targets of the same batch with the first answer", async () => {
const store = useSqlExecutionDangerStore();
// Parallel execution can queue several targets before the operator answers.
const first = store.requestConfirmation({ sql: "UPDATE t", kind: "sql", scopeId: "batch-3" });
const second = store.requestConfirmation({ sql: "UPDATE t", kind: "sql", scopeId: "batch-3" });
const other = store.requestConfirmation({ sql: "UPDATE u", kind: "sql", scopeId: "batch-4" });
store.confirm();
expect(await first).toBe(true);
expect(await second).toBe(true);
expect(store.pending?.scopeId).toBe("batch-4");
store.cancel();
expect(await other).toBe(false);
});
it("prompts again after the batch scope is cancelled", async () => {
const store = useSqlExecutionDangerStore();
const first = store.requestConfirmation({ sql: "UPDATE t", kind: "sql", scopeId: "batch-5" });
store.confirm();
await first;
store.cancelScope("batch-5");
const again = store.requestConfirmation({ sql: "UPDATE t", kind: "sql", scopeId: "batch-5" });
expect(store.pending?.scopeId).toBe("batch-5");
store.cancel();
expect(await again).toBe(false);
});
it("keeps prompting for requests without a scope", async () => {
const store = useSqlExecutionDangerStore();
const first = store.requestConfirmation({ sql: "DROP TABLE a", kind: "sql" });
store.confirm();
expect(await first).toBe(true);
const second = store.requestConfirmation({ sql: "DROP TABLE a", kind: "sql" });
expect(store.pending).toBeDefined();
store.cancel();
expect(await second).toBe(false);
});
});
@@ -8,6 +8,12 @@ export interface SqlExecutionDangerRequest {
connectionName?: string;
database?: string;
targetLabel?: string;
/**
* Every data source the pending batch will touch. Shown in the confirmation
* so the operator confirms the whole fan-out, not just the target that
* happened to reach the prompt first.
*/
targets?: string[];
databaseType?: DatabaseType;
/** Identifies a cancellable execution batch without coupling this store to its orchestrator. */
scopeId?: string;
@@ -18,13 +24,41 @@ interface QueuedDangerRequest {
resolve: (confirmed: boolean) => void;
}
/**
* A batch asks once per target, but the operator answers once per batch: the
* decision is remembered per `scopeId` so the remaining targets reuse it
* instead of re-prompting (and re-asking for the confirmation code).
*/
const MAX_REMEMBERED_SCOPES = 50;
export const useSqlExecutionDangerStore = defineStore("sqlExecutionDanger", () => {
const pending = ref<SqlExecutionDangerRequest>();
const queue: QueuedDangerRequest[] = [];
const scopeOutcome = new Map<string, boolean>();
let resolvePending: ((confirmed: boolean) => void) | undefined;
function rememberScopeOutcome(scopeId: string | undefined, confirmed: boolean): void {
if (!scopeId) return;
scopeOutcome.delete(scopeId);
scopeOutcome.set(scopeId, confirmed);
while (scopeOutcome.size > MAX_REMEMBERED_SCOPES) {
const oldest = scopeOutcome.keys().next().value;
if (oldest === undefined) break;
scopeOutcome.delete(oldest);
}
}
function rememberedOutcome(request: SqlExecutionDangerRequest): boolean | undefined {
return request.scopeId ? scopeOutcome.get(request.scopeId) : undefined;
}
function requestConfirmation(request: SqlExecutionDangerRequest): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const remembered = rememberedOutcome(request);
if (remembered !== undefined) {
resolve(remembered);
return;
}
if (pending.value) {
queue.push({ request, resolve });
return;
@@ -38,13 +72,28 @@ export const useSqlExecutionDangerStore = defineStore("sqlExecutionDanger", () =
resolvePending = resolve;
}
/** Promotes queued requests, resolving the ones whose batch is already decided. */
function promoteNext(): void {
while (queue.length > 0) {
const next = queue.shift()!;
const remembered = rememberedOutcome(next.request);
if (remembered !== undefined) {
next.resolve(remembered);
continue;
}
begin(next.request, next.resolve);
return;
}
}
function settle(confirmed: boolean): void {
const request = pending.value;
const resolve = resolvePending;
resolvePending = undefined;
pending.value = undefined;
rememberScopeOutcome(request?.scopeId, confirmed);
resolve?.(confirmed);
const next = queue.shift();
if (next) begin(next.request, next.resolve);
promoteNext();
}
function confirm(): void {
@@ -72,6 +121,8 @@ export const useSqlExecutionDangerStore = defineStore("sqlExecutionDanger", () =
queue.splice(0, queue.length, ...retained);
cancelled.forEach((entry) => entry.resolve(false));
if (pending.value?.scopeId === scopeId) settle(false);
// A cancelled batch must prompt again if the same scope id is ever reused.
scopeOutcome.delete(scopeId);
}
return { pending, requestConfirmation, confirm, cancel, cancelAll, cancelScope };
+3 -1
View File
@@ -1,4 +1,4 @@
import type { DatabaseType } from "@/types/database";
import type { DatabaseType, QueryResult } from "@/types/database";
export interface MultiDbExecutionTarget {
connectionId: string;
@@ -43,6 +43,8 @@ export interface MultiDbTargetExecutionResult {
errorMessage?: string;
durationMs?: number;
transaction?: MultiDbManualTransaction;
/** Result produced on this target; feeds the merged multi-source view. */
result?: QueryResult;
}
export interface SqlExecutionTargetValidation {