feat(transactions): add manual transactions to more execution paths

This commit is contained in:
efeire authored and GitHub committed 2026-09-20 11:07:11 +08:00
1 parent 88ba58a79c
commit a924285136
38 files changed
+2471 -146

No files matched your search

@@ -18,6 +18,8 @@ import { dedupeMultiDbExecutionTargets, multiDbExecutionTargetKey, type MultiDbE
import type { ConnectionConfig, DatabaseType } from "@/types/database";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { targetAllowsEmptyDatabase, targetIsSingleDatabase, targetSupportsCatalog, targetSupportsSchema, targetUsesConnectionOnlyScope } from "@/lib/database/sqlExecutionTargetCapabilities";
import { supportsTransaction } from "@/lib/database/databaseFeatureSupport";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
const open = defineModel<boolean>("open", { default: false });
@@ -31,6 +33,7 @@ const props = defineProps<{
cancelTarget?: MultiDbExecutionAdapter["cancelTarget"];
cancelPending?: MultiDbExecutionAdapter["cancelPending"];
sourceOffset?: number;
initialManualTransaction?: boolean;
}>();
const { t } = useI18n();
@@ -65,6 +68,10 @@ const pendingClose = ref(false);
const unsavedPromptOpen = ref(false);
const executionStarted = ref(false);
const executionMode = ref<MultiDbExecutionMode>("serial");
const manualTransaction = ref(false);
const discardTransactionsOpen = ref(false);
const closingTransactions = ref(false);
const supportsManualTransaction = computed(() => isTauriRuntime() && supportsTransaction(props.databaseType));
const currentTime = ref(Date.now());
let elapsedTimer: ReturnType<typeof setInterval> | undefined;
let validationRun = 0;
@@ -103,7 +110,7 @@ const hasUnsavedChanges = computed(() => {
}
return manualSelectionTouched.value;
});
const canExecute = computed(() => !isExecuting.value && !!props.sql.trim() && allTargetsValid.value && invalidTargetCount.value === 0 && needsRecheckCount.value === 0);
const canExecute = computed(() => !isExecuting.value && !execution.hasTransactions.value && !!props.sql.trim() && allTargetsValid.value && invalidTargetCount.value === 0 && needsRecheckCount.value === 0);
const canSaveGroup = computed(() => !isExecuting.value && !!props.databaseType && selectedTargets.value.length > 0 && allTargetsValid.value);
const progressCompleted = computed(() => (batch.value?.items ?? []).filter((item) => !["pending", "running"].includes(item.status)).length);
const progressCounts = computed(() => {
@@ -113,6 +120,7 @@ const progressCounts = computed(() => {
failed: items.filter((item) => item.status === "failed").length,
skipped: items.filter((item) => item.status === "skipped").length,
notExecuted: items.filter((item) => item.status === "not_executed" || item.status === "cancelled").length,
pendingCommit: items.filter((item) => item.status === "pending_commit").length,
};
});
const elapsedMs = computed(() => {
@@ -125,7 +133,7 @@ function syncBackgroundTask(current: NonNullable<typeof batch.value>): void {
const items = current.items;
const completed = items.filter((item) => !["pending", "running"].includes(item.status)).length;
const failureCount = items.filter((item) => item.status === "failed").length;
const status = current.status === "cancelled" ? "cancelled" : current.status === "completed" ? "completed" : "running";
const status = items.some((item) => item.transaction) ? "running" : current.status === "cancelled" ? "cancelled" : current.status === "completed" ? "completed" : "running";
updateMultiDbExecutionTask(current.id, {
sourceTabId: current.sourceTabId,
total: items.length,
@@ -178,6 +186,9 @@ onMounted(() => {
onBeforeUnmount(() => {
if (elapsedTimer) clearInterval(elapsedTimer);
if (trackedBatchId) unregisterTaskCancelHandler(trackedBatchId);
void execution.dispose().then((cleaned) => {
if (!cleaned) toast(t("multiDbExecute.cleanupFailed"), 5000);
});
});
const searchQuery = computed(() => searchText.value.trim().toLocaleLowerCase());
@@ -221,6 +232,8 @@ function statusLabel(status: MultiDbExecutionItemStatus): string {
skipped: t("multiDbExecute.skipped"),
cancelled: t("multiDbExecute.cancelled"),
not_executed: t("multiDbExecute.notExecuted"),
pending_commit: t("multiDbExecute.pendingCommit"),
rolled_back: t("multiDbExecute.rolledBack"),
};
return labels[status];
}
@@ -557,6 +570,10 @@ function cancelUnsavedPrompt(): void {
}
function requestClose(): void {
if ((batch.value?.context.manualTransaction && isExecuting.value) || execution.hasTransactions.value) {
discardTransactionsOpen.value = true;
return;
}
if (isExecuting.value) {
open.value = false;
return;
@@ -570,6 +587,21 @@ function requestClose(): void {
open.value = false;
}
async function discardTransactionsAndClose(): Promise<void> {
if (closingTransactions.value) return;
closingTransactions.value = true;
try {
if (!(await execution.cancelAndRollback())) {
discardTransactionsOpen.value = false;
return;
}
discardTransactionsOpen.value = false;
open.value = false;
} finally {
closingTransactions.value = false;
}
}
function openGroupNameDialog(mode: "create" | "update" | "clone" | "rename", group?: SqlExecutionTargetGroup): void {
if ((mode === "create" || mode === "update" || mode === "clone") && !canSaveGroup.value) return;
groupNameMode.value = mode;
@@ -637,9 +669,11 @@ function groupNameForId(id: string): string {
}
function openForNewBatch(): void {
if (isExecuting.value || execution.hasTransactions.value) return;
execution.reset();
executionStarted.value = false;
executionMode.value = "serial";
manualTransaction.value = props.initialManualTransaction === true && supportsManualTransaction.value;
trackedBatchId = undefined;
manageGroups.value = false;
selectedGroupId.value = undefined;
@@ -662,7 +696,7 @@ async function startExecution(): Promise<void> {
if (!valid || !canExecute.value) return;
if (selectedGroupId.value) targetGroupStore.markGroupUsed(selectedGroupId.value);
executionStarted.value = true;
void execution.start(props.sql, selectedTargets.value, { sourceOffset: props.sourceOffset }, executionMode.value);
void execution.start(props.sql, selectedTargets.value, { sourceOffset: props.sourceOffset, manualTransaction: manualTransaction.value && supportsManualTransaction.value }, executionMode.value);
}
function removeSelected(target: MultiDbExecutionTarget): void {
@@ -722,7 +756,7 @@ 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") : t("multiDbExecute.title") }}
{{ executionStarted ? t("multiDbExecute.progress", { completed: progressCompleted, total: batch?.items.length ?? 0 }) : t("multiDbExecute.title") }}
</DialogTitle>
</DialogHeader>
@@ -735,6 +769,7 @@ watch(
<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">
<div class="space-y-2">
@@ -750,6 +785,10 @@ watch(
<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>
<div v-if="item.transaction" class="flex shrink-0 gap-2">
<Button size="sm" :disabled="isExecuting || item.settling || !item.transaction.canCommit" @click="execution.finishTransaction(item.id, 'commit')">{{ t("multiDbExecute.commit") }}</Button>
<Button size="sm" variant="outline" :disabled="isExecuting || item.settling" @click="execution.finishTransaction(item.id, 'rollback')">{{ t("multiDbExecute.rollback") }}</Button>
</div>
</div>
</div>
</div>
@@ -758,7 +797,7 @@ watch(
<X class="h-4 w-4" />
{{ t("multiDbExecute.cancelBatch") }}
</Button>
<Button v-if="isExecuting" variant="outline" @click="requestClose">{{ t("exportProgress.minimize") }}</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>
</DialogFooter>
</div>
@@ -989,6 +1028,11 @@ watch(
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2 border-t bg-muted/15 px-5 py-2 text-xs text-muted-foreground">
<label v-if="supportsManualTransaction" class="flex items-center gap-2">
<input v-model="manualTransaction" type="checkbox" :disabled="isExecuting" />
{{ t("multiDbExecute.manualTransaction") }}
</label>
<p v-if="manualTransaction" class="w-full">{{ t("multiDbExecute.manualWarning") }}</p>
<span>{{ executionMode === "serial" ? t("multiDbExecute.serialContinueOnError") : t("multiDbExecute.parallelContinueOnError") }}</span>
<span v-if="validating" class="inline-flex items-center gap-1"><Loader2 class="h-3 w-3 animate-spin" />{{ t("common.loading") }}</span>
<span v-else-if="invalidTargetCount" class="text-destructive">{{ t("multiDbExecute.invalidTargetCount", { count: invalidTargetCount }) }}</span>
@@ -1013,6 +1057,19 @@ watch(
</DialogContent>
</Dialog>
<Dialog :open="discardTransactionsOpen" @update:open="(value) => !closingTransactions && (discardTransactionsOpen = value)">
<DialogContent class="sm:max-w-[480px]">
<DialogHeader
><DialogTitle>{{ t("multiDbExecute.discardTitle") }}</DialogTitle></DialogHeader
>
<p class="text-sm text-muted-foreground">{{ t("multiDbExecute.discardWarning") }}</p>
<DialogFooter>
<Button variant="outline" :disabled="closingTransactions" @click="discardTransactionsOpen = false">{{ t("dangerDialog.cancel") }}</Button>
<Button variant="destructive" :disabled="closingTransactions || batch?.status === 'cancelling'" @click="discardTransactionsAndClose">{{ t("multiDbExecute.rollbackAndClose") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog :open="groupNameDialogOpen" @update:open="(value) => (value ? (groupNameDialogOpen = true) : cancelGroupNameDialog())">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>
@@ -20,6 +20,8 @@ import { historyEntrySource } from "@/lib/history/historyEntrySource";
import type { HistoryConnectionFilter, HistoryDatabaseFilter, HistoryEntry, HistorySearchRequest } from "@/lib/backend/api";
import { copyToClipboard } from "@/lib/common/clipboard";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { supportsTransaction } from "@/lib/database/databaseFeatureSupport";
import { formatError, isManualTransactionSessionExpired } from "@/lib/backend/errorUtils";
import * as api from "@/lib/backend/api";
const { t } = useI18n();
@@ -54,6 +56,12 @@ const startDateInputRef = ref<HTMLInputElement | null>(null);
const endDateInputRef = ref<HTMLInputElement | null>(null);
const selectedEntry = ref<HistoryEntry | null>(null);
const isRollingBack = ref(false);
const manualRollback = ref(false);
const resolvingTransaction = ref(false);
const pendingRollback = ref<{ sessionId: string; entry: HistoryEntry; affectedRows: number; executionTime: number; ready: boolean; commitUncertain?: boolean }>();
const rollbackLocked = computed(() => isRollingBack.value || !!pendingRollback.value || resolvingTransaction.value);
let disposed = false;
const canUseManualRollback = computed(() => supportsTransaction(connectionStore.getConfig(selectedEntry.value?.connection_id ?? "")?.db_type));
const showDeleteConfirm = ref(false);
const showClearConfirm = ref(false);
const deleteTargetId = ref<string | null>(null);
@@ -357,12 +365,30 @@ function detailsRows(entry: HistoryEntry) {
return rows;
}
async function rollback(entry: HistoryEntry) {
if (!canRollbackHistoryEntry(entry) || isRollingBack.value) return;
async function recordRollback(entry: HistoryEntry, affectedRows: number, executionTime: number) {
await store.add({
connection_id: entry.connection_id,
connection_name: entry.connection_name,
database: entry.database,
sql: entry.rollback_sql!,
execution_time_ms: executionTime,
success: true,
activity_kind: "data_change",
operation: "ROLLBACK",
target: entry.target,
affected_rows: affectedRows,
details_json: JSON.stringify({ rollback_of: entry.id }),
});
}
async function rollback(entry: HistoryEntry, manual = false) {
if (!canRollbackHistoryEntry(entry) || rollbackLocked.value) return;
if (!window.confirm(t("history.rollbackConfirm"))) return;
entry = { ...entry };
const connectionId = entry.connection_id!;
const rollbackSql = entry.rollback_sql!;
const useTransaction = manual && supportsTransaction(connectionStore.getConfig(connectionId)?.db_type);
isRollingBack.value = true;
const start = Date.now();
try {
@@ -371,31 +397,84 @@ async function rollback(entry: HistoryEntry) {
database: entry.database,
sql: rollbackSql,
source: t("production.sourceQueryHistory"),
execute: () => api.executeScript(connectionId, entry.database, rollbackSql),
execute: async () => {
if (!useTransaction) return api.executeScript(connectionId, entry.database, rollbackSql);
if (disposed) return undefined;
const sessionId = await api.beginManualTransaction(connectionId, entry.database);
pendingRollback.value = { sessionId, entry, affectedRows: 0, executionTime: 0, ready: false };
if (disposed) return undefined;
const results = await api.executeInManualTransaction(sessionId, rollbackSql, entry.database);
return { affected_rows: results.reduce((total, result) => total + result.affected_rows, 0) };
},
});
if (!result) return;
await store.add({
connection_id: connectionId,
connection_name: entry.connection_name,
database: entry.database,
sql: rollbackSql,
execution_time_ms: Date.now() - start,
success: true,
activity_kind: "data_change",
operation: "ROLLBACK",
target: entry.target,
affected_rows: result.affected_rows,
details_json: JSON.stringify({ rollback_of: entry.id }),
});
toast(t("history.rollbackSuccess"));
if (pendingRollback.value) {
pendingRollback.value.affectedRows = result.affected_rows;
pendingRollback.value.executionTime = Date.now() - start;
pendingRollback.value.ready = true;
} else {
await recordRollback(entry, result.affected_rows, Date.now() - start);
toast(t("history.rollbackSuccess"));
}
selectedEntry.value = null;
} catch (e: any) {
toast(t("history.rollbackFailed", { message: e?.message || String(e) }), 5000);
} finally {
if (pendingRollback.value && (!pendingRollback.value.ready || disposed)) await finishRollbackTransaction(false);
isRollingBack.value = false;
}
}
async function finishRollbackTransaction(commit: boolean): Promise<boolean> {
const pending = pendingRollback.value;
if (!pending || resolvingTransaction.value || (commit && (!pending.ready || isRollingBack.value))) return false;
resolvingTransaction.value = true;
try {
let committed = false;
try {
if (commit) {
await api.commitManualTransaction(pending.sessionId);
committed = true;
} else await api.rollbackManualTransaction(pending.sessionId);
} catch (error) {
if (!isManualTransactionSessionExpired(error) && formatError(error) !== "Transaction session not found") {
if (commit) {
pending.commitUncertain = true;
pending.ready = false;
}
throw error;
}
if (pending.commitUncertain) toast(t("toolbar.commitOutcomeUnknown"), 5000);
else if (commit) toast(t("history.transactionEnded"), 5000);
}
pendingRollback.value = undefined;
if (committed) {
toast(t("history.rollbackSuccess"));
// A history-storage error cannot undo a database commit or leave a
// transaction control that invites committing the same work again.
try {
await recordRollback(pending.entry, pending.affectedRows, pending.executionTime);
} catch (error) {
toast(formatError(error), 5000);
}
}
return true;
} catch (error) {
toast(formatError(error), 5000);
return false;
} finally {
resolvingTransaction.value = false;
}
}
async function closeHistory() {
if (resolvingTransaction.value || (isRollingBack.value && pendingRollback.value)) return;
if (pendingRollback.value) {
if (!window.confirm(t("history.rollbackBeforeClose")) || !(await finishRollbackTransaction(false))) return;
}
emit("close");
}
function getHistoryMenuItems(entry: HistoryEntry): ContextMenuItem[] {
return [
{
@@ -434,6 +513,8 @@ onMounted(() => {
});
onBeforeUnmount(() => {
disposed = true;
if (pendingRollback.value && !isRollingBack.value) void finishRollbackTransaction(false);
store.setHistoryPanelActive(false);
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
searchDebounceTimer = null;
@@ -451,11 +532,17 @@ onBeforeUnmount(() => {
<Button v-if="store.total > 0 || store.connectionOptions.length > 0" variant="ghost" size="icon" class="h-5 w-5" @click="confirmClearHistory">
<Trash2 class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="emit('close')">
<Button variant="ghost" size="icon" class="h-5 w-5" :aria-label="t('common.close')" :disabled="resolvingTransaction || (isRollingBack && !!pendingRollback)" @click="closeHistory">
<X class="h-3 w-3" />
</Button>
</div>
<div v-if="pendingRollback" class="flex flex-wrap items-center gap-2 border-b p-2 text-xs" role="status">
<span class="w-full">{{ pendingRollback.entry.connection_name }} / {{ pendingRollback.entry.database }}: {{ t("history.pendingRollback") }}</span>
<Button size="sm" variant="outline" :disabled="isRollingBack || resolvingTransaction" @click="finishRollbackTransaction(false)">{{ t("history.discardRollback") }}</Button>
<Button size="sm" :disabled="isRollingBack || resolvingTransaction || !pendingRollback.ready" @click="finishRollbackTransaction(true)">{{ t("toolbar.commit") }}</Button>
</div>
<div class="border-b shrink-0">
<div ref="filterScrollRef" class="history-filter-scroll flex gap-1 px-2 pt-2" :class="{ 'history-filter-scroll--scrollable': filtersScrollable }">
<button v-for="filter in filters" :key="filter" type="button" class="h-6 shrink-0 rounded border px-2 text-xs" :class="activeFilter === filter ? 'border-primary bg-primary text-primary-foreground' : 'bg-background'" @click="activeFilter = filter">
@@ -683,7 +770,11 @@ onBeforeUnmount(() => {
{{ t("history.analyzeWithAi") }}
</Button>
<Button variant="outline" @click="selectedEntry && restore(selectedEntry)">{{ t("history.restore") }}</Button>
<Button v-if="selectedEntry && canRollbackHistoryEntry(selectedEntry)" :disabled="isRollingBack" @click="rollback(selectedEntry)">
<label v-if="selectedEntry && canRollbackHistoryEntry(selectedEntry) && canUseManualRollback" class="flex items-center gap-2 text-xs" :title="t('history.manualRollbackHint')">
<input v-model="manualRollback" type="checkbox" :disabled="rollbackLocked" />
{{ t("toolbar.manualTransaction") }}
</label>
<Button v-if="selectedEntry && canRollbackHistoryEntry(selectedEntry)" :disabled="rollbackLocked" @click="rollback(selectedEntry, manualRollback)">
<RotateCcw class="h-4 w-4" />
{{ isRollingBack ? t("common.loading") : t("history.rollback") }}
</Button>
@@ -0,0 +1,94 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { computed, createApp, nextTick, type App } from "vue";
import MultiDbExecuteDialog from "../MultiDbExecuteDialog.vue";
import type { MultiDbExecutionAdapter } from "@/composables/useMultiDbExecution";
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
vi.mock("@/stores/sqlExecutionTargetGroupStore", () => ({ useSqlExecutionTargetGroupStore: () => ({ getGroupsByDatabaseType: () => [], getGroup: () => undefined }) }));
vi.mock("@/composables/useExportTracker", () => ({ formatDataTransferDuration: String, useExportTracker: () => ({ addMultiDbExecutionTask: vi.fn(), updateMultiDbExecutionTask: vi.fn(), registerTaskCancelHandler: vi.fn(), unregisterTaskCancelHandler: vi.fn() }) }));
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" })),
}),
}));
let app: App | undefined;
const onOpenChange = vi.fn();
afterEach(() => {
app?.unmount();
app = undefined;
document.body.innerHTML = "";
onOpenChange.mockClear();
});
async function flushPromises() {
await new Promise((resolve) => setTimeout(resolve, 0));
await nextTick();
}
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.mount(root);
await flushPromises();
const checkbox = document.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(checkbox?.checked).toBe(true);
button("multiDbExecute.execute").click();
await flushPromises();
}
function button(label: string) {
const result = [...document.querySelectorAll<HTMLButtonElement>("button")].find((candidate) => candidate.textContent?.trim() === label);
if (!result) throw new Error(`Missing button ${label}`);
return result;
}
describe("manual multi-database dialog", () => {
it("shows pending changes, commits only on click, and clears transaction controls", async () => {
const finish = vi.fn().mockResolvedValue(undefined);
const execute = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async () => ({ status: "pending_commit" as const, transaction: { canCommit: true, finish } }));
await openDialog(execute);
expect(execute.mock.calls[0][0].context.manualTransaction).toBe(true);
expect(document.body.textContent).toContain("multiDbExecute.pendingCommit");
expect(finish).not.toHaveBeenCalled();
button("multiDbExecute.commit").click();
await flushPromises();
expect(finish).toHaveBeenCalledExactlyOnceWith("commit");
expect([...document.querySelectorAll("button")].some((candidate) => candidate.textContent?.trim() === "multiDbExecute.commit")).toBe(false);
});
it("keeps the dialog open until pending changes are explicitly discarded", async () => {
const finish = vi.fn().mockResolvedValue(undefined);
await openDialog(vi.fn(async () => ({ status: "pending_commit" as const, transaction: { canCommit: true, finish } })));
button("common.close").click();
await flushPromises();
expect(document.body.textContent).toContain("multiDbExecute.discardTitle");
expect(onOpenChange).not.toHaveBeenCalled();
expect(finish).not.toHaveBeenCalled();
button("multiDbExecute.rollbackAndClose").click();
await flushPromises();
expect(finish).toHaveBeenCalledExactlyOnceWith("rollback");
expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false);
});
it("keeps rollback available after failed cleanup", async () => {
const finish = vi.fn().mockRejectedValueOnce(new Error("offline")).mockResolvedValue(undefined);
await openDialog(vi.fn(async () => ({ status: "failed" as const, transaction: { canCommit: false, finish } })));
expect(button("multiDbExecute.commit").disabled).toBe(true);
button("multiDbExecute.rollback").click();
await flushPromises();
expect(document.body.textContent).toContain("offline");
expect(button("multiDbExecute.rollback").disabled).toBe(false);
button("multiDbExecute.rollback").click();
await flushPromises();
expect(document.body.textContent).toContain("multiDbExecute.rolledBack");
});
});
@@ -0,0 +1,231 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import QueryHistory from "../QueryHistory.vue";
const mocks = vi.hoisted(() => ({
add: vi.fn(),
beginManualTransaction: vi.fn(),
executeInManualTransaction: vi.fn(),
commitManualTransaction: vi.fn(),
rollbackManualTransaction: vi.fn(),
executeScript: vi.fn(),
guard: vi.fn(),
toast: vi.fn(),
close: vi.fn(),
entry: {
id: "history-1",
connection_id: "ob-test",
connection_name: "Transaction test",
database: "SYS",
sql: "UPDATE TEST_DATA SET VAL = 20 WHERE ID = 1",
rollback_sql: "UPDATE TEST_DATA SET VAL = 10 WHERE ID = 1",
execution_time_ms: 1,
executed_at: "2026-01-01T00:00:00Z",
success: true,
activity_kind: "data_change",
operation: "UPDATE",
target: "TEST_DATA",
},
}));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@/stores/historyStore", () => ({
useHistoryStore: () => ({
entries: [mocks.entry],
connectionOptions: [],
total: 1,
loading: false,
error: "",
nextCursor: null,
setHistoryPanelActive: vi.fn(),
loadConnectionOptions: vi.fn().mockResolvedValue(undefined),
search: vi.fn().mockResolvedValue(undefined),
add: mocks.add,
}),
}));
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ getConfig: () => ({ id: "ob-test", db_type: "oceanbase-oracle" }) }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: (text: string) => text }) }));
vi.mock("@/lib/database/productionExecutionGuard", () => ({ executeWithProductionSqlGuard: mocks.guard }));
vi.mock("@/lib/backend/api", () => ({
beginManualTransaction: mocks.beginManualTransaction,
executeInManualTransaction: mocks.executeInManualTransaction,
commitManualTransaction: mocks.commitManualTransaction,
rollbackManualTransaction: mocks.rollbackManualTransaction,
executeScript: mocks.executeScript,
}));
vi.mock("vue-virtual-scroller", () => ({
RecycleScroller: defineComponent({
props: ["items"],
setup:
(props, { slots }) =>
() =>
h(
"div",
props.items.map((item: unknown) => slots.default?.({ item })),
),
}),
}));
vi.mock("@/components/ui/CustomContextMenu.vue", () => ({
default: defineComponent({
setup:
(_, { slots }) =>
() =>
h("div", slots.default?.({ onContextMenu: vi.fn(), isOpen: false })),
}),
}));
let app: App | undefined;
function button(key: string) {
const found = [...document.querySelectorAll<HTMLButtonElement>("button")].find((item) => item.textContent?.trim() === key);
if (!found) throw new Error(`Missing button ${key}`);
return found;
}
async function openRollback(manual = true) {
const root = document.createElement("div");
document.body.append(root);
app = createApp(QueryHistory, { onClose: mocks.close });
app.mount(root);
await nextTick();
const row = [...document.querySelectorAll<HTMLDivElement>("div")].find((item) => item.textContent?.includes(mocks.entry.sql) && item.classList.contains("cursor-pointer"));
expect(row).toBeDefined();
row!.click();
await nextTick();
await vi.waitFor(() => expect(button("history.rollback")).toBeDefined());
if (manual) {
const checkbox = [...document.querySelectorAll("label")].find((item) => item.textContent?.includes("toolbar.manualTransaction"))?.querySelector<HTMLInputElement>("input");
expect(checkbox).toBeDefined();
checkbox!.click();
await nextTick();
}
button("history.rollback").click();
}
beforeEach(() => {
vi.resetAllMocks();
vi.stubGlobal("confirm", vi.fn().mockReturnValue(true));
mocks.guard.mockImplementation((options) => options.execute());
mocks.beginManualTransaction.mockResolvedValue("history-txn");
mocks.executeInManualTransaction.mockResolvedValue([{ affected_rows: 1 }]);
mocks.commitManualTransaction.mockResolvedValue({});
mocks.rollbackManualTransaction.mockResolvedValue({});
mocks.executeScript.mockResolvedValue({ affected_rows: 1 });
mocks.add.mockResolvedValue(undefined);
});
afterEach(async () => {
app?.unmount();
app = undefined;
await nextTick();
document.body.textContent = "";
vi.unstubAllGlobals();
});
describe("QueryHistory manual rollback SQL", () => {
it("uses the selected history target and records success only after commit", async () => {
await openRollback();
await vi.waitFor(() => expect(button("toolbar.commit").disabled).toBe(false));
expect(mocks.beginManualTransaction).toHaveBeenCalledWith("ob-test", "SYS");
expect(mocks.executeInManualTransaction).toHaveBeenCalledWith("history-txn", mocks.entry.rollback_sql, "SYS");
expect(mocks.executeScript).not.toHaveBeenCalled();
expect(mocks.add).not.toHaveBeenCalled();
expect(mocks.toast).not.toHaveBeenCalledWith("history.rollbackSuccess");
button("toolbar.commit").click();
await vi.waitFor(() => expect(mocks.add).toHaveBeenCalledWith(expect.objectContaining({ sql: mocks.entry.rollback_sql, affected_rows: 1, details_json: JSON.stringify({ rollback_of: "history-1" }) })));
expect(mocks.commitManualTransaction).toHaveBeenCalledWith("history-txn");
});
it("discards the compensation without recording a successful rollback", async () => {
await openRollback();
await vi.waitFor(() => expect(button("history.discardRollback").disabled).toBe(false));
button("history.discardRollback").click();
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("history-txn"));
expect(mocks.add).not.toHaveBeenCalled();
expect(mocks.commitManualTransaction).not.toHaveBeenCalled();
});
it("rolls back a failed statement without falling back to ordinary execution", async () => {
mocks.executeInManualTransaction.mockRejectedValueOnce(new Error("statement failed"));
await openRollback();
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("history-txn"));
expect(mocks.executeScript).not.toHaveBeenCalled();
expect(mocks.add).not.toHaveBeenCalled();
});
it("keeps transaction controls when commit fails and does not record success", async () => {
mocks.commitManualTransaction.mockRejectedValueOnce(new Error("commit unavailable"));
await openRollback();
await vi.waitFor(() => expect(button("toolbar.commit").disabled).toBe(false));
button("toolbar.commit").click();
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("commit unavailable", 5000));
expect(button("history.discardRollback").disabled).toBe(false);
expect(button("toolbar.commit").disabled).toBe(true);
expect(mocks.add).not.toHaveBeenCalled();
});
it("does not claim successful compensation after an unknown commit outcome", async () => {
mocks.commitManualTransaction.mockRejectedValueOnce(new Error("response lost"));
await openRollback();
await vi.waitFor(() => expect(button("toolbar.commit").disabled).toBe(false));
button("toolbar.commit").click();
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("response lost", 5000));
mocks.rollbackManualTransaction.mockRejectedValueOnce(new Error("Transaction session not found"));
button("history.discardRollback").click();
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("toolbar.commitOutcomeUnknown", 5000));
expect(mocks.add).not.toHaveBeenCalled();
expect(mocks.commitManualTransaction).toHaveBeenCalledTimes(1);
});
it("cleans up a session returned after unmount without executing compensation", async () => {
let resolve!: (session: string) => void;
mocks.beginManualTransaction.mockReturnValueOnce(new Promise((done) => (resolve = done)));
await openRollback();
await vi.waitFor(() => expect(mocks.beginManualTransaction).toHaveBeenCalledOnce());
app!.unmount();
app = undefined;
resolve("late-history-txn");
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("late-history-txn"));
expect(mocks.executeInManualTransaction).not.toHaveBeenCalled();
});
it("does not create a transaction when the production guard declines", async () => {
mocks.guard.mockResolvedValueOnce(undefined);
await openRollback();
await vi.waitFor(() => expect(mocks.guard).toHaveBeenCalledOnce());
expect(mocks.beginManualTransaction).not.toHaveBeenCalled();
expect(mocks.executeInManualTransaction).not.toHaveBeenCalled();
});
it("does not record success when the pending transaction has expired", async () => {
mocks.commitManualTransaction.mockRejectedValueOnce(new Error("Transaction session not found"));
await openRollback();
await vi.waitFor(() => expect(button("toolbar.commit").disabled).toBe(false));
button("toolbar.commit").click();
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("history.transactionEnded", 5000));
expect(mocks.add).not.toHaveBeenCalled();
expect(document.body.textContent).not.toContain("history.pendingRollback");
});
it("asks before discarding pending changes when closing history", async () => {
await openRollback();
await vi.waitFor(() => expect(button("toolbar.commit").disabled).toBe(false));
const confirm = vi.mocked(window.confirm);
confirm.mockReturnValueOnce(false).mockReturnValueOnce(true);
const close = document.querySelector<HTMLButtonElement>('button[aria-label="common.close"]')!;
close.click();
await nextTick();
expect(mocks.close).not.toHaveBeenCalled();
expect(mocks.rollbackManualTransaction).not.toHaveBeenCalled();
close.click();
await vi.waitFor(() => expect(mocks.close).toHaveBeenCalledOnce());
expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("history-txn");
expect(mocks.add).not.toHaveBeenCalled();
});
it("preserves ordinary execution when manual mode is not selected", async () => {
await openRollback(false);
await vi.waitFor(() => expect(mocks.add).toHaveBeenCalledOnce());
expect(mocks.executeScript).toHaveBeenCalledWith("ob-test", "SYS", mocks.entry.rollback_sql);
expect(mocks.beginManualTransaction).not.toHaveBeenCalled();
});
});