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

@@ -30,6 +30,8 @@ import {
import CompareKeyColumnsSelect from "@/components/diff/CompareKeyColumnsSelect.vue";
import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { supportsTransaction } from "@/lib/database/databaseFeatureSupport";
import { formatError, isManualTransactionSessionExpired } from "@/lib/backend/errorUtils";
import TableMultiSelect from "@/components/diff/TableMultiSelect.vue";
import { ArrowLeftRight, CheckSquare, ChevronDown, ChevronRight, Copy, GitCompareArrows, Loader2, Play, RotateCcw, Square } from "@lucide/vue";
@@ -75,6 +77,14 @@ const compareProgressCurrent = ref(0);
const compareProgressTotal = ref(0);
const compareProgressTable = ref("");
const executing = ref(false);
const manualTransaction = ref(false);
const txnSessionId = ref<string>();
let commitUncertain = false;
const resolvingTransaction = ref(false);
const transactionFailed = ref(false);
let executionInterrupted = false;
const executionLocked = computed(() => executing.value || resolvingTransaction.value || !!txnSessionId.value);
const canUseManualTransaction = computed(() => supportsTransaction(store.getConfig(targetConnectionId.value)?.db_type));
const executedCount = ref(0);
const executeTotal = ref(0);
const syncErrors = ref<{ sql: string; error: string }[]>([]);
@@ -465,6 +475,7 @@ function applyDataCompareSession(session: DataCompareSession | undefined): void
}
function swapSourceTarget() {
if (executionLocked.value) return;
const previousSelectedTables = [...selectedSourceTableNames.value];
const nextSingleTarget = previousSelectedTables.length === 1 ? (previousSelectedTables[0] ?? "") : "";
const nextSourceSelection = previousSelectedTables.length <= 1 ? [targetTable.value].filter(Boolean) : previousSelectedTables;
@@ -622,6 +633,7 @@ function toggleShowAll(table: DataCompareTableResult, kind: DiffKind) {
}
function setDiffSelection(kind: DiffKind, selected: boolean) {
if (executionLocked.value) return;
batchResults.value.forEach((table) => {
table.diff[kind].forEach((row) => {
row.selected = selected;
@@ -631,6 +643,7 @@ function setDiffSelection(kind: DiffKind, selected: boolean) {
}
function setTableDiffSelection(table: DataCompareTableResult, kind: DiffKind, selected: boolean) {
if (executionLocked.value) return;
table.diff[kind].forEach((row) => {
row.selected = selected;
});
@@ -638,6 +651,7 @@ function setTableDiffSelection(table: DataCompareTableResult, kind: DiffKind, se
}
function clearAllSelections() {
if (executionLocked.value) return;
(["added", "removed", "modified"] as DiffKind[]).forEach((kind) => {
batchResults.value.forEach((table) => {
table.diff[kind].forEach((row) => {
@@ -649,6 +663,7 @@ function clearAllSelections() {
}
function toggleRowSelection(row: SelectableDataCompareRow | SelectableDataCompareModifiedRow) {
if (executionLocked.value) return;
row.selected = !row.selected;
rebuildSyncPlan().catch((e) => toast(String(e), 5000));
}
@@ -695,7 +710,7 @@ async function rebuildSyncPlan() {
}
function startCompare(): void {
if (!canCompare.value || comparing.value || executing.value) return;
if (!canCompare.value || comparing.value || executionLocked.value) return;
const tasks = buildCompareTasks();
if (tasks.length === 0) {
toast(t("dataCompare.noComparableTables"), 5000);
@@ -747,30 +762,46 @@ async function copySql() {
}
async function executeSql() {
if (!syncPlan.value.syncSql.trim() || syncPlan.value.syncStatements.length === 0 || executing.value) return;
const targetConnection = store.getConfig(targetConnectionId.value);
if (!syncPlan.value.syncSql.trim() || syncPlan.value.syncStatements.length === 0 || planningSync.value || comparing.value || executionLocked.value) return;
const connectionId = targetConnectionId.value;
const database = targetDatabase.value;
const schema = targetSchema.value;
const statements = [...syncPlan.value.syncStatements];
const useTransaction = manualTransaction.value && canUseManualTransaction.value;
const targetConnection = store.getConfig(connectionId);
executing.value = true;
executionInterrupted = false;
transactionFailed.value = false;
try {
const failed = await executeWithProductionSqlGuard({
connection: targetConnection,
database: targetDatabase.value,
database,
sql: syncPlan.value.syncSql,
source: t("production.sourceDataCompare"),
execute: async () => {
executing.value = true;
syncErrors.value = [];
executeTotal.value = syncPlan.value.syncStatements.length;
executeTotal.value = statements.length;
executedCount.value = 0;
await store.ensureConnected(targetConnectionId.value);
const statements = syncPlan.value.syncStatements;
await store.ensureConnected(connectionId);
if (useTransaction && (componentUnmounted || executionInterrupted)) return undefined;
if (useTransaction) {
txnSessionId.value = await api.beginManualTransaction(connectionId, database, schema);
commitUncertain = false;
}
for (let index = 0; index < statements.length; index += SYNC_EXECUTE_BATCH_SIZE) {
if (useTransaction && (componentUnmounted || executionInterrupted)) return undefined;
const batch = statements.slice(index, index + SYNC_EXECUTE_BATCH_SIZE);
try {
await api.executeBatch(targetConnectionId.value, targetDatabase.value, batch, targetSchema.value);
if (useTransaction) await api.executeInManualTransaction(txnSessionId.value!, batch.join(";\n"), database, schema);
else await api.executeBatch(connectionId, database, batch, schema);
executedCount.value += batch.length;
} catch (e: any) {
// A failed manual transaction is rolled back by the backend. Never
// replay its statements on an ordinary connection.
if (useTransaction) throw e;
for (const stmt of batch) {
try {
await api.executeBatch(targetConnectionId.value, targetDatabase.value, [stmt], targetSchema.value);
await api.executeBatch(connectionId, database, [stmt], schema);
} catch (singleError: any) {
syncErrors.value.push({ sql: stmt, error: singleError?.message || String(singleError) });
}
@@ -782,18 +813,76 @@ async function executeSql() {
},
});
if (failed === undefined) return;
if (failed === 0) {
if (failed === 0 && !txnSessionId.value) {
toast(t("dataCompare.syncSuccess"), 2000);
} else {
toast(t("diff.syncSummary", { success: syncPlan.value.syncStatements.length - failed, failed }), 5000);
} else if (failed > 0) {
toast(t("diff.syncSummary", { success: statements.length - failed, failed }), 5000);
}
} catch (e: any) {
transactionFailed.value = true;
toast(e?.message || String(e), 5000);
} finally {
if (txnSessionId.value && (transactionFailed.value || componentUnmounted || executionInterrupted)) await finishTransaction(false);
executing.value = false;
}
}
async function finishTransaction(commit: boolean): Promise<boolean> {
const sessionId = txnSessionId.value;
if (!sessionId || resolvingTransaction.value || (commit && (executing.value || transactionFailed.value))) return false;
resolvingTransaction.value = true;
try {
let committed = false;
let outcomeUnknown = false;
try {
if (commit) {
await api.commitManualTransaction(sessionId);
committed = true;
} else await api.rollbackManualTransaction(sessionId);
} catch (error) {
if (!isManualTransactionSessionExpired(error) && formatError(error) !== "Transaction session not found") {
if (commit) {
commitUncertain = true;
transactionFailed.value = true;
}
throw error;
}
outcomeUnknown = commitUncertain;
if (outcomeUnknown) toast(t("toolbar.commitOutcomeUnknown"), 5000);
else if (commit) toast(t("dataCompare.transactionEnded"), 5000);
}
txnSessionId.value = undefined;
if (committed || outcomeUnknown) {
const session = getDataCompareSession(activeSessionId.value);
if (session) {
session.batchResults = [];
session.syncPlan = emptyDataCompareSyncPlan();
session.version++;
}
clearResult();
if (committed) toast(t("dataCompare.syncSuccess"), 2000);
}
return true;
} catch (error) {
toast(formatError(error), 5000);
return false;
} finally {
resolvingTransaction.value = false;
}
}
async function handleOpenChange(value: boolean) {
if (!value && ((executing.value && manualTransaction.value) || resolvingTransaction.value)) return;
if (!value && txnSessionId.value) {
if (!window.confirm(t("dataCompare.rollbackBeforeClose")) || !(await finishTransaction(false))) return;
}
open.value = value;
}
watch(canUseManualTransaction, (supported) => {
if (!supported && !executionLocked.value) manualTransaction.value = false;
});
function formatValue(value: DataCompareCellValue): string {
if (value == null) return "NULL";
if (typeof value === "string") return value;
@@ -912,7 +1001,12 @@ watch(targetTable, () => {
watch(
[() => open.value, () => props.sessionId],
async ([value, sessionId]) => {
if (!value) return;
if (!value) {
executionInterrupted = true;
if (!executing.value && txnSessionId.value) void finishTransaction(false);
return;
}
if (executionLocked.value) return;
clearResult();
shownSessionError = "";
const session = getDataCompareSession(sessionId);
@@ -960,11 +1054,12 @@ watch(
);
onBeforeUnmount(() => {
componentUnmounted = true;
if (!executing.value && txnSessionId.value) void finishTransaction(false);
});
</script>
<template>
<Dialog v-model:open="open">
<Dialog :open="open" @update:open="handleOpenChange">
<DialogContent class="sm:max-w-5xl max-h-[85vh] flex flex-col overflow-hidden" @interact-outside.prevent>
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
@@ -973,7 +1068,7 @@ onBeforeUnmount(() => {
</DialogTitle>
</DialogHeader>
<div class="flex-1 min-h-0 overflow-auto space-y-4 py-2">
<fieldset :disabled="executionLocked" class="flex-1 min-h-0 min-w-0 overflow-auto space-y-4 py-2">
<div class="grid grid-cols-[1fr_auto_1fr] gap-4 items-start">
<div class="space-y-2 rounded-lg border border-blue-500/35 bg-blue-500/5 p-3">
<div class="flex items-center gap-2 text-sm font-medium text-blue-600 dark:text-blue-400">
@@ -982,7 +1077,7 @@ onBeforeUnmount(() => {
</div>
<ConnectionTreeSelect
v-model="sourceConnectionId"
:disabled="comparing"
:disabled="comparing || executionLocked"
:connections="sqlConnections"
:layout="store.sidebarLayout"
:placeholder="t('diff.selectConnection')"
@@ -997,7 +1092,7 @@ onBeforeUnmount(() => {
:placeholder="t('diff.selectDatabase')"
:search-placeholder="t('diff.searchDatabase')"
:empty-text="t('common.noResults')"
:disabled="comparing || !sourceDatabases.length"
:disabled="comparing || executionLocked || !sourceDatabases.length"
trigger-variant="outline"
trigger-class="h-8 w-full justify-between text-xs"
content-class="w-[var(--reka-popover-trigger-width)]"
@@ -1006,7 +1101,7 @@ onBeforeUnmount(() => {
v-if="sourceSchemas.length"
v-model="sourceSchema"
:options="sourceSchemas"
:disabled="comparing"
:disabled="comparing || executionLocked"
:placeholder="t('diff.selectSchema')"
:search-placeholder="t('diff.searchSchema')"
:empty-text="t('common.noResults')"
@@ -1021,12 +1116,12 @@ onBeforeUnmount(() => {
:tables="sourceTables"
:title="t('dataCompare.sourceTables')"
:empty-text="!sourceConnectionId || !sourceDatabase ? t('dataCompare.selectSourceTables') : t('dataCompare.noTables')"
:disabled="comparing"
:disabled="comparing || executionLocked"
/>
</div>
<div class="flex items-center pt-6">
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('diff.swap')" :disabled="comparing" @click="swapSourceTarget">
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('diff.swap')" :disabled="comparing || executionLocked" @click="swapSourceTarget">
<ArrowLeftRight class="w-3.5 h-3.5" />
</Button>
</div>
@@ -1038,7 +1133,7 @@ onBeforeUnmount(() => {
</div>
<ConnectionTreeSelect
v-model="targetConnectionId"
:disabled="comparing"
:disabled="comparing || executionLocked"
:connections="sqlConnections"
:layout="store.sidebarLayout"
:placeholder="t('diff.selectConnection')"
@@ -1053,7 +1148,7 @@ onBeforeUnmount(() => {
:placeholder="t('diff.selectDatabase')"
:search-placeholder="t('diff.searchDatabase')"
:empty-text="t('common.noResults')"
:disabled="comparing || !targetDatabases.length"
:disabled="comparing || executionLocked || !targetDatabases.length"
trigger-variant="outline"
trigger-class="h-8 w-full justify-between text-xs"
content-class="w-[var(--reka-popover-trigger-width)]"
@@ -1062,7 +1157,7 @@ onBeforeUnmount(() => {
v-if="targetSchemas.length"
v-model="targetSchema"
:options="targetSchemas"
:disabled="comparing"
:disabled="comparing || executionLocked"
:placeholder="t('diff.selectSchema')"
:search-placeholder="t('diff.searchSchema')"
:empty-text="t('common.noResults')"
@@ -1079,7 +1174,7 @@ onBeforeUnmount(() => {
:placeholder="t('dataCompare.selectTable')"
:search-placeholder="t('dataCompare.searchTable')"
:empty-text="t('common.noResults')"
:disabled="comparing"
:disabled="comparing || executionLocked"
trigger-variant="outline"
trigger-class="h-8 w-full justify-between text-xs"
content-class="w-[var(--reka-popover-trigger-width)]"
@@ -1112,7 +1207,7 @@ onBeforeUnmount(() => {
<span v-if="singleKeyColumnRow" class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium" :class="singleKeyColumnRow.manual ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'">
{{ singleKeyColumnRow.manual ? t("dataCompare.keyColumnsStatusManual") : t("dataCompare.keyColumnsStatusAuto") }}
</span>
<Button v-if="singleKeyColumnRow?.manual" size="sm" variant="ghost" class="h-6 px-1.5 text-[11px]" :disabled="comparing" @click="clearTableKeyColumnsOverride(singleKeyColumnRow.table)">
<Button v-if="singleKeyColumnRow?.manual" size="sm" variant="ghost" class="h-6 px-1.5 text-[11px]" :disabled="comparing || executionLocked" @click="clearTableKeyColumnsOverride(singleKeyColumnRow.table)">
{{ t("dataCompare.keyColumnsResetAuto") }}
</Button>
</div>
@@ -1123,7 +1218,13 @@ onBeforeUnmount(() => {
<!-- Single table: pick match columns straight from the real database columns. -->
<div v-else-if="singleKeyColumnRow" class="space-y-1">
<CompareKeyColumnsSelect :model-value="singleKeyColumnRow.columns" :columns="columnsForTable(singleKeyColumnRow.table)" :loading="singleKeyColumnRow.loading" :disabled="comparing" @update:model-value="(columns) => setTableKeyColumns(singleKeyColumnRow!.table, columns)" />
<CompareKeyColumnsSelect
:model-value="singleKeyColumnRow.columns"
:columns="columnsForTable(singleKeyColumnRow.table)"
:loading="singleKeyColumnRow.loading"
:disabled="comparing || executionLocked"
@update:model-value="(columns) => setTableKeyColumns(singleKeyColumnRow!.table, columns)"
/>
<div class="text-[11px]" data-key-column-hint :class="singleKeyColumnRow.error || singleKeyColumnRow.status === 'missing' ? 'text-destructive' : 'text-muted-foreground'">
{{ singleKeyColumnRow.hint }}
</div>
@@ -1149,10 +1250,10 @@ onBeforeUnmount(() => {
</span>
</button>
<div v-if="expandedKeyColumnTable === row.table" class="space-y-1 border-t bg-muted/10 px-2 py-2">
<CompareKeyColumnsSelect :model-value="row.columns" :columns="columnsForTable(row.table)" :loading="row.loading" :disabled="comparing" @update:model-value="(columns) => setTableKeyColumns(row.table, columns)" />
<CompareKeyColumnsSelect :model-value="row.columns" :columns="columnsForTable(row.table)" :loading="row.loading" :disabled="comparing || executionLocked" @update:model-value="(columns) => setTableKeyColumns(row.table, columns)" />
<div class="flex flex-wrap items-center gap-2 text-[11px]">
<span data-key-column-hint :class="row.error || (row.status === 'missing' && row.matched) ? 'text-destructive' : 'text-muted-foreground'">{{ row.hint }}</span>
<Button v-if="row.manual" size="sm" variant="ghost" class="h-6 px-1.5 text-[11px]" :disabled="comparing" @click="clearTableKeyColumnsOverride(row.table)">
<Button v-if="row.manual" size="sm" variant="ghost" class="h-6 px-1.5 text-[11px]" :disabled="comparing || executionLocked" @click="clearTableKeyColumnsOverride(row.table)">
{{ t("dataCompare.keyColumnsResetAuto") }}
</Button>
</div>
@@ -1341,10 +1442,10 @@ onBeforeUnmount(() => {
</div>
</div>
</div>
</div>
</fieldset>
<DialogFooter v-if="!hasResults">
<Button variant="outline" @click="open = false">{{ t("common.close") }}</Button>
<Button variant="outline" @click="handleOpenChange(false)">{{ t("common.close") }}</Button>
<span v-if="compareProgressLabel" class="text-xs text-muted-foreground self-center">{{ compareProgressLabel }}</span>
<Button size="sm" :disabled="!canCompare || comparing" @click="startCompare">
<Loader2 v-if="comparing" class="w-3.5 h-3.5 animate-spin mr-1" />
@@ -1354,8 +1455,8 @@ onBeforeUnmount(() => {
</DialogFooter>
<DialogFooter v-else class="flex items-center gap-2">
<Button variant="outline" @click="open = false">{{ t("common.close") }}</Button>
<Button variant="outline" size="sm" :disabled="comparing || executing || !canCompare" @click="startCompare">
<Button variant="outline" :disabled="(executing && manualTransaction) || resolvingTransaction" @click="handleOpenChange(false)">{{ t("common.close") }}</Button>
<Button variant="outline" size="sm" :disabled="comparing || executionLocked || !canCompare" @click="startCompare">
<Loader2 v-if="comparing" class="w-3 h-3 animate-spin mr-1" />
<RotateCcw v-else class="w-3 h-3 mr-1" />
{{ t("dataCompare.recompare") }}
@@ -1378,7 +1479,16 @@ onBeforeUnmount(() => {
}}
</span>
<Button variant="outline" size="sm" :disabled="!syncPlan.syncSql.trim()" @click="copySql"> <Copy class="w-3 h-3 mr-1" /> {{ t("diff.copySql") }} </Button>
<Button size="sm" :disabled="planningSync || executing || syncPlan.statementCount === 0" @click="executeSql">
<template v-if="txnSessionId && !executing">
<span class="text-xs text-muted-foreground">{{ t("dataCompare.pendingTransaction") }}</span>
<Button variant="outline" size="sm" :disabled="resolvingTransaction" @click="finishTransaction(false)">{{ t("toolbar.rollback") }}</Button>
<Button size="sm" :disabled="resolvingTransaction || transactionFailed" @click="finishTransaction(true)">{{ t("toolbar.commit") }}</Button>
</template>
<label v-else-if="canUseManualTransaction" class="flex items-center gap-2 text-xs" :title="t('dataCompare.manualTransactionHint')">
<input v-model="manualTransaction" type="checkbox" :disabled="executionLocked" />
{{ t("toolbar.manualTransaction") }}
</label>
<Button v-if="!txnSessionId" size="sm" :disabled="planningSync || comparing || executionLocked || syncPlan.statementCount === 0" @click="executeSql">
<Loader2 v-if="executing" class="w-3 h-3 animate-spin mr-1" />
<Play v-else class="w-3 h-3 mr-1" />
{{ t("diff.executeSync") }}
@@ -7,6 +7,7 @@ import DataCompareDialog from "@/components/diff/DataCompareDialog.vue";
import type { DataCompareSession } from "@/composables/useDataCompareSession";
const mocks = vi.hoisted(() => ({
toast: vi.fn(),
ensureConnected: vi.fn().mockResolvedValue(undefined),
listDatabases: vi.fn().mockResolvedValue([]),
listSchemas: vi.fn().mockResolvedValue(["DBX_TEST", "REPORTING", "SYS"]),
@@ -14,8 +15,14 @@ const mocks = vi.hoisted(() => ({
getColumns: vi.fn().mockResolvedValue([{ name: "ID", data_type: "NUMBER", is_primary_key: true }]),
buildDataCompareSyncPlan: vi.fn(),
executeBatch: vi.fn(),
beginManualTransaction: vi.fn().mockResolvedValue("compare-txn"),
executeInManualTransaction: vi.fn().mockResolvedValue([]),
commitManualTransaction: vi.fn().mockResolvedValue({}),
rollbackManualTransaction: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
const sessionMocks = vi.hoisted(() => ({
session: null as DataCompareSession | null,
}));
@@ -60,6 +67,10 @@ vi.mock("@/lib/backend/api", () => ({
getColumns: mocks.getColumns,
buildDataCompareSyncPlan: mocks.buildDataCompareSyncPlan,
executeBatch: mocks.executeBatch,
beginManualTransaction: mocks.beginManualTransaction,
executeInManualTransaction: mocks.executeInManualTransaction,
commitManualTransaction: mocks.commitManualTransaction,
rollbackManualTransaction: mocks.rollbackManualTransaction,
}));
const mountedApps: App[] = [];
@@ -76,6 +87,7 @@ afterEach(() => {
document.body.textContent = "";
sessionMocks.session = null;
vi.clearAllMocks();
vi.unstubAllGlobals();
});
function completedSession(): DataCompareSession {
@@ -159,6 +171,150 @@ function mountSessionDialog(session: DataCompareSession): App {
return app;
}
function buttonFor(key: string): HTMLButtonElement {
const button = [...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === i18n.global.t(key));
if (!button) throw new Error(`Missing button: ${key}`);
return button;
}
async function mountManualSession(session = completedSession()) {
const app = mountSessionDialog(session);
await flushAsyncSetup();
const label = [...document.querySelectorAll("label")].find((label) => label.textContent?.includes(i18n.global.t("toolbar.manualTransaction")));
const checkbox = label?.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(checkbox).toBeDefined();
checkbox!.click();
await nextTick();
return app;
}
describe("DataCompareDialog manual transactions", () => {
it("keeps the target session pending until commit and locks target changes", async () => {
const session = completedSession();
await mountManualSession(session);
buttonFor("diff.executeSync").click();
await vi.waitFor(() => expect(document.body.textContent).toContain(i18n.global.t("dataCompare.pendingTransaction")));
expect(mocks.beginManualTransaction).toHaveBeenCalledWith("oracle-jdbc-11g", "REPORTING", "DBX_TEST");
expect(mocks.executeInManualTransaction).toHaveBeenCalledWith("compare-txn", "INSERT 1;\nINSERT 2", "REPORTING", "DBX_TEST");
expect(mocks.executeBatch).not.toHaveBeenCalled();
expect(mocks.commitManualTransaction).not.toHaveBeenCalled();
expect(buttonFor("dataCompare.recompare").disabled).toBe(true);
expect([...document.querySelectorAll<HTMLButtonElement>("button.dbx-searchable-select-trigger")].every((button) => button.disabled)).toBe(true);
buttonFor("toolbar.commit").click();
await vi.waitFor(() => expect(mocks.commitManualTransaction).toHaveBeenCalledWith("compare-txn"));
await flushAsyncSetup();
expect(document.body.textContent).not.toContain(i18n.global.t("dataCompare.pendingTransaction"));
expect(session.syncPlan.syncStatements).toEqual([]);
expect(session.batchResults).toEqual([]);
});
it("rolls back without committing", async () => {
await mountManualSession();
buttonFor("diff.executeSync").click();
await vi.waitFor(() => expect(document.body.textContent).toContain(i18n.global.t("dataCompare.pendingTransaction")));
buttonFor("toolbar.rollback").click();
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("compare-txn"));
expect(mocks.commitManualTransaction).not.toHaveBeenCalled();
});
it("stops on the first failed batch without ordinary retries or later batches", async () => {
const session = completedSession();
session.syncPlan.syncStatements = Array.from({ length: 501 }, (_, index) => `INSERT ${index}`);
session.syncPlan.statementCount = 501;
mocks.executeInManualTransaction.mockRejectedValueOnce(new Error("transaction statement rejected"));
await mountManualSession(session);
buttonFor("diff.executeSync").click();
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("compare-txn"));
expect(mocks.executeInManualTransaction).toHaveBeenCalledTimes(1);
expect(mocks.executeBatch).not.toHaveBeenCalled();
expect(mocks.commitManualTransaction).not.toHaveBeenCalled();
});
it("cleans up a transaction created after the dialog unmounts", async () => {
let resolveBegin!: (id: string) => void;
mocks.beginManualTransaction.mockReturnValueOnce(new Promise((resolve) => (resolveBegin = resolve)));
const app = await mountManualSession();
buttonFor("diff.executeSync").click();
await vi.waitFor(() => expect(mocks.beginManualTransaction).toHaveBeenCalled());
mountedApps.splice(mountedApps.indexOf(app), 1);
app.unmount();
resolveBegin("late-compare-txn");
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("late-compare-txn"));
expect(mocks.executeInManualTransaction).not.toHaveBeenCalled();
});
it("keeps a rollback control when commit fails", async () => {
mocks.commitManualTransaction.mockRejectedValueOnce(new Error("commit unavailable"));
await mountManualSession();
buttonFor("diff.executeSync").click();
await vi.waitFor(() => expect(document.body.textContent).toContain(i18n.global.t("dataCompare.pendingTransaction")));
buttonFor("toolbar.commit").click();
await vi.waitFor(() => expect(mocks.commitManualTransaction).toHaveBeenCalled());
await flushAsyncSetup();
expect(buttonFor("toolbar.rollback").disabled).toBe(false);
expect(document.body.textContent).toContain(i18n.global.t("dataCompare.pendingTransaction"));
});
it("waits for an active batch before rollback on unmount and skips later batches", async () => {
const session = completedSession();
session.syncPlan.syncStatements = Array.from({ length: 501 }, (_, index) => `INSERT ${index}`);
session.syncPlan.statementCount = 501;
let finish!: (rows: unknown[]) => void;
mocks.executeInManualTransaction.mockReturnValueOnce(new Promise((resolve) => (finish = resolve)));
const app = await mountManualSession(session);
buttonFor("diff.executeSync").click();
await vi.waitFor(() => expect(mocks.executeInManualTransaction).toHaveBeenCalledOnce());
mountedApps.splice(mountedApps.indexOf(app), 1);
app.unmount();
expect(mocks.rollbackManualTransaction).not.toHaveBeenCalled();
finish([]);
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("compare-txn"));
expect(mocks.executeInManualTransaction).toHaveBeenCalledTimes(1);
});
it("invalidates the sync plan after an unknown commit result without claiming success", async () => {
const session = completedSession();
mocks.commitManualTransaction.mockRejectedValueOnce(new Error("response lost"));
await mountManualSession(session);
buttonFor("diff.executeSync").click();
await vi.waitFor(() => expect(document.body.textContent).toContain(i18n.global.t("dataCompare.pendingTransaction")));
buttonFor("toolbar.commit").click();
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("response lost", 5000));
expect(buttonFor("toolbar.commit").disabled).toBe(true);
mocks.rollbackManualTransaction.mockRejectedValueOnce(new Error("Transaction session not found"));
buttonFor("toolbar.rollback").click();
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith(i18n.global.t("toolbar.commitOutcomeUnknown"), 5000));
expect(session.syncPlan.syncStatements).toEqual([]);
expect(session.batchResults).toEqual([]);
expect(mocks.toast).not.toHaveBeenCalledWith(i18n.global.t("dataCompare.syncSuccess"), 2000);
});
it("disables commit when rollback after an execution error fails", async () => {
mocks.executeInManualTransaction.mockRejectedValueOnce(new Error("statement failed"));
mocks.rollbackManualTransaction.mockRejectedValueOnce(new Error("rollback unavailable"));
await mountManualSession();
buttonFor("diff.executeSync").click();
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalled());
await flushAsyncSetup();
expect(buttonFor("toolbar.commit").disabled).toBe(true);
expect(buttonFor("toolbar.rollback").disabled).toBe(false);
});
it("requires rollback confirmation before closing a pending transaction", async () => {
const confirm = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true);
vi.stubGlobal("confirm", confirm);
await mountManualSession();
buttonFor("diff.executeSync").click();
await vi.waitFor(() => expect(document.body.textContent).toContain(i18n.global.t("dataCompare.pendingTransaction")));
buttonFor("common.close").click();
await flushAsyncSetup();
expect(mocks.rollbackManualTransaction).not.toHaveBeenCalled();
buttonFor("common.close").click();
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("compare-txn"));
expect(confirm).toHaveBeenCalledTimes(2);
});
});
describe("DataCompareDialog source prefill", () => {
it("keeps the Oracle source table after loading database and schema prefills", async () => {
const container = document.createElement("div");
@@ -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();
});
});
@@ -295,7 +295,7 @@ const executeButtonClass = computed(() => {
const canMultiExecute = computed(() => {
if (!supportsQueryExecution(props.activeConnection?.db_type)) return false;
if (props.activeTab.isExecuting || props.activeTab.isExplaining || props.activeTab.isCancelling) return false;
if (props.autoCommit === false || isTransactionActive.value) return false;
if (isTransactionActive.value) return false;
return !!props.executableSql.trim();
});
@@ -131,6 +131,14 @@ describe("EditorToolbar commit/rollback visibility", () => {
const commitSelector = `button[aria-label="toolbar.commit"]`;
const rollbackSelector = `button[aria-label="toolbar.rollback"]`;
it.each([undefined, "active-session"])("only opens independent multi-db transactions when the source has no active session (%s)", async (txnSessionId) => {
const host = await mountToolbar({ dbType: "oceanbase-oracle", stickyProvenReadOnlyState: true, txnSessionId });
const multiDb = host.querySelector<HTMLButtonElement>('button[aria-label="toolbar.multiDbExecute"]');
expect(multiDb).not.toBeNull();
expect(multiDb!.disabled).toBe(!!txnSessionId);
host.remove();
});
it("hides Commit/Rollback for a clean MySQL sticky manual session", async () => {
const host = await mountToolbar({ dbType: "mysql", stickyProvenReadOnlyState: true, txnPossiblyDirty: false, txnSessionId: "txn-1" });
@@ -5,6 +5,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
addSqlFileTask: vi.fn(),
beginManualTransaction: vi.fn(),
commitManualTransaction: vi.fn(),
rollbackManualTransaction: vi.fn(),
cancelSqlFileExecution: vi.fn(),
ensureConnected: vi.fn(),
executeSqlFiles: vi.fn(),
@@ -14,6 +17,7 @@ const mocks = vi.hoisted(() => ({
previewSqlFile: vi.fn(),
inspectSqlFileTables: vi.fn(),
progressHandler: undefined as undefined | ((progress: Record<string, unknown>) => void),
changeDialogOpen: undefined as undefined | ((open: boolean) => void),
refreshDatabaseTreeNode: vi.fn(),
requestConfirmation: vi.fn(),
toast: vi.fn(),
@@ -56,6 +60,9 @@ vi.mock("@/stores/connectionStore", () => ({
}),
}));
vi.mock("@/lib/backend/api", () => ({
beginManualTransaction: mocks.beginManualTransaction,
commitManualTransaction: mocks.commitManualTransaction,
rollbackManualTransaction: mocks.rollbackManualTransaction,
cancelSqlFileExecution: mocks.cancelSqlFileExecution,
executeSqlFiles: mocks.executeSqlFiles,
listenSqlFileProgress: mocks.listenSqlFileProgress,
@@ -67,7 +74,13 @@ vi.mock("@lucide/vue", () => {
return { Check: Icon, CheckSquare: Icon, ChevronRight: Icon, FileCode: Icon, FolderOpen: Icon, Loader2: Icon, Play: Icon, Square: Icon, X: Icon };
});
vi.mock("@/components/ui/dialog", () => ({
Dialog: passthrough("div"),
Dialog: defineComponent({
emits: ["update:open"],
setup(_, { attrs, slots, emit }) {
mocks.changeDialogOpen = (open) => emit("update:open", open);
return () => h("div", attrs, slots.default?.());
},
}),
DialogFooter: passthrough("div"),
DialogHeader: passthrough("div"),
DialogScrollContent: passthrough("div"),
@@ -124,10 +137,10 @@ function findButton(label: string): HTMLButtonElement {
return button!;
}
async function mountReadyDialog() {
async function mountReadyDialog(onOpenChange = vi.fn()) {
root = document.createElement("div");
document.body.append(root);
app = createApp(SqlFileExecutionDialog, { open: true });
app = createApp(SqlFileExecutionDialog, { open: true, "onUpdate:open": onOpenChange });
app.mount(root);
await vi.waitFor(() => expect(mocks.fetchSqlFileTargetOptions).toHaveBeenCalled());
@@ -190,6 +203,9 @@ beforeEach(() => {
});
}
});
mocks.beginManualTransaction.mockReset().mockResolvedValue("txn-1");
mocks.commitManualTransaction.mockReset().mockResolvedValue(undefined);
mocks.rollbackManualTransaction.mockReset().mockResolvedValue(undefined);
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.inspectSqlFileTables.mockResolvedValue([
{ database: "app", name: "users" },
@@ -212,10 +228,12 @@ beforeEach(() => {
mocks.cancelSqlFileExecution.mockResolvedValue(true);
mocks.refreshDatabaseTreeNode.mockResolvedValue(undefined);
mocks.requestConfirmation.mockResolvedValue(true);
mocks.uuid.mockReturnValueOnce("run-1").mockReturnValueOnce("run-2");
mocks.uuid.mockReset().mockReturnValueOnce("run-1").mockReturnValueOnce("run-2");
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
app?.unmount();
root?.remove();
app = undefined;
@@ -223,6 +241,209 @@ afterEach(() => {
});
describe("SqlFileExecutionDialog retries", () => {
it("allows an ordinary execution to continue in the background and close after completion", async () => {
const onOpenChange = vi.fn();
await mountReadyDialog(onOpenChange);
const gate = deferred();
mocks.executeSqlFiles.mockImplementationOnce(() => gate.promise);
findButton("sqlFile.execute").click();
await vi.waitFor(() => expect(mocks.executeSqlFiles).toHaveBeenCalledOnce());
findButton("sqlFile.runInBackground").click();
await nextTick();
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(mocks.cancelSqlFileExecution).not.toHaveBeenCalled();
mocks.progressHandler?.(progress("run-1", "done"));
gate.resolve();
await vi.waitFor(() => expect(findButton("sqlFile.execute").disabled).toBe(false));
onOpenChange.mockClear();
findButton("common.close").click();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("cancels the active ordinary run and allows a fresh retry", async () => {
await mountReadyDialog();
const gate = deferred();
mocks.executeSqlFiles.mockImplementationOnce(() => gate.promise);
findButton("sqlFile.execute").click();
await vi.waitFor(() => expect(mocks.executeSqlFiles).toHaveBeenCalledOnce());
findButton("sqlFile.cancel").click();
await vi.waitFor(() => expect(mocks.cancelSqlFileExecution).toHaveBeenCalledWith("run-1"));
mocks.progressHandler?.(progress("run-1", "cancelled"));
gate.resolve();
await vi.waitFor(() => expect(root!.textContent).toContain("sqlFile.status.cancelled"));
await vi.waitFor(() => expect(findButton("sqlFile.execute").disabled).toBe(false));
mocks.executeSqlFiles.mockImplementationOnce(async (request) => mocks.progressHandler?.(progress(request.executionId, "done")));
findButton("sqlFile.execute").click();
await vi.waitFor(() => expect(mocks.executeSqlFiles).toHaveBeenCalledTimes(2));
expect(mocks.executeSqlFiles.mock.calls[1]![0].executionId).toBe("run-2");
await vi.waitFor(() => expect(root!.textContent).toContain("sqlFile.status.done"));
});
async function enableManualTransaction() {
await mountReadyDialog();
const label = Array.from(root!.querySelectorAll("label")).find((item) => item.textContent?.includes("toolbar.manualTransaction"));
expect(label).toBeDefined();
label!.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click();
await nextTick();
}
it("keeps successful files pending until an explicit commit and locks file selection", async () => {
await enableManualTransaction();
await completeFirstExecution();
await vi.waitFor(() => expect(findButton("toolbar.commit").disabled).toBe(false));
expect(mocks.beginManualTransaction).toHaveBeenCalledWith("mysql-1", "");
expect(mocks.executeSqlFiles.mock.calls[0]![0]).toMatchObject({ txnSessionId: "txn-1", continueOnError: false, skipRelationalConstraints: false });
expect(mocks.commitManualTransaction).not.toHaveBeenCalled();
expect(mocks.rollbackManualTransaction).not.toHaveBeenCalled();
expect(mocks.refreshDatabaseTreeNode).not.toHaveBeenCalled();
expect(findButton("sqlFile.browse").disabled).toBe(true);
expect(mocks.updateSqlFileTask).toHaveBeenLastCalledWith("run-1", expect.objectContaining({ status: "running", statementSummary: "sqlFile.pendingTransaction" }), expect.anything());
findButton("toolbar.commit").click();
await vi.waitFor(() => expect(findButton("sqlFile.execute").disabled).toBe(false));
expect(mocks.commitManualTransaction).toHaveBeenCalledExactlyOnceWith("txn-1");
expect(mocks.updateSqlFileTask).toHaveBeenLastCalledWith("run-1", expect.objectContaining({ status: "done" }));
});
it("rolls back pending files without marking their task committed", async () => {
await enableManualTransaction();
await completeFirstExecution();
await vi.waitFor(() => expect(findButton("toolbar.rollback").disabled).toBe(false));
const buttons = root!.querySelectorAll("button");
expect(buttons[buttons.length - 1]).toBe(findButton("toolbar.rollback"));
findButton("toolbar.rollback").click();
await vi.waitFor(() => expect(findButton("sqlFile.execute").disabled).toBe(false));
expect(mocks.rollbackManualTransaction).toHaveBeenCalledExactlyOnceWith("txn-1");
expect(mocks.commitManualTransaction).not.toHaveBeenCalled();
expect(mocks.updateSqlFileTask).toHaveBeenLastCalledWith("run-1", expect.objectContaining({ status: "cancelled" }));
});
it("rolls back on execution failure without retrying the file", async () => {
await enableManualTransaction();
mocks.executeSqlFiles.mockRejectedValueOnce(new Error("statement failed"));
findButton("sqlFile.execute").click();
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("txn-1"));
expect(mocks.executeSqlFiles).toHaveBeenCalledTimes(1);
expect(mocks.commitManualTransaction).not.toHaveBeenCalled();
expect(mocks.toast).toHaveBeenCalledWith("statement failed", 5000);
});
it("waits for the active statement to finish before rolling back a cancellation", async () => {
await enableManualTransaction();
const gate = deferred();
mocks.executeSqlFiles.mockImplementationOnce(async (request: { executionId: string }) => {
await gate.promise;
mocks.progressHandler?.(progress(request.executionId, "cancelled"));
});
findButton("sqlFile.execute").click();
await vi.waitFor(() => expect(mocks.executeSqlFiles).toHaveBeenCalled());
findButton("sqlFile.cancel").click();
await vi.waitFor(() => expect(mocks.cancelSqlFileExecution).toHaveBeenCalledWith("run-1"));
expect(mocks.rollbackManualTransaction).not.toHaveBeenCalled();
gate.resolve();
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("txn-1"));
expect(mocks.commitManualTransaction).not.toHaveBeenCalled();
});
it("releases a transaction that begins after the dialog unmounts", async () => {
await enableManualTransaction();
const gate = deferred();
mocks.beginManualTransaction.mockImplementationOnce(async () => {
await gate.promise;
return "late-txn";
});
findButton("sqlFile.execute").click();
await vi.waitFor(() => expect(mocks.beginManualTransaction).toHaveBeenCalled());
app!.unmount();
app = undefined;
gate.resolve();
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("late-txn"));
expect(mocks.executeSqlFiles).not.toHaveBeenCalled();
expect(mocks.updateSqlFileTask).toHaveBeenLastCalledWith("run-1", expect.objectContaining({ status: "cancelled" }));
});
it("rolls back before closing pending files and keeps them open when close is declined", async () => {
await enableManualTransaction();
await completeFirstExecution();
await vi.waitFor(() => expect(findButton("toolbar.commit").disabled).toBe(false));
const confirm = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true);
vi.stubGlobal("confirm", confirm);
mocks.changeDialogOpen!(false);
await nextTick();
expect(confirm).toHaveBeenCalledWith("sqlFile.rollbackBeforeClose");
expect(mocks.rollbackManualTransaction).not.toHaveBeenCalled();
mocks.changeDialogOpen!(false);
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledExactlyOnceWith("txn-1"));
});
it("does not start file execution if cancelled while registering progress", async () => {
await enableManualTransaction();
const gate = deferred();
mocks.listenSqlFileProgress.mockImplementationOnce(async () => {
await gate.promise;
return mocks.unlisten;
});
findButton("sqlFile.execute").click();
await vi.waitFor(() => expect(mocks.listenSqlFileProgress).toHaveBeenCalled());
findButton("sqlFile.cancel").click();
gate.resolve();
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("txn-1"));
expect(mocks.executeSqlFiles).not.toHaveBeenCalled();
expect(mocks.unlisten).toHaveBeenCalled();
});
it("does not allow committing a failed file when cleanup could not be confirmed", async () => {
await enableManualTransaction();
mocks.executeSqlFiles.mockRejectedValueOnce(new Error("file failed"));
mocks.rollbackManualTransaction.mockRejectedValueOnce(new Error("rollback connection lost"));
findButton("sqlFile.execute").click();
await vi.waitFor(() => expect(findButton("toolbar.rollback").disabled).toBe(false));
expect(findButton("toolbar.commit").disabled).toBe(true);
expect(findButton("sqlFile.browse").disabled).toBe(true);
findButton("toolbar.rollback").click();
await vi.waitFor(() => expect(findButton("sqlFile.execute").disabled).toBe(false));
expect(mocks.commitManualTransaction).not.toHaveBeenCalled();
});
it("retains pending controls when commit fails, allowing rollback", async () => {
await enableManualTransaction();
await completeFirstExecution();
await vi.waitFor(() => expect(findButton("toolbar.commit").disabled).toBe(false));
mocks.commitManualTransaction.mockRejectedValueOnce(new Error("connection lost"));
findButton("toolbar.commit").click();
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("connection lost", 5000));
expect(findButton("sqlFile.browse").disabled).toBe(true);
expect(findButton("toolbar.rollback").disabled).toBe(false);
expect(findButton("toolbar.commit").disabled).toBe(true);
findButton("toolbar.rollback").click();
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("txn-1"));
});
it("reports an expired transaction instead of claiming commit succeeded", async () => {
await enableManualTransaction();
await completeFirstExecution();
await vi.waitFor(() => expect(findButton("toolbar.commit").disabled).toBe(false));
mocks.commitManualTransaction.mockRejectedValueOnce(new Error("Transaction session not found"));
findButton("toolbar.commit").click();
await vi.waitFor(() => expect(findButton("sqlFile.execute").disabled).toBe(false));
expect(mocks.toast).toHaveBeenCalledWith("sqlFile.transactionEnded", 5000);
expect(mocks.updateSqlFileTask).toHaveBeenLastCalledWith("run-1", expect.objectContaining({ status: "cancelled" }));
});
it("reports an unknown commit outcome when the response was lost and rollback finds no session", async () => {
await enableManualTransaction();
await completeFirstExecution();
await vi.waitFor(() => expect(findButton("toolbar.commit").disabled).toBe(false));
mocks.commitManualTransaction.mockRejectedValueOnce(new Error("response lost"));
findButton("toolbar.commit").click();
await vi.waitFor(() => expect(findButton("toolbar.commit").disabled).toBe(true));
await vi.waitFor(() => expect(findButton("toolbar.rollback").disabled).toBe(false));
mocks.rollbackManualTransaction.mockRejectedValueOnce(new Error("Transaction session not found"));
findButton("toolbar.rollback").click();
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("toolbar.commitOutcomeUnknown", 5000));
expect(mocks.updateSqlFileTask).toHaveBeenLastCalledWith("run-1", expect.objectContaining({ status: "error" }));
expect(mocks.commitManualTransaction).toHaveBeenCalledTimes(1);
});
it("shows byte-based progress while SQL executes and only completes on a terminal event", async () => {
await mountReadyDialog();
const executionGate = deferred();
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { uuid } from "@/lib/common/utils";
import { useI18n } from "vue-i18n";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
@@ -17,9 +17,11 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { connectionIsEffectivelyReadOnly, ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
import { supportsTransaction } from "@/lib/database/databaseFeatureSupport";
import { formatError, isManualTransactionSessionExpired } from "@/lib/backend/errorUtils";
import { fetchSqlFileTargetOptions } from "@/composables/useDatabaseOptions";
import { requiresSqlFileTargetDatabaseSelection, supportsConnectionLevelDatabaseBootstrap } from "@/lib/connection/connectionLevelDatabaseBootstrap";
import { cancelSqlFileExecution, executeSqlFiles, inspectSqlFileTables, listenSqlFileProgress, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus, type SqlFileTable } from "@/lib/backend/api";
import { beginManualTransaction, commitManualTransaction, rollbackManualTransaction, cancelSqlFileExecution, executeSqlFiles, inspectSqlFileTables, listenSqlFileProgress, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus, type SqlFileTable } from "@/lib/backend/api";
import { buildDisplayFileNames, tooltipText as computeTooltipText } from "./sqlFilePreviewLabel";
import { parseSqlFilePathInput } from "./sqlFilePathInput";
import SqlFileProgressIndicator from "./SqlFileProgressIndicator.vue";
@@ -81,7 +83,7 @@ const pathInput = ref("");
watch(filePathDisplay, (value) => (pathInput.value = value), { immediate: true });
async function commitPathInput() {
if (!isDesktopRuntime || running.value || selectingFile.value || loadingPreview.value) return;
if (!isDesktopRuntime || executionLocked.value || selectingFile.value || loadingPreview.value) return;
const paths = parseSqlFilePathInput(pathInput.value);
if (paths.length === 0 || paths.join("; ") === filePathDisplay.value) {
pathInput.value = filePathDisplay.value;
@@ -114,6 +116,11 @@ const databaseOptions = ref<string[]>([]);
const loadingDatabases = ref(false);
const continueOnError = ref(false);
const skipRelationalConstraints = ref(false);
const manualTransaction = ref(false);
const txnSessionId = ref<string>();
const commitUncertain = ref(false);
const resolvingTransaction = ref(false);
let disposed = false;
const running = ref(false);
const cancelling = ref(false);
@@ -181,6 +188,64 @@ const supportsRelationalConstraintBypass = computed(() => {
});
const selectedConnection = computed(() => sqlConnections.value.find((c) => c.id === connectionId.value));
const canUseManualTransaction = computed(() => supportsTransaction(selectedConnection.value?.db_type));
const executionLocked = computed(() => running.value || resolvingTransaction.value || !!txnSessionId.value);
async function finishTransaction(commit: boolean) {
const sessionId = txnSessionId.value;
if (!sessionId || resolvingTransaction.value || (commit && (terminalStatus.value !== "done" || commitUncertain.value))) return false;
resolvingTransaction.value = true;
try {
let committed = false;
let outcomeUnknown = false;
try {
if (commit) {
await commitManualTransaction(sessionId);
committed = true;
} else await rollbackManualTransaction(sessionId);
} catch (error) {
if (!isManualTransactionSessionExpired(error) && formatError(error) !== "Transaction session not found") {
if (commit) commitUncertain.value = true;
throw error;
}
outcomeUnknown = commitUncertain.value;
if (outcomeUnknown) toast(t("toolbar.commitOutcomeUnknown"), 5000);
else if (commit) toast(t("sqlFile.transactionEnded"), 5000);
}
txnSessionId.value = undefined;
if (terminalStatus.value === "done") {
terminalStatus.value = outcomeUnknown ? "error" : committed ? "done" : "cancelled";
if (progress.value) updateSqlFileTask(executionId.value, { ...progress.value, status: terminalStatus.value });
}
await refreshTargetAfterImport();
return true;
} catch (error: any) {
toast(error?.message || String(error), 5000);
return false;
} finally {
resolvingTransaction.value = false;
}
}
watch(manualTransaction, (enabled) => {
if (enabled) {
continueOnError.value = false;
skipRelationalConstraints.value = false;
}
});
watch(canUseManualTransaction, (supported) => {
if (!supported && !txnSessionId.value) manualTransaction.value = false;
});
onBeforeUnmount(() => {
disposed = true;
if (running.value && manualTransaction.value) {
cancelRequested.value = true;
if (executionStarted.value) void cancelSqlFileExecution(executionId.value).catch(() => {});
}
// The running call releases the session in its finally block; do not race a
// rollback with an active statement. A late begin response is handled below.
if (!running.value && txnSessionId.value) void finishTransaction(false);
});
const restoreSelectedTables = ref(false);
const backupTables = ref<SqlFileTable[]>([]);
@@ -233,7 +298,7 @@ watch([restoreSelectedTables, canSelectTables, () => previews.value[0]?.filePath
const canStart = computed(() => {
const connection = selectedConnection.value;
if (previews.value.length === 0 || !connection || running.value || loadingPreview.value || loadingDatabases.value) return false;
if (previews.value.length === 0 || !connection || executionLocked.value || loadingPreview.value || loadingDatabases.value) return false;
if (restoreSelectedTables.value && (loadingTables.value || tableScanError.value || selectedTables.value.length === 0)) return false;
let hasDatabaseContext = false;
const canExecuteWithoutSelectedDatabase = previews.value.every((item) => {
@@ -303,6 +368,8 @@ function formatElapsed(ms: number) {
}
function statusLabel(status: SqlFileStatus | "idle") {
if (status === "error" && commitUncertain.value) return t("toolbar.commitOutcomeUnknown");
if (status === "done" && txnSessionId.value) return t("sqlFile.pendingTransaction");
return t(`sqlFile.status.${status}`);
}
@@ -349,6 +416,7 @@ function resetState() {
loadingDatabases.value = false;
continueOnError.value = false;
skipRelationalConstraints.value = false;
manualTransaction.value = false;
restoreSelectedTables.value = false;
resetExecution();
}
@@ -399,6 +467,7 @@ async function previewSelectedSqlFile(fileOrPath: string | File) {
}
async function loadPreviews(filesOrPaths: Array<string | File>) {
if (executionLocked.value) return;
loadingPreview.value = true;
previews.value = [];
resetExecution();
@@ -416,7 +485,7 @@ async function loadPreviews(filesOrPaths: Array<string | File>) {
}
async function selectFile() {
if (running.value) return;
if (executionLocked.value) return;
if (!isTauriRuntime()) {
fileInput.value?.click();
return;
@@ -443,7 +512,7 @@ async function handleFileInputChange(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
input.value = "";
if (files.length === 0 || running.value) return;
if (files.length === 0 || executionLocked.value) return;
selectingFile.value = true;
try {
await loadPreviews(files);
@@ -513,6 +582,7 @@ async function startExecution() {
failureDetailsExpanded.value = false;
const taskLabel = previews.value.length === 1 ? previews.value[0]!.fileName : `${previews.value[0]!.fileName} (+${previews.value.length - 1})`;
activeExecutionTask.value = addSqlFileTask(batchId, taskLabel, filePathDisplay.value);
let completedSuccessfully = false;
try {
await store.ensureConnected(connectionId.value);
@@ -521,13 +591,22 @@ async function startExecution() {
return;
}
if (manualTransaction.value) {
const sessionId = await beginManualTransaction(connectionId.value, database.value.trim());
txnSessionId.value = sessionId;
commitUncertain.value = false;
if (disposed || cancelRequested.value) {
terminalStatus.value = "cancelled";
return;
}
}
let resolveTerminalProgress: (progress: SqlFileProgress) => void = () => {};
let rejectTerminalProgress: (error: Error) => void = () => {};
const terminalProgress = new Promise<SqlFileProgress>((resolve, reject) => {
resolveTerminalProgress = resolve;
rejectTerminalProgress = reject;
});
let completedSuccessfully = false;
const unlisten = await listenProgress(
batchId,
(next) => {
@@ -564,7 +643,8 @@ async function startExecution() {
}
}
updateSqlFileTask(batchId, next, {
const taskProgress = txnSessionId.value && next.status === "done" ? { ...next, status: "running" as const, statementSummary: t("sqlFile.pendingTransaction") } : next;
updateSqlFileTask(batchId, taskProgress, {
fileIndex: currentFileIndex.value >= 0 ? currentFileIndex.value : previews.value.length === 1 ? 0 : undefined,
fileName: currentFileName.value || (previews.value.length === 1 ? (displayFileNames.value.get(previews.value[0]!.filePath) ?? previews.value[0]!.fileName) : undefined),
});
@@ -578,6 +658,10 @@ async function startExecution() {
);
try {
if (cancelRequested.value) {
terminalStatus.value = "cancelled";
return;
}
executionStarted.value = true;
const executionPaths = previews.value.flatMap((item) => item.packageFilePaths ?? [item.filePath]);
await executeSqlFiles(
@@ -587,6 +671,7 @@ async function startExecution() {
database: database.value.trim(),
filePath: executionPaths[0]!,
continueOnError: continueOnError.value,
...(txnSessionId.value ? { txnSessionId: txnSessionId.value } : {}),
...(restoreSelectedTables.value ? { selectedTables: selectedTables.value.map((table) => ({ ...table })) } : {}),
partCooldownMs: previews.value.some((item) => item.packageFilePaths) ? 500 : 0,
skipRelationalConstraints: skipRelationalConstraints.value,
@@ -606,7 +691,7 @@ async function startExecution() {
unlisten();
}
if (completedSuccessfully) await refreshTargetAfterImport();
if (completedSuccessfully && !txnSessionId.value) await refreshTargetAfterImport();
} catch (e: any) {
terminalStatus.value = cancelRequested.value ? "cancelled" : "error";
terminalError.value = e?.message || String(e);
@@ -626,6 +711,12 @@ async function startExecution() {
toast(terminalError.value, 5000);
}
} finally {
if (txnSessionId.value && (!completedSuccessfully || disposed || cancelRequested.value)) {
await finishTransaction(false);
}
if (cancelRequested.value && !progress.value) {
updateSqlFileTask(batchId, { executionId: batchId, status: "cancelled", statementIndex: 0, successCount: 0, failureCount: 0, affectedRows: 0, elapsedMs: 0, statementSummary: "" });
}
running.value = false;
cancelling.value = false;
executionStarted.value = false;
@@ -649,7 +740,12 @@ async function cancelExecution() {
}
}
function handleOpenChange(nextOpen: boolean) {
async function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && (resolvingTransaction.value || (running.value && manualTransaction.value))) return;
if (!nextOpen && txnSessionId.value) {
if (!window.confirm(t("sqlFile.rollbackBeforeClose"))) return;
if (!(await finishTransaction(false))) return;
}
open.value = nextOpen;
}
@@ -658,15 +754,19 @@ watch(connectionId, (id) => {
});
watch(sqlConnections, () => {
if (!open.value || running.value || selectedConnection.value) return;
if (!open.value || executionLocked.value || selectedConnection.value) return;
connectionId.value = resolveInitialConnectionId();
});
watch(
open,
(value) => {
if (!value) return;
if (running.value) return;
if (!value) {
if (manualTransaction.value && running.value) void cancelExecution();
else if (txnSessionId.value) void finishTransaction(false);
return;
}
if (running.value || txnSessionId.value || resolvingTransaction.value) return;
resetState();
if (connectionId.value) {
loadDatabasesForConnection(connectionId.value);
@@ -700,9 +800,9 @@ watch(
<div class="flex items-center gap-2">
<input ref="fileInput" type="file" accept=".sql,.sql.gz,.zip,text/sql,application/gzip,application/zip" multiple class="hidden" @change="handleFileInputChange" />
<Input v-if="isDesktopRuntime" v-model="pathInput" :disabled="running" class="h-8 text-xs font-mono" :placeholder="t('sqlFile.selectSqlFile')" @keydown.enter="commitPathInputOnEnter" @blur="commitPathInput" />
<Input v-if="isDesktopRuntime" v-model="pathInput" :disabled="executionLocked" class="h-8 text-xs font-mono" :placeholder="t('sqlFile.selectSqlFile')" @keydown.enter="commitPathInputOnEnter" @blur="commitPathInput" />
<Input v-else :model-value="filePathDisplay" readonly class="h-8 text-xs font-mono" :placeholder="t('sqlFile.selectSqlFile')" />
<Button variant="outline" size="sm" class="h-8 shrink-0" :disabled="running || selectingFile" @click="selectFile">
<Button variant="outline" size="sm" class="h-8 shrink-0" :disabled="executionLocked || selectingFile" @click="selectFile">
<Loader2 v-if="selectingFile || loadingPreview" class="w-3.5 h-3.5 mr-1.5 animate-spin" />
<FolderOpen v-else class="w-3.5 h-3.5 mr-1.5" />
{{ t("sqlFile.browse") }}
@@ -761,7 +861,7 @@ watch(
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="space-y-1.5">
<Label class="text-xs">{{ t("sqlFile.connection") }}</Label>
<Select v-model="connectionId" :disabled="running">
<Select v-model="connectionId" :disabled="executionLocked">
<SelectTrigger class="h-8 text-xs">
<div v-if="connectionId" class="flex items-center gap-1.5 min-w-0">
<DatabaseIcon :db-type="connectionIconType(connectionId)" class="w-3.5 h-3.5 shrink-0" />
@@ -783,7 +883,7 @@ watch(
<div class="space-y-1.5">
<Label class="text-xs">{{ t("sqlFile.database") }}</Label>
<Select v-if="databaseOptions.length" v-model="database" :disabled="running || loadingDatabases">
<Select v-if="databaseOptions.length" v-model="database" :disabled="executionLocked || loadingDatabases">
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="t('sqlFile.selectDatabase')" />
</SelectTrigger>
@@ -792,7 +892,7 @@ watch(
</SelectContent>
</Select>
<div v-else class="relative">
<Input v-model="database" class="h-8 text-xs" :disabled="running || loadingDatabases" :placeholder="t('sqlFile.databasePlaceholder')" />
<Input v-model="database" class="h-8 text-xs" :disabled="executionLocked || loadingDatabases" :placeholder="t('sqlFile.databasePlaceholder')" />
<Loader2 v-if="loadingDatabases" class="absolute right-2 top-2 w-3.5 h-3.5 animate-spin text-muted-foreground" />
</div>
</div>
@@ -802,22 +902,22 @@ watch(
<div v-if="canSelectTables" class="min-w-0 space-y-2.5" data-table-restore>
<Label class="text-xs">{{ t("sqlFile.restoreScope") }}</Label>
<div class="flex items-center gap-4 text-xs">
<label class="flex items-center gap-2"><input v-model="restoreSelectedTables" type="radio" :value="false" :disabled="running" name="sql-file-restore-scope" />{{ t("sqlFile.restoreAll") }}</label>
<label class="flex items-center gap-2"><input v-model="restoreSelectedTables" type="radio" :value="true" :disabled="running" name="sql-file-restore-scope" />{{ t("sqlFile.restoreSelectedTables") }}</label>
<label class="flex items-center gap-2"><input v-model="restoreSelectedTables" type="radio" :value="false" :disabled="executionLocked" name="sql-file-restore-scope" />{{ t("sqlFile.restoreAll") }}</label>
<label class="flex items-center gap-2"><input v-model="restoreSelectedTables" type="radio" :value="true" :disabled="executionLocked" name="sql-file-restore-scope" />{{ t("sqlFile.restoreSelectedTables") }}</label>
</div>
<template v-if="restoreSelectedTables">
<p class="text-xs text-muted-foreground">{{ t("sqlFile.restoreTablesOnly") }}</p>
<div v-if="loadingTables" class="flex items-center gap-2 text-xs" role="status"><Loader2 class="h-3.5 w-3.5 animate-spin" />{{ t("sqlFile.scanningTables") }}</div>
<p v-else-if="tableScanError" class="break-words text-xs text-destructive" role="alert">{{ tableScanError }}</p>
<template v-else>
<Input v-model="tableSearch" :placeholder="t('sqlFile.searchBackupTables')" :aria-label="t('sqlFile.searchBackupTables')" :disabled="running" class="h-8 text-xs" />
<Input v-model="tableSearch" :placeholder="t('sqlFile.searchBackupTables')" :aria-label="t('sqlFile.searchBackupTables')" :disabled="executionLocked" class="h-8 text-xs" />
<div class="flex items-center justify-between gap-3 text-xs">
<label class="flex items-center gap-2"><input type="checkbox" :checked="allFilteredTablesSelected" :disabled="running || filteredBackupTables.length === 0" @change="toggleFilteredTables" />{{ t("sqlFile.selectVisibleTables") }}</label>
<label class="flex items-center gap-2"><input type="checkbox" :checked="allFilteredTablesSelected" :disabled="executionLocked || filteredBackupTables.length === 0" @change="toggleFilteredTables" />{{ t("sqlFile.selectVisibleTables") }}</label>
<span>{{ t("sqlFile.selectedTableCount", { selected: selectedTables.length, total: backupTables.length }) }}</span>
</div>
<div class="max-h-44 overflow-y-auto border rounded-md p-2 text-xs">
<label v-for="table in filteredBackupTables" :key="tableKey(table)" class="flex min-w-0 items-center gap-2 py-1">
<input type="checkbox" :checked="selectedTableKeys.has(tableKey(table))" :disabled="running" @change="toggleTable(table)" />
<input type="checkbox" :checked="selectedTableKeys.has(tableKey(table))" :disabled="executionLocked" @change="toggleTable(table)" />
<span class="min-w-0 break-all">{{ tableLabel(table) }}</span>
</label>
<p v-if="filteredBackupTables.length === 0" class="text-muted-foreground">{{ t("sqlFile.noBackupTables") }}</p>
@@ -831,12 +931,17 @@ watch(
{{ t("sqlFile.options") }}
</div>
<button type="button" class="flex items-center gap-2 text-xs text-left" :disabled="running" @click="continueOnError = !continueOnError">
<label v-if="canUseManualTransaction" class="flex items-center gap-2 text-xs">
<input v-model="manualTransaction" type="checkbox" :disabled="executionLocked" />
{{ t("toolbar.manualTransaction") }}
</label>
<p v-if="manualTransaction" class="text-xs text-muted-foreground">{{ t("sqlFile.manualTransactionHint") }}</p>
<button type="button" class="flex items-center gap-2 text-xs text-left" :disabled="executionLocked || manualTransaction" @click="continueOnError = !continueOnError">
<CheckSquare v-if="continueOnError" class="w-3.5 h-3.5 text-primary shrink-0" />
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
{{ t("sqlFile.continueOnError") }}
</button>
<button v-if="supportsRelationalConstraintBypass" type="button" class="flex items-center gap-2 text-xs text-left" :disabled="running" @click="skipRelationalConstraints = !skipRelationalConstraints">
<button v-if="supportsRelationalConstraintBypass" type="button" class="flex items-center gap-2 text-xs text-left" :disabled="executionLocked || manualTransaction" @click="skipRelationalConstraints = !skipRelationalConstraints">
<CheckSquare v-if="skipRelationalConstraints" class="w-3.5 h-3.5 text-primary shrink-0" />
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
{{ t("sqlFile.skipRelationalConstraints") }}
@@ -968,17 +1073,24 @@ watch(
<DialogFooter class="shrink-0">
<template v-if="running">
<Button variant="outline" size="sm" @click="open = false">
<p v-if="manualTransaction" class="mr-auto text-xs text-muted-foreground">{{ t("sqlFile.manualCancelHint") }}</p>
<Button v-else variant="outline" size="sm" @click="handleOpenChange(false)">
{{ t("sqlFile.runInBackground") }}
</Button>
<Button variant="destructive" size="sm" :disabled="cancelling" @click="cancelExecution">
<Button variant="destructive" size="sm" class="w-32" :disabled="cancelling" @click="cancelExecution">
<Loader2 v-if="cancelling" class="w-3.5 h-3.5 mr-1.5 animate-spin" />
<X v-else class="w-3.5 h-3.5 mr-1.5" />
{{ cancelling ? t("sqlFile.cancelling") : t("sqlFile.cancel") }}
</Button>
</template>
<template v-else-if="txnSessionId">
<p class="mr-auto text-xs text-muted-foreground">{{ t("sqlFile.pendingTransaction") }}</p>
<!-- Preserve the whole cancel hit area when execution finishes. -->
<Button size="sm" :disabled="resolvingTransaction || commitUncertain || terminalStatus !== 'done'" @click="finishTransaction(true)">{{ t("toolbar.commit") }}</Button>
<Button variant="outline" size="sm" class="w-32" :disabled="resolvingTransaction" @click="finishTransaction(false)">{{ t("toolbar.rollback") }}</Button>
</template>
<template v-else>
<Button variant="outline" size="sm" @click="open = false">
<Button variant="outline" size="sm" @click="handleOpenChange(false)">
{{ t("common.close") }}
</Button>
<Button size="sm" :disabled="!canStart" @click="startExecution">