From a924285136568c693ad45c455fa0286ee39dca12 Mon Sep 17 00:00:00 2001 From: efeire Date: Sun, 20 Sep 2026 11:07:11 +0800 Subject: [PATCH] feat(transactions): add manual transactions to more execution paths --- apps/desktop/src/App.vue | 8 +- .../src/components/diff/DataCompareDialog.vue | 178 +++++++++++--- .../DataCompareDialog.prefill.spec.ts | 156 ++++++++++++ .../editor/MultiDbExecuteDialog.vue | 67 ++++- .../src/components/editor/QueryHistory.vue | 129 ++++++++-- .../__tests__/MultiDbExecuteDialog.spec.ts | 94 +++++++ .../QueryHistory.manualTransaction.spec.ts | 231 ++++++++++++++++++ .../src/components/layout/EditorToolbar.vue | 2 +- .../EditorToolbar.transactionMode.spec.ts | 8 + .../SqlFileExecutionDialog.retry.spec.ts | 229 ++++++++++++++++- .../sql-file/SqlFileExecutionDialog.vue | 168 ++++++++++--- .../__tests__/useMultiDbExecution.spec.ts | 110 +++++++++ .../useSqlExecutionManualMultiDb.spec.ts | 203 +++++++++++++++ .../src/composables/useMultiDbExecution.ts | 78 +++++- .../src/composables/useSqlExecution.ts | 178 +++++++++++--- apps/desktop/src/i18n/locales/az.ts | 13 + apps/desktop/src/i18n/locales/en.ts | 27 ++ apps/desktop/src/i18n/locales/es.ts | 28 +++ apps/desktop/src/i18n/locales/it.ts | 27 ++ apps/desktop/src/i18n/locales/ja.ts | 27 ++ apps/desktop/src/i18n/locales/ko.ts | 27 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 27 ++ apps/desktop/src/i18n/locales/tr.ts | 13 + apps/desktop/src/i18n/locales/zh-CN.ts | 27 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 27 ++ apps/desktop/src/lib/backend/tauri.ts | 1 + apps/desktop/src/stores/queryStore.ts | 5 + apps/desktop/src/types/sqlExecution.ts | 10 +- crates/dbx-core/src/data/sql_file_import.rs | 173 ++++++++++++- crates/dbx-core/src/query/mod.rs | 164 ++++++++++++- crates/dbx-core/tests/live_mysql57.rs | 3 + .../tests/live_mysql_database_export.rs | 3 + .../live_oceanbase_sql_file_transaction.rs | 139 +++++++++++ .../tests/live_postgres_all_schema_export.rs | 1 + .../tests/live_sqlserver_completion.rs | 1 + crates/dbx-drivers/src/db/agent_driver.rs | 26 +- crates/dbx-sql/src/sql.rs | 3 + .../app-tests/sqlFileExecutionDialog.test.ts | 6 - 38 files changed, 2471 insertions(+), 146 deletions(-) create mode 100644 apps/desktop/src/components/editor/__tests__/MultiDbExecuteDialog.spec.ts create mode 100644 apps/desktop/src/components/editor/__tests__/QueryHistory.manualTransaction.spec.ts create mode 100644 apps/desktop/src/composables/__tests__/useSqlExecutionManualMultiDb.spec.ts create mode 100644 crates/dbx-core/tests/live_oceanbase_sql_file_transaction.rs diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 0ff5535d05..2e334f41fd 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -963,6 +963,7 @@ function requestActiveEditorPreviewChanges() { const multiExecuteDatabaseType = ref(); const multiExecuteInitialTargets = ref>([]); const multiExecuteLaunchId = ref(0); +const multiExecuteManualTransaction = ref(false); // Launch-time input only. MultiDbExecuteDialog copies this into its immutable // batch context before the first target starts; execution never reads this ref. const multiExecuteSourceOffset = ref(); @@ -972,7 +973,7 @@ function multiExecuteTargetLabel(target: { connectionId: string; catalog?: strin return [connection?.name || target.connectionId, target.catalog, target.database, target.schema].filter((value) => value !== undefined && value !== "").join(" / "); } -async function executeMultiDbTarget(input: { target: { connectionId: string; catalog?: string; database: string; schema?: string }; sourceTabId: string; sql: string; scopeId: string; context: { sourceOffset?: number }; isCancellationRequested: () => boolean }) { +async function executeMultiDbTarget(input: { target: { connectionId: string; catalog?: string; database: string; schema?: string }; sourceTabId: string; sql: string; scopeId: string; context: { sourceOffset?: number; manualTransaction?: boolean }; isCancellationRequested: () => boolean }) { const tab = queryStore.tabs.find((candidate) => candidate.id === input.sourceTabId); const connection = connectionStore.getConfig(input.target.connectionId); if (!tab || !connection) return { status: "failed" as const, errorMessage: t("multiDbExecute.targetMissingConnection") }; @@ -987,6 +988,7 @@ async function executeMultiDbTarget(input: { target: { connectionId: string; cat target: input.target, }, sourceOffset: input.context.sourceOffset, + manualTransaction: input.context.manualTransaction, blockDangerousRedisCommands: blockDangerousRedisCommands.value, targetLabel: multiExecuteTargetLabel(input.target), scopeId: input.scopeId, @@ -1006,7 +1008,7 @@ function cancelPendingMultiDbTarget(scopeId: string): void { async function requestMultiDbExecute() { const tab = activeTab.value; - if (!tab || !activeConnection.value || showMultiDbExecuteDialog.value) return; + if (!tab || !activeConnection.value || showMultiDbExecuteDialog.value || tab.txnSessionId) return; const sourceTabId = tab.id; const sourceConnection = connectionStore.getConfig(tab.connectionId) ?? activeConnection.value; const sourceTarget = normalizeSqlExecutionTarget(sourceConnection, { @@ -1020,6 +1022,7 @@ async function requestMultiDbExecute() { multiExecuteSourceOffset.value = sourceOffset; multiExecuteLaunchId.value += 1; multiExecuteSourceTabId.value = sourceTabId; + multiExecuteManualTransaction.value = tab.autoCommit === false; multiExecuteDatabaseType.value = effectiveDatabaseTypeForConnection(sourceConnection); multiExecuteInitialTargets.value = [sourceTarget]; showMultiDbExecuteDialog.value = true; @@ -4388,6 +4391,7 @@ onUnmounted(() => { :initial-targets="multiExecuteInitialTargets" :launch-id="multiExecuteLaunchId" :execute-target="executeMultiDbTarget" + :initial-manual-transaction="multiExecuteManualTransaction" :cancel-target="cancelMultiDbTarget" :cancel-pending="cancelPendingMultiDbTarget" :source-offset="multiExecuteSourceOffset" diff --git a/apps/desktop/src/components/diff/DataCompareDialog.vue b/apps/desktop/src/components/diff/DataCompareDialog.vue index 21344d527c..8683f4fbf8 100644 --- a/apps/desktop/src/components/diff/DataCompareDialog.vue +++ b/apps/desktop/src/components/diff/DataCompareDialog.vue @@ -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(); +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 { + 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); });