feat(oracle): hide commit/rollback for clean manual-transaction state

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-08-27 11:30:27 +08:00
1 parent d2326acb81
commit bd05c0350d
13 files changed
+729 -22

No files matched your search

+8
View File
@@ -341,6 +341,12 @@ const activeConnection = computed(() => {
return tab ? connectionStore.getConfig(tab.connectionId) : undefined;
});
// Oracle manual-mode indicator derived from the RESOLVED database type (an
// Oracle connection uses the agent runtime but reports db_type "oracle"), so
// the toolbar's Commit/Rollback visibility can apply the Oracle dirty-state rule
// without inferring Oracle from the raw transport type.
const isOracleManualTransaction = computed(() => effectiveDatabaseTypeForConnection(activeConnection.value) === "oracle" && (activeTab.value?.autoCommit ?? true) === false);
function updateAgentDriverUpdateCount(count: number) {
if (!settingsStore.editorSettings.updateNotificationsEnabled) {
agentDriverUpdateCount.value = 0;
@@ -3039,6 +3045,8 @@ onUnmounted(() => {
:auto-commit="activeTab.autoCommit ?? true"
:txn-session-id="activeTab?.txnSessionId"
:txn-auto-rolled-back="activeTab?.txnAutoRolledBack"
:oracle-txn-possibly-dirty="activeTab?.oracleTxnPossiblyDirty"
:is-oracle-manual-transaction="isOracleManualTransaction"
@update:explain-mode="(m: 'explain' | 'autotrace') => (explainMode = m)"
@update:block-dangerous-redis-commands="(v: boolean) => (blockDangerousRedisCommands = v)"
@update:auto-commit="
@@ -34,6 +34,12 @@ const props = defineProps<{
autoCommit?: boolean;
txnSessionId?: string;
txnAutoRolledBack?: boolean;
/** Oracle-only: whether the current manual Oracle session executed a statement
* DBX cannot prove read-only. Commit/Rollback are hidden while false. */
oracleTxnPossiblyDirty?: boolean;
/** Oracle manual mode derived from the resolved database type (not raw
* db_type, which can be the agent transport). */
isOracleManualTransaction?: boolean;
}>();
const emit = defineEmits<{
@@ -153,6 +159,12 @@ function toggleSqlSemanticDiagnostics() {
const isTransactionActive = computed(() => !!props.txnSessionId);
const isManualTransactionMode = computed(() => props.autoCommit === false || isTransactionActive.value);
const transactionModeBadge = computed(() => (isManualTransactionMode.value ? "M" : "A"));
// Oracle manual mode hides Commit/Rollback while the session is clean (no
// unproven statement executed). Every other database keeps the existing rule.
const showTxnActions = computed(() => {
if (props.isOracleManualTransaction) return isTransactionActive.value && props.oracleTxnPossiblyDirty === true;
return isTransactionActive.value;
});
const transactionTooltip = computed(() => {
const isAgent = (props.activeConnection?.db_type as string) === "agent";
const isManual = isManualTransactionMode.value;
@@ -430,8 +442,8 @@ async function changeCatalog(selectedCatalog: string) {
</TooltipTrigger>
<TooltipContent>{{ transactionTooltip }}</TooltipContent>
</Tooltip>
<!-- Commit button (only when transaction is active) -->
<Tooltip v-if="isTransactionActive">
<!-- Commit button (only when a transaction action is warranted) -->
<Tooltip v-if="showTxnActions">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-green-600 hover:bg-green-500/10 hover:text-green-700 dark:text-green-300 dark:hover:text-green-200" :disabled="activeTab.isExecuting" :aria-label="t('toolbar.commit')" @click="emit('commit')">
<Check class="h-3.5 w-3.5" />
@@ -440,8 +452,8 @@ async function changeCatalog(selectedCatalog: string) {
<TooltipContent>{{ t("toolbar.commit") }}</TooltipContent>
</Tooltip>
<!-- Rollback button (only when transaction is active) -->
<Tooltip v-if="isTransactionActive">
<!-- Rollback button (only when a transaction action is warranted) -->
<Tooltip v-if="showTxnActions">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-red-600 hover:bg-red-500/10 hover:text-red-700 dark:text-red-300 dark:hover:text-red-200" :disabled="activeTab.isExecuting" :aria-label="t('toolbar.rollback')" @click="emit('rollback')">
<RotateCcw class="h-3.5 w-3.5" />
@@ -31,4 +31,13 @@ describe("EditorToolbar transaction mode control", () => {
expect(rollbackIndex).toBeGreaterThan(commitIndex);
expect(actionGroupEndIndex).toBeGreaterThan(rollbackIndex);
});
it("hides Commit/Rollback for a clean Oracle manual session only", () => {
expect(toolbarSource).toContain("const showTxnActions = computed(() => {");
expect(toolbarSource).toContain("if (props.isOracleManualTransaction) return isTransactionActive.value && props.oracleTxnPossiblyDirty === true;");
expect(toolbarSource).toContain("return isTransactionActive.value;");
// Both buttons are driven by the combined condition.
expect(toolbarSource).toContain('<Tooltip v-if="showTxnActions">');
expect(toolbarSource.match(/<Tooltip v-if="showTxnActions">/g)).toHaveLength(2);
});
});
+1 -1
View File
@@ -1253,7 +1253,7 @@ export async function beginManualTransaction(_connectionId: string, _database: s
throw new Error("Manual transaction management is only available in the desktop app.");
}
export async function executeInManualTransaction(_txnSessionId: string, _sql: string, _database: string, _schema?: string, _maxRows?: number, _tableDataPreview?: boolean, _pageSize?: number, _resultSessionId?: string): Promise<QueryResult[]> {
export async function executeInManualTransaction(_txnSessionId: string, _sql: string, _database: string, _schema?: string, _maxRows?: number, _tableDataPreview?: boolean, _pageSize?: number, _resultSessionId?: string, _classificationSql?: string): Promise<QueryResult[]> {
throw new Error("Manual transaction management is only available in the desktop app.");
}
+2 -1
View File
@@ -1482,7 +1482,7 @@ export async function beginManualTransaction(connectionId: string, database: str
return invoke("begin_manual_transaction", { connectionId, database, schema, catalog });
}
export async function executeInManualTransaction(txnSessionId: string, sql: string, database: string, schema?: string, maxRows?: number, tableDataPreview?: boolean, pageSize?: number, resultSessionId?: string): Promise<QueryResult[]> {
export async function executeInManualTransaction(txnSessionId: string, sql: string, database: string, schema?: string, maxRows?: number, tableDataPreview?: boolean, pageSize?: number, resultSessionId?: string, classificationSql?: string): Promise<QueryResult[]> {
return invoke("execute_in_manual_transaction", {
txnSessionId,
sql,
@@ -1492,6 +1492,7 @@ export async function executeInManualTransaction(txnSessionId: string, sql: stri
tableDataPreview,
pageSize,
resultSessionId,
classificationSql,
});
}
@@ -844,7 +844,7 @@ describe("queryStore hidden primary key editing", () => {
await execution;
expect(beginManualTransaction).toHaveBeenCalledWith("oracle-1", "ORCL", undefined, undefined);
expect(executeInManualTransaction).toHaveBeenCalledWith("txn-1", "SELECT t.* FROM APP.WIDE_TABLE t", "ORCL", undefined, expect.any(Number), true);
expect(executeInManualTransaction).toHaveBeenCalledWith("txn-1", "SELECT t.* FROM APP.WIDE_TABLE t", "ORCL", undefined, expect.any(Number), true, undefined, undefined, "SELECT t.* FROM APP.WIDE_TABLE t");
expect(store.tabs.find((tab) => tab.id === tabId)?.result?.large_value_cells).toEqual([{ row_index: 0, column_index: 1, original_bytes: 81920 }]);
});
@@ -890,7 +890,7 @@ describe("queryStore hidden primary key editing", () => {
await store.executeTabSql(tabId, sql);
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.autoCommit).toBe(false);
expect(executeInManualTransaction).toHaveBeenNthCalledWith(1, "txn-1", sql, "ORCL", undefined, expect.any(Number), false, 100, undefined);
expect(executeInManualTransaction).toHaveBeenNthCalledWith(1, "txn-1", sql, "ORCL", undefined, expect.any(Number), false, 100, undefined, sql);
expect(tab.result?.rows).toHaveLength(100);
await store.executeTabSql(tabId, sql, {
@@ -902,7 +902,7 @@ describe("queryStore hidden primary key editing", () => {
replaceActiveResultInGroup: true,
});
expect(executeInManualTransaction).toHaveBeenNthCalledWith(2, "txn-1", sql, "ORCL", undefined, expect.any(Number), false, 100, "oracle-go-1");
expect(executeInManualTransaction).toHaveBeenNthCalledWith(2, "txn-1", sql, "ORCL", undefined, expect.any(Number), false, 100, "oracle-go-1", undefined);
expect(tab.result?.rows).toHaveLength(200);
expect(tab.result?.rows[100]).toEqual([101]);
});
@@ -959,7 +959,7 @@ describe("queryStore hidden primary key editing", () => {
await store.executeTabSql(tabId, "SELECT name FROM users");
expect(executeInManualTransaction).toHaveBeenCalledWith("txn-1", "SELECT name, `id` AS `__DBX_PK_0` FROM users", "app", undefined, expect.any(Number), false);
expect(executeInManualTransaction).toHaveBeenCalledWith("txn-1", "SELECT name, `id` AS `__DBX_PK_0` FROM users", "app", undefined, expect.any(Number), false, undefined, undefined, undefined);
});
it("keeps a keyless Oracle query editable when its WHERE clause reads another table", async () => {
@@ -0,0 +1,343 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
analyzeEditableQueryEditability: vi.fn(),
beginManualTransaction: vi.fn(),
closeClientConnectionSession: vi.fn(),
closeQuerySession: vi.fn(),
commitManualTransaction: vi.fn(),
executeInManualTransaction: vi.fn(),
executeMulti: vi.fn(),
getConnectionConfig: vi.fn(),
prepareQueryPaginationExecutionPlan: vi.fn(),
rollbackManualTransaction: vi.fn(),
saveOpenTabsState: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => ({
analyzeEditableQueryEditability: mocks.analyzeEditableQueryEditability,
beginManualTransaction: mocks.beginManualTransaction,
closeClientConnectionSession: mocks.closeClientConnectionSession,
closeQuerySession: mocks.closeQuerySession,
commitManualTransaction: mocks.commitManualTransaction,
executeInManualTransaction: mocks.executeInManualTransaction,
executeMulti: mocks.executeMulti,
prepareQueryPaginationExecutionPlan: mocks.prepareQueryPaginationExecutionPlan,
rollbackManualTransaction: mocks.rollbackManualTransaction,
saveOpenTabsState: mocks.saveOpenTabsState,
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
ensureConnected: vi.fn().mockResolvedValue(undefined),
getConfig: mocks.getConnectionConfig,
recordConnectionLostError: vi.fn(),
}),
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({
editorSettings: {
autoCalculateTotalRows: false,
continueOnErrorOnBatch: false,
pageSize: 100,
queryResultMaxRowsEnabled: false,
queryResultMaxRows: 1000,
openTabsRestoreMode: "all",
confirmUnsavedSqlClose: false,
},
}),
}));
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
removeItem: vi.fn((key: string) => data.delete(key)),
});
}
type OracleResult = Record<string, unknown>;
function cleanSelect(): OracleResult[] {
return [{ columns: ["VALUE"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, manual_transaction_proven_read_only: true }];
}
function dirtyUpdate(): OracleResult[] {
return [{ columns: [], rows: [], affected_rows: 1, execution_time_ms: 1 }];
}
function noOpResult(): OracleResult[] {
return [{ columns: [], rows: [], affected_rows: 0, execution_time_ms: 1, manual_transaction_no_statement: true }];
}
function expiredTransactionError() {
return {
version: 1 as const,
code: "DBX-TXN-1001",
messageKey: "backendErrors.transaction.sessionExpired",
messageParams: { timeoutSecs: 300 },
source: "legacyBackend" as const,
operationOutcome: "not_started" as const,
origin: { subsystem: "database", adapter: "native" },
diagnostics: { category: "transaction", stage: "execute" },
};
}
describe("queryStore Oracle manual-transaction sticky state", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
mocks.getConnectionConfig.mockReturnValue({
id: "oracle-1",
name: "Oracle",
db_type: "oracle",
database: "ORCL",
query_timeout_secs: 30,
});
mocks.prepareQueryPaginationExecutionPlan.mockImplementation(async (options) => ({
sqlToExecute: options.sql,
pageSql: undefined,
pageLimit: undefined,
pageOffset: undefined,
countSql: undefined,
useAgentResultSession: false,
}));
mocks.analyzeEditableQueryEditability.mockResolvedValue({ editable: false, reason: "not-select" });
mocks.saveOpenTabsState.mockResolvedValue(undefined);
});
async function setupManualTab(store: { createTab: (c: string, d: string, t: string, m: string, s: string) => string }, _sql = "SELECT * FROM EMP") {
mocks.beginManualTransaction.mockResolvedValue("txn-oracle");
const tabId = store.createTab("oracle-1", "ORCL", "Query", "query", "APP");
store.setAutoCommit(tabId, false);
return tabId;
}
it("sends classificationSql only for the initial Oracle manual execution", async () => {
mocks.executeInManualTransaction.mockResolvedValue(cleanSelect());
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = await setupManualTab(store);
await store.executeTabSql(tabId, "SELECT * FROM EMP");
expect(mocks.executeInManualTransaction).toHaveBeenCalledOnce();
const args = mocks.executeInManualTransaction.mock.calls[0]!;
expect(args[0]).toBe("txn-oracle");
// classificationSql is the last argument of the non-cursor branch.
expect(args.at(-1)).toBe("SELECT * FROM EMP");
});
it("keeps a clean Oracle manual session clean after a proven simple SELECT", async () => {
mocks.executeInManualTransaction.mockResolvedValue(cleanSelect());
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = await setupManualTab(store);
await store.executeTabSql(tabId, "SELECT * FROM EMP");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.oracleTxnPossiblyDirty).not.toBe(true);
expect(tab.txnSessionId).toBe("txn-oracle");
});
it("dirties an Oracle manual session after an UPDATE", async () => {
mocks.executeInManualTransaction.mockResolvedValue(dirtyUpdate());
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = await setupManualTab(store);
await store.executeTabSql(tabId, "UPDATE EMP SET DEPTNO = 10");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.oracleTxnPossiblyDirty).toBe(true);
});
it("never clears the sticky state after a later read (UPDATE -> SELECT)", async () => {
mocks.executeInManualTransaction.mockResolvedValueOnce(dirtyUpdate()).mockResolvedValueOnce(cleanSelect());
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = await setupManualTab(store);
await store.executeTabSql(tabId, "UPDATE EMP SET DEPTNO = 10");
await store.executeTabSql(tabId, "SELECT * FROM EMP");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.oracleTxnPossiblyDirty).toBe(true);
});
it("dirties the session for any unproven statement in a mixed script", async () => {
mocks.executeInManualTransaction.mockResolvedValue([
{ columns: ["VALUE"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, manual_transaction_proven_read_only: true },
{ columns: [], rows: [], affected_rows: 1, execution_time_ms: 1 },
]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = await setupManualTab(store);
await store.executeTabSql(tabId, "SELECT * FROM DUAL; DELETE FROM EMP");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.oracleTxnPossiblyDirty).toBe(true);
});
it("treats a Core no-op result as neither dirty nor clean-changing", async () => {
mocks.executeInManualTransaction.mockResolvedValueOnce(noOpResult()).mockResolvedValueOnce(noOpResult());
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = await setupManualTab(store);
// Empty script keeps dirty undefined; then a real clean select still stays clean.
await store.executeTabSql(tabId, " -- only a comment\n");
await store.executeTabSql(tabId, "SELECT * FROM EMP");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.oracleTxnPossiblyDirty).not.toBe(true);
});
it("passes no classificationSql and changes no sticky state on a cursor-page fetch", async () => {
mocks.executeInManualTransaction.mockResolvedValueOnce(dirtyUpdate()).mockResolvedValueOnce(cleanSelect());
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = await setupManualTab(store);
await store.executeTabSql(tabId, "UPDATE EMP SET DEPTNO = 10");
// Simulate a later page fetch of a clean result.
mocks.executeInManualTransaction.mockClear();
mocks.executeInManualTransaction.mockResolvedValue(cleanSelect());
await store.executeTabSql(tabId, "SELECT * FROM EMP", {
pagination: { sessionId: "cursor-1", clientSessionId: "client-1", limit: 100, offset: 100 },
});
const args = mocks.executeInManualTransaction.mock.calls[0]!;
// Page fetch carries no classificationSql (last argument), so it cannot
// change the sticky bit.
expect(args.at(-1)).toBeUndefined();
const tab = store.tabs.find((item) => item.id === tabId)!;
// Page fetch must neither set nor clear the dirty bit.
expect(tab.oracleTxnPossiblyDirty).toBe(true);
});
it("resets the sticky state on session-expiry recovery and recomputes from the retry", async () => {
mocks.beginManualTransaction.mockResolvedValueOnce("txn-old").mockResolvedValueOnce("txn-new");
mocks.executeInManualTransaction.mockResolvedValueOnce(dirtyUpdate()).mockRejectedValueOnce(expiredTransactionError()).mockResolvedValueOnce(cleanSelect());
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = await setupManualTab(store);
await store.executeTabSql(tabId, "UPDATE EMP SET DEPTNO = 10");
await store.executeTabSql(tabId, "SELECT * FROM EMP");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.txnSessionId).toBe("txn-new");
expect(tab.oracleTxnPossiblyDirty).not.toBe(true);
});
it("clears the sticky state together with the session on DBX rollback", async () => {
mocks.executeInManualTransaction.mockResolvedValue(dirtyUpdate());
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = await setupManualTab(store);
await store.executeTabSql(tabId, "UPDATE EMP SET DEPTNO = 10");
await store.rollbackTransaction(tabId);
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.txnSessionId).toBeUndefined();
expect(tab.oracleTxnPossiblyDirty).not.toBe(true);
});
it("clears session and sticky state when a statement failure disposes the manual session", async () => {
mocks.executeInManualTransaction.mockResolvedValueOnce(dirtyUpdate()).mockRejectedValueOnce(new Error("Statement 1 failed: ORA-02292 integrity constraint violated. The manual transaction was rolled back."));
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = await setupManualTab(store);
await store.executeTabSql(tabId, "UPDATE EMP SET DEPTNO = 10");
await store.executeTabSql(tabId, "DELETE FROM EMP WHERE DEPTNO = 99");
const tab = store.tabs.find((item) => item.id === tabId)!;
// The `rolled back` fragment is the cleanup compatibility contract: the
// backend removed the manual session, so both fields must reset with it.
expect(tab.txnSessionId).toBeUndefined();
expect(tab.oracleTxnPossiblyDirty).not.toBe(true);
expect(tab.txnAutoRolledBack).not.toBe(true);
});
it("dirties a clean session when a frontend timeout rejects while the manual session survives", async () => {
mocks.executeInManualTransaction.mockRejectedValueOnce(new Error("Query timed out after 30 seconds"));
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = await setupManualTab(store);
await store.executeTabSql(tabId, "UPDATE EMP SET DEPTNO = 10");
const tab = store.tabs.find((item) => item.id === tabId)!;
// The statement may still have executed server-side while the manual
// session survives, so the sticky state must stay fail-closed dirty.
expect(tab.txnSessionId).toBe("txn-oracle");
expect(tab.oracleTxnPossiblyDirty).toBe(true);
});
it("keeps a clean session clean when a cursor-page fetch times out", async () => {
mocks.executeInManualTransaction.mockResolvedValueOnce(cleanSelect()).mockRejectedValueOnce(new Error("Query timed out after 30 seconds"));
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = await setupManualTab(store);
await store.executeTabSql(tabId, "SELECT * FROM EMP");
await store.executeTabSql(tabId, "SELECT * FROM EMP", {
pagination: { sessionId: "cursor-1", clientSessionId: "client-1", limit: 100, offset: 100 },
});
const tab = store.tabs.find((item) => item.id === tabId)!;
// Page fetches never participate in the sticky state, matching aggregation.
expect(tab.txnSessionId).toBe("txn-oracle");
expect(tab.oracleTxnPossiblyDirty).not.toBe(true);
});
it("leaves non-Oracle manual sessions using the old rule and no marker handling", async () => {
mocks.getConnectionConfig.mockReturnValue({
id: "pg-1",
name: "Postgres",
db_type: "postgres",
database: "postgres",
query_timeout_secs: 30,
});
mocks.beginManualTransaction.mockResolvedValue("txn-pg");
// A Postgres manual execution never returns the Oracle marker.
mocks.executeInManualTransaction.mockResolvedValue([{ columns: [], rows: [], affected_rows: 0, execution_time_ms: 1 }]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("pg-1", "postgres", "Query", "query", "public");
store.setAutoCommit(tabId, false);
await store.executeTabSql(tabId, "SELECT 1");
const tab = store.tabs.find((item) => item.id === tabId)!;
// No classificationSql was sent and no Oracle sticky state exists.
const args = mocks.executeInManualTransaction.mock.calls[0]!;
expect(args.at(-1)).toBeUndefined();
expect(tab.oracleTxnPossiblyDirty).toBeUndefined();
expect(tab.txnSessionId).toBe("txn-pg");
});
});
+54 -7
View File
@@ -3243,18 +3243,36 @@ export const useQueryStore = defineStore("query", () => {
if (tab.txnSessionId) {
void rollbackTransaction(id);
} else {
tab.txnAutoRolledBack = false;
clearManualTransactionSession(tab);
}
}
}
}
/** Reset only the Oracle sticky-dirty bit. Used when a session continues but
* the old dirty state must be discarded (e.g. idle-expiry recovery where the
* replacement session starts fresh). Full session cleanup goes through
* `clearManualTransactionSession`. */
function clearOracleTxnPossiblyDirty(tab: { oracleTxnPossiblyDirty?: boolean }) {
if (tab.oracleTxnPossiblyDirty !== undefined) tab.oracleTxnPossiblyDirty = false;
}
/** Centralized manual-session cleanup. Clears every field tied to a manual
* transaction session exactly when that session is conclusively ended or
* discarded. Callers must not assign these fields individually. */
function clearManualTransactionSession(tab: QueryTab) {
tab.txnSessionId = undefined;
tab.txnAutoRolledBack = false;
if (tab.oracleTxnPossiblyDirty !== undefined) tab.oracleTxnPossiblyDirty = false;
}
function rollbackTabTransaction(tab: QueryTab, options?: { resetAutoCommit?: boolean }) {
if (tab.txnSessionId) void rollbackTransaction(tab.id);
if (options?.resetAutoCommit) {
const dbType = useConnectionStore().getConfig(tab.connectionId)?.db_type;
tab.autoCommit = defaultAutoCommitForDbType(dbType);
}
clearOracleTxnPossiblyDirty(tab);
tab.txnAutoRolledBack = false;
}
@@ -3264,8 +3282,7 @@ export const useQueryStore = defineStore("query", () => {
try {
await api.commitManualTransaction(tab.txnSessionId);
} finally {
tab.txnSessionId = undefined;
tab.txnAutoRolledBack = false;
clearManualTransactionSession(tab);
}
}
@@ -3275,8 +3292,7 @@ export const useQueryStore = defineStore("query", () => {
try {
await api.rollbackManualTransaction(tab.txnSessionId);
} finally {
tab.txnSessionId = undefined;
tab.txnAutoRolledBack = false;
clearManualTransactionSession(tab);
}
}
@@ -5242,19 +5258,26 @@ export const useQueryStore = defineStore("query", () => {
}
queryExecutionLog("info", "execute-in-txn:invoke", { traceId, txnSessionId: tab.txnSessionId, elapsed: elapsed() });
executionDispatched = true;
// Only an initial manual execution classifies the user SQL (Oracle-only).
// A later cursor-page fetch must neither set nor clear the sticky bit.
const isInitialOracleManualExecution = effectiveDbType === "oracle" && !options?.pagination?.sessionId;
const classificationSql = isInitialOracleManualExecution ? queryBaseSql : undefined;
let manualTransactionRecoveryAttempted = false;
executionPromise = (async () => {
const txnSessionId = tab.txnSessionId;
if (!txnSessionId) throw new Error("Manual transaction session was not initialized");
const executeInTransaction = (sessionId: string) =>
useAgentResultSession
? api.executeInManualTransaction(sessionId, sqlToExecute, executionDatabase, executionSchema, agentProtocolQueryResultMaxRows(queryResultMaxRows), useOracleLobPreview, pageLimit, options?.pagination?.sessionId)
: api.executeInManualTransaction(sessionId, sqlToExecute, executionDatabase, executionSchema, pageLimit ?? agentProtocolQueryResultMaxRows(queryResultMaxRows), useOracleLobPreview);
? api.executeInManualTransaction(sessionId, sqlToExecute, executionDatabase, executionSchema, agentProtocolQueryResultMaxRows(queryResultMaxRows), useOracleLobPreview, pageLimit, options?.pagination?.sessionId, classificationSql)
: api.executeInManualTransaction(sessionId, sqlToExecute, executionDatabase, executionSchema, pageLimit ?? agentProtocolQueryResultMaxRows(queryResultMaxRows), useOracleLobPreview, undefined, undefined, classificationSql);
try {
return await executeInTransaction(txnSessionId);
} catch (error) {
if (options?.pagination?.sessionId || manualTransactionRecoveryAttempted || !isManualTransactionSessionExpired(error)) throw error;
manualTransactionRecoveryAttempted = true;
// The expired session was discarded by the backend; the replacement
// session starts fresh, so the old sticky state resets with it.
clearOracleTxnPossiblyDirty(tab);
tab.txnSessionId = undefined;
tab.txnAutoRolledBack = true;
queryExecutionLog("info", "manual-txn:expired-recover", { traceId, elapsed: elapsed() });
@@ -5325,6 +5348,18 @@ export const useQueryStore = defineStore("query", () => {
const annotatedResults = annotateQueryResultSources(markQueryResultsRowsRaw(await withFrontendQueryTimeout(executionPromise, frontendTimeoutSecs, t("editor.queryTimeoutError", { seconds: frontendTimeoutSecs }))), queryBaseSql, sourceLabelDatabase, effectiveDbType, options?.sourceOffset);
const results = offsetBatchQueryResultIndexes(annotatedResults.results, batchResume?.startStatementIndex ?? 0);
reconcileBatchSqlResults(tab, executionId, results);
// Oracle-only sticky state aggregation. Only the initial manual execution
// participates: a later cursor-page fetch (pagination.sessionId present)
// must neither set nor clear the bit, and the Core no-op (empty script)
// must neither set nor clear it. Otherwise any result that is not proven
// read-only dirties the session monotonically.
if (tab.autoCommit === false && effectiveDbType === "oracle" && !options?.pagination?.sessionId && tab.txnSessionId) {
const rawResults = annotatedResults.results;
const isCoreNoOp = rawResults.length > 0 && rawResults.every((result) => result.manual_transaction_no_statement === true);
if (!isCoreNoOp && rawResults.some((result) => result.manual_transaction_proven_read_only !== true)) {
tab.oracleTxnPossiblyDirty = true;
}
}
const successfulOracleSchemaChanges = effectiveDbType === "oracle" ? results.filter((result) => result.execution_error !== true && isOracleCurrentSchemaStatement(result.sourceStatement)).length : 0;
const successfulSapHanaSchemaChanges = effectiveDbType === "saphana" ? results.filter((result) => result.execution_error !== true && isSapHanaSetSchemaStatement(result.sourceStatement)).length : 0;
const sqlServerUseDatabase = effectiveDbType === "sqlserver" ? annotatedResults.sqlServerUseDatabase : undefined;
@@ -5532,11 +5567,23 @@ export const useQueryStore = defineStore("query", () => {
const errMsg: string = e?.message ?? String(e);
const idleTimeout = /5 minutes of inactivity/i.test(errMsg) || errMsg.includes("5 分钟无操作") || errMsg.includes("已自动回滚");
if (idleTimeout) {
// Backend session was removed and rolled back after idle expiry: clear
// the sticky dirty state together with the session.
clearOracleTxnPossiblyDirty(tab);
tab.txnSessionId = undefined;
tab.txnAutoRolledBack = true;
} else if (/rolled.?back/i.test(errMsg) || /transaction session not found/i.test(errMsg) || /agent runtime terminated/i.test(errMsg)) {
// Statement failure that disposed the manual session: the `rolled back`
// message fragment is a frontend cleanup compatibility contract.
clearOracleTxnPossiblyDirty(tab);
tab.txnSessionId = undefined;
tab.txnAutoRolledBack = false;
} else if (tab.txnSessionId && executionDispatched && !options?.pagination?.sessionId && effectiveDatabaseTypeForConnection(useConnectionStore().getConfig(tab.connectionId)) === "oracle") {
// Frontend timeout/cancel or mid-script failure: the statement may still
// have executed server-side while the manual session survives, so keep
// the sticky dirty state fail-closed instead of a clean toolbar on a
// dirty session. Cursor-page fetches stay excluded like the aggregation.
tab.oracleTxnPossiblyDirty = true;
}
}
const current = findExecutionTab(id);
+11
View File
@@ -646,6 +646,13 @@ export interface QueryResult {
execution_error?: true;
/** Set only for SQL Server informational messages emitted by the backend. */
server_message?: true;
/** Oracle-only manual-transaction UX marker: set on a manual-transaction result
* whose statement DBX proved to be an ordinary top-level read. Absent for
* every non-Oracle execution and every unproven Oracle statement. */
manual_transaction_proven_read_only?: true;
/** Oracle-only manual-transaction UX marker: set on the synthetic successful
* result of an empty/whitespace/comments-only manual script. */
manual_transaction_no_statement?: true;
/** Structured backend error; authoritative when execution_error is true. */
error?: BackendError;
/** Zero-based index of the submitted statement that produced this result. */
@@ -1311,6 +1318,10 @@ export interface QueryTab {
txnSessionId?: string;
/** Set to true when a manual transaction was auto-rolled back due to inactivity */
txnAutoRolledBack?: boolean;
/** Oracle-only, non-persisted: whether the current manual Oracle session has
* executed at least one statement DBX cannot prove read-only. Commit/Rollback
* actions are hidden while a session is clean. Never cleared by a later read. */
oracleTxnPossiblyDirty?: boolean;
}
export interface SavedSqlFolder {