feat(transactions): add manual transactions to more execution paths
This commit is contained in:
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") }}
|
||||
|
||||
+156
@@ -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");
|
||||
});
|
||||
});
|
||||
+231
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
+8
@@ -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" });
|
||||
|
||||
|
||||
+225
-4
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user