fix(sidebar): make destructive table operation cancellation reliable
This commit is contained in:
1 parent
7ce992b8f8
commit
b4015a4393
20 files changed
+481
-140
No files matched your search
@@ -1594,6 +1594,10 @@ function openSidebarContextMenu(event: MouseEvent, node: TreeNode, openContextMe
|
||||
}
|
||||
|
||||
function openSidebarDangerDialog(request: SidebarDangerDialogRequest) {
|
||||
if (sidebarDangerRunningExecutionId.value) {
|
||||
toast(t("contextMenu.dangerOperationAlreadyRunning"), 4000);
|
||||
return;
|
||||
}
|
||||
sidebarDangerDialogRequest.value = request;
|
||||
sidebarDangerDialogConfirming.value = false;
|
||||
// Defense in depth: sidebarDangerDialogCancelling is a singleton shared
|
||||
|
||||
@@ -70,7 +70,6 @@ import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
|
||||
import type { ColumnInfo, DatabaseType, TreeNode, TreeNodeType } from "@/types/database";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
|
||||
import { connectionUsesVisibleSchemaFilter } from "@/lib/database/visibleDatabases";
|
||||
import { canTreeNodePin, canTreeNodeShowExpander } from "@/lib/sidebar/sidebarTreeItemLayout";
|
||||
@@ -2524,13 +2523,25 @@ function openRenameObjectDialog() {
|
||||
async function executeTreeNodeSqlWithProductionGuard(
|
||||
node: Pick<TreeNode, "connectionId" | "database" | "schema">,
|
||||
sql: string,
|
||||
options: { database?: string; schema?: string; executeAsScript?: boolean; executionId?: string; isCancelledBeforeDispatch?: () => boolean; markDispatched?: () => void } = {},
|
||||
options: {
|
||||
database?: string;
|
||||
schema?: string;
|
||||
executeAsScript?: boolean;
|
||||
executionId?: string;
|
||||
isCancelledBeforeDispatch?: () => boolean;
|
||||
markDispatched?: () => void;
|
||||
} = {},
|
||||
) {
|
||||
if (!node.connectionId) return undefined;
|
||||
const database = options.database ?? node.database ?? "";
|
||||
const config = connectionStore.getConfig(node.connectionId);
|
||||
// Always resolve the connection's configured timeout the same way the
|
||||
// main editor does — independent of whether this call also wires up
|
||||
// Cancel-button UI. Gating this behind an opt-in flag previously left
|
||||
// every non-danger-dialog caller falling back to the backend's hardcoded
|
||||
// 30s default instead of the user's configured (or unlimited) timeout.
|
||||
const timeoutSecs = queryTimeoutSecsForConnection(config, settingsStore.editorSettings.globalQueryTimeoutSecs);
|
||||
const executionId = options.executionId ?? uuid();
|
||||
const executionId = options.executionId;
|
||||
return executeWithProductionSqlGuard({
|
||||
connection: config,
|
||||
database,
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const connectionTreeSource = readFileSync(new URL("../ConnectionTree.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("ConnectionTree danger dialog guard", () => {
|
||||
it("refuses to open a new danger dialog while a previous danger operation is still running", () => {
|
||||
expect(connectionTreeSource).toMatch(/function openSidebarDangerDialog\(request: SidebarDangerDialogRequest\) \{\s*if \(sidebarDangerRunningExecutionId\.value\) \{\s*toast\(t\("contextMenu\.dangerOperationAlreadyRunning"\), \d+\);\s*return;\s*\}/);
|
||||
});
|
||||
});
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("../SidebarTreeRuntimeHost.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("executeTreeNodeSqlWithProductionGuard executionId", () => {
|
||||
it("only registers a backend execution id for callers that opt in, instead of generating one for every caller", () => {
|
||||
expect(source).toContain("const executionId = options.executionId;");
|
||||
expect(source).not.toMatch(/executionId\s*=\s*options\.executionId\s*\?\?\s*uuid\(\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resetSidebarTreeDialogState, sidebarDangerRunningCancel, sidebarDangerRunningExecutionId } from "../sidebarTreeDialogState";
|
||||
|
||||
describe("resetSidebarTreeDialogState", () => {
|
||||
it("clears the danger-running singleton", () => {
|
||||
sidebarDangerRunningExecutionId.value = "exec-1";
|
||||
sidebarDangerRunningCancel.value = () => {};
|
||||
|
||||
resetSidebarTreeDialogState();
|
||||
|
||||
expect(sidebarDangerRunningExecutionId.value).toBe("");
|
||||
expect(sidebarDangerRunningCancel.value).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -241,6 +241,8 @@ export function resetSidebarTreeDialogState() {
|
||||
sidebarTreeDialogOwner.value = null;
|
||||
sidebarDangerTarget.value = null;
|
||||
sidebarFormTarget.value = null;
|
||||
sidebarDangerRunningExecutionId.value = "";
|
||||
sidebarDangerRunningCancel.value = null;
|
||||
connectionDeleteTargetSnapshot.value = [];
|
||||
connectionGroupDeleteTargetSnapshot.value = [];
|
||||
deleteConnectionsWithGroup.value = false;
|
||||
|
||||
+87
@@ -259,6 +259,40 @@ describe("useSidebarTableMutationRuntime SAP HANA schema-scoped actions", () =>
|
||||
expect(sidebarDangerRunningCancel.value).toBeNull();
|
||||
});
|
||||
|
||||
it("waits for an in-flight cancel confirmation before classifying a cancelled execution", async () => {
|
||||
const { feature } = runtime("");
|
||||
let signalCancelStarted: () => void = () => {};
|
||||
let resolveCancel: (confirmed: boolean) => void = () => {};
|
||||
const cancelStarted = new Promise<void>((resolve) => {
|
||||
signalCancelStarted = resolve;
|
||||
});
|
||||
const cancelResult = new Promise<boolean>((resolve) => {
|
||||
resolveCancel = resolve;
|
||||
});
|
||||
mocks.cancelQuery.mockImplementationOnce(() => {
|
||||
signalCancelStarted();
|
||||
return cancelResult;
|
||||
});
|
||||
mocks.executeWithProductionGuard.mockImplementationOnce(async (_node: unknown, _sql: unknown, options: { markDispatched: () => void }) => {
|
||||
options.markDispatched();
|
||||
void sidebarDangerRunningCancel.value?.();
|
||||
throw new Error("Query cancelled");
|
||||
});
|
||||
|
||||
const operationPromise = feature.confirmEmptyTable();
|
||||
await cancelStarted;
|
||||
|
||||
expect(mocks.toast).not.toHaveBeenCalledWith("contextMenu.tableOperationCancelUnconfirmed", 8000);
|
||||
resolveCancel(true);
|
||||
await operationPromise;
|
||||
|
||||
expect(mocks.toast).toHaveBeenCalledWith("contextMenu.tableOperationCancelled", 3000);
|
||||
expect(mocks.toast).not.toHaveBeenCalledWith("contextMenu.tableOperationCancelUnconfirmed", 8000);
|
||||
expect(mocks.toast).not.toHaveBeenCalledWith("contextMenu.tableOperationCancelPending", 6000);
|
||||
expect(sidebarDangerRunningExecutionId.value).toBe("");
|
||||
expect(sidebarDangerRunningCancel.value).toBeNull();
|
||||
});
|
||||
|
||||
it("never dispatches the SQL when the user cancels before the operation is sent to the database (registration race)", async () => {
|
||||
const { feature } = runtime("");
|
||||
mocks.ensureConnected.mockImplementationOnce(async () => {
|
||||
@@ -291,6 +325,10 @@ describe("useSidebarTableMutationRuntime SAP HANA schema-scoped actions", () =>
|
||||
|
||||
// Bounded retry, not a single silently-ignored attempt.
|
||||
expect(mocks.cancelQuery.mock.calls.length).toBeGreaterThan(1);
|
||||
// The cancel handler itself surfaces feedback the moment its own
|
||||
// confirmation window gives up (before the underlying operation later
|
||||
// settles and reports tableOperationCancelUnconfirmed).
|
||||
expect(mocks.toast).toHaveBeenCalledWith("contextMenu.tableOperationCancelPending", 6000);
|
||||
expect(mocks.toast).toHaveBeenCalledWith("contextMenu.tableOperationCancelUnconfirmed", 8000);
|
||||
expect(mocks.toast).not.toHaveBeenCalledWith("contextMenu.tableOperationCancelled", 3000);
|
||||
expect(mocks.toast).not.toHaveBeenCalledWith("contextMenu.tableOperationFailed", 5000);
|
||||
@@ -340,10 +378,59 @@ describe("useSidebarTableMutationRuntime SAP HANA schema-scoped actions", () =>
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
// A single hung attempt must not block the remaining retries — each
|
||||
// attempt gets its own bounded slice of the overall retry budget.
|
||||
expect(mocks.cancelQuery.mock.calls.length).toBeGreaterThan(1);
|
||||
// The closure above must have settled instead of hanging — a stuck
|
||||
// cancel handle would otherwise permanently disable the shared
|
||||
// sidebarDangerDialogCancelling state for every future danger dialog.
|
||||
expect(mocks.toast).not.toHaveBeenCalledWith("contextMenu.tableOperationCancelled", 3000);
|
||||
// Settling silently is not enough on its own: the user must be told the
|
||||
// cancel could not be confirmed instead of the button just going quiet.
|
||||
expect(mocks.toast).toHaveBeenCalledWith("contextMenu.tableOperationCancelPending", 6000);
|
||||
// The operation may genuinely still be running server-side, so the
|
||||
// running-execution state must not be torn down just because the
|
||||
// confirmation window elapsed.
|
||||
expect(sidebarDangerRunningExecutionId.value).not.toBe("");
|
||||
});
|
||||
|
||||
it("confirms the cancel when the first backend response arrives after its own per-attempt timeout", async () => {
|
||||
const { feature } = runtime("");
|
||||
mocks.executeWithProductionGuard.mockImplementationOnce(async (_node: unknown, _sql: unknown, options: { markDispatched: () => void }) => {
|
||||
options.markDispatched();
|
||||
throw new Error("Query timed out after 60 seconds");
|
||||
});
|
||||
|
||||
await feature.confirmEmptyTable();
|
||||
mocks.toast.mockClear();
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let resolveFirstAttempt: (confirmed: boolean) => void = () => {};
|
||||
const firstAttempt = new Promise<boolean>((resolve) => {
|
||||
resolveFirstAttempt = resolve;
|
||||
});
|
||||
mocks.cancelQuery.mockImplementationOnce(() => firstAttempt);
|
||||
// By the time a retry is fired, the backend has already removed the
|
||||
// execution id (the first attempt is the one that actually cancelled
|
||||
// it), so any further attempt can only ever answer false.
|
||||
mocks.cancelQuery.mockResolvedValue(false);
|
||||
|
||||
const cancelPromise = sidebarDangerRunningCancel.value?.();
|
||||
// Let the first attempt's own 2s per-attempt timeout elapse without an
|
||||
// answer, so the loop moves on to firing a retry.
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
// The first attempt now succeeds, 0.5s after its own timeout fired —
|
||||
// it must still be observed instead of being discarded.
|
||||
resolveFirstAttempt(true);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await cancelPromise;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
expect(mocks.toast).toHaveBeenCalledWith("contextMenu.tableOperationCancelled", 3000);
|
||||
expect(sidebarDangerRunningExecutionId.value).toBe("");
|
||||
});
|
||||
|
||||
it.each(actions)("does not report a false success for $action when the user declines the production-safety confirmation", async ({ action }) => {
|
||||
|
||||
@@ -258,25 +258,74 @@ export function useSidebarTableMutationRuntime(options: SidebarTableMutationRunt
|
||||
// danger dialog's Cancel Query button (sidebarDangerDialogCancelling is a
|
||||
// shared singleton — see ConnectionTree.vue).
|
||||
const CANCEL_QUERY_OVERALL_TIMEOUT_MS = 10_000;
|
||||
// Each attempt gets an even share of the overall budget, so a single hung
|
||||
// api.cancelQuery call can't block the remaining retries — it can only
|
||||
// ever stop *waiting* on that attempt, not actually abort it (neither
|
||||
// backend transport plumbs an AbortSignal through cancelQuery).
|
||||
const CANCEL_QUERY_PER_ATTEMPT_TIMEOUT_MS = Math.floor(CANCEL_QUERY_OVERALL_TIMEOUT_MS / CANCEL_QUERY_MAX_ATTEMPTS);
|
||||
|
||||
async function confirmCancelWithRetry(executionId: string): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < CANCEL_QUERY_MAX_ATTEMPTS; attempt++) {
|
||||
let confirmed = false;
|
||||
try {
|
||||
confirmed = await api.cancelQuery(executionId);
|
||||
} catch {
|
||||
confirmed = false;
|
||||
}
|
||||
if (confirmed) return true;
|
||||
// Once an attempt's own per-attempt timeout elapses, the loop below
|
||||
// moves on to firing the next attempt without waiting for it — but it
|
||||
// must keep observing that attempt's eventual result instead of just
|
||||
// dropping the promise. A retry fired after the timeout hits the
|
||||
// backend *after* it already removed the execution id on a first
|
||||
// attempt that merely answered late, so every subsequent retry can only
|
||||
// ever return false; discarding the first attempt's own (eventually
|
||||
// `true`) result would make the whole call report an unconfirmed
|
||||
// cancel even though the backend did cancel it.
|
||||
let confirmed = false;
|
||||
let settledAttempts = 0;
|
||||
let totalAttempts = 0;
|
||||
let notifyNextSettle: (() => void) | undefined;
|
||||
|
||||
function trackAttempt(attemptPromise: Promise<boolean>) {
|
||||
totalAttempts += 1;
|
||||
void attemptPromise.then((result) => {
|
||||
settledAttempts += 1;
|
||||
if (result) confirmed = true;
|
||||
notifyNextSettle?.();
|
||||
});
|
||||
}
|
||||
return false;
|
||||
|
||||
for (let attempt = 0; attempt < CANCEL_QUERY_MAX_ATTEMPTS && !confirmed; attempt++) {
|
||||
trackAttempt(api.cancelQuery(executionId).catch(() => false));
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeoutId = setTimeout(resolve, CANCEL_QUERY_PER_ATTEMPT_TIMEOUT_MS);
|
||||
notifyNextSettle = () => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// All attempts have been fired; wait for any still-outstanding ones so
|
||||
// a slow-but-successful cancel is not lost just because it settled
|
||||
// after its own attempt's pacing timeout.
|
||||
while (!confirmed && settledAttempts < totalAttempts) {
|
||||
await new Promise<void>((resolve) => {
|
||||
notifyNextSettle = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
return confirmed;
|
||||
}
|
||||
|
||||
function confirmCancelWithRetryAndTimeout(executionId: string): Promise<boolean> {
|
||||
// Races the retry loop against a UI-facing deadline, but also returns the
|
||||
// retry loop's own promise so a caller can keep observing it after the
|
||||
// race settles — otherwise a retry that is still running when the timeout
|
||||
// wins would have its eventual result silently discarded.
|
||||
function confirmCancelWithRetryAndTimeout(executionId: string): { confirmedWithinTimeout: Promise<boolean>; retryPromise: Promise<boolean> } {
|
||||
const retryPromise = confirmCancelWithRetry(executionId);
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
const timeout = new Promise<boolean>((resolve) => {
|
||||
setTimeout(() => resolve(false), CANCEL_QUERY_OVERALL_TIMEOUT_MS);
|
||||
timeoutId = setTimeout(() => resolve(false), CANCEL_QUERY_OVERALL_TIMEOUT_MS);
|
||||
});
|
||||
return Promise.race([confirmCancelWithRetry(executionId), timeout]);
|
||||
// Once the retry loop itself settles, the timeout has nothing left to
|
||||
// race against — clear it instead of leaving it to fire (and resolve an
|
||||
// already-settled promise) up to CANCEL_QUERY_OVERALL_TIMEOUT_MS later.
|
||||
void retryPromise.finally(() => clearTimeout(timeoutId));
|
||||
return { confirmedWithinTimeout: Promise.race([retryPromise, timeout]), retryPromise };
|
||||
}
|
||||
|
||||
const DANGER_OPERATION_CANCELLED_BEFORE_DISPATCH_MESSAGE = "Operation cancelled before it was sent to the database.";
|
||||
@@ -287,6 +336,7 @@ export function useSidebarTableMutationRuntime(options: SidebarTableMutationRunt
|
||||
markDispatched: () => void;
|
||||
wasCancelled: () => boolean;
|
||||
cancelConfirmed: () => boolean;
|
||||
waitForCancelConfirmation: () => Promise<boolean>;
|
||||
markHandedOff: () => void;
|
||||
}
|
||||
|
||||
@@ -295,22 +345,70 @@ export function useSidebarTableMutationRuntime(options: SidebarTableMutationRunt
|
||||
let dispatched = false;
|
||||
let cancelledByUser = false;
|
||||
let cancelConfirmedFlag = false;
|
||||
let cancelConfirmationPromise: Promise<boolean> | null = null;
|
||||
// Set once the owning confirm*Table() call has already given up waiting
|
||||
// (a client-observed timeout) and deferred final cleanup to a later
|
||||
// confirmed cancel — see markHandedOff below.
|
||||
let handedOff = false;
|
||||
|
||||
// Shared by both the immediate (raced) outcome and a later-arriving
|
||||
// retry result so a confirmed cancel is finalized exactly once, however
|
||||
// late it arrives.
|
||||
function finalizeConfirmedCancel() {
|
||||
cancelConfirmedFlag = true;
|
||||
if (handedOff && sidebarDangerRunningExecutionId.value === executionId) {
|
||||
toast(t("contextMenu.tableOperationCancelled", { name: nodeLabel }), 3000);
|
||||
endDangerRunningExecution();
|
||||
}
|
||||
}
|
||||
|
||||
sidebarDangerRunningExecutionId.value = executionId;
|
||||
sidebarDangerRunningCancel.value = async () => {
|
||||
cancelledByUser = true;
|
||||
// Nothing has reached the backend yet: the pending dispatch will see
|
||||
// isCancelledBeforeDispatch() and skip the network call entirely, so
|
||||
// this is already a confirmed cancellation.
|
||||
const confirmed = dispatched ? await confirmCancelWithRetryAndTimeout(executionId) : true;
|
||||
if (confirmed) cancelConfirmedFlag = true;
|
||||
if (handedOff && confirmed && sidebarDangerRunningExecutionId.value === executionId) {
|
||||
toast(t("contextMenu.tableOperationCancelled", { name: nodeLabel }), 3000);
|
||||
endDangerRunningExecution();
|
||||
if (!dispatched) {
|
||||
finalizeConfirmedCancel();
|
||||
return;
|
||||
}
|
||||
if (!cancelConfirmationPromise) {
|
||||
cancelConfirmationPromise = (async () => {
|
||||
const { confirmedWithinTimeout, retryPromise } = confirmCancelWithRetryAndTimeout(executionId);
|
||||
// Do not discard the losing side of the race: if the retry loop is
|
||||
// still going when the UI timeout wins below and it later succeeds,
|
||||
// still finalize the confirmed cancel instead of dropping the result.
|
||||
void retryPromise.then((eventuallyConfirmed) => {
|
||||
if (eventuallyConfirmed) finalizeConfirmedCancel();
|
||||
});
|
||||
const confirmed = await confirmedWithinTimeout;
|
||||
if (confirmed) {
|
||||
finalizeConfirmedCancel();
|
||||
} else if (sidebarDangerRunningExecutionId.value === executionId) {
|
||||
// The confirmation window elapsed with no answer from the database.
|
||||
// The operation may genuinely still be running server-side, so leave
|
||||
// sidebarDangerRunningExecutionId / the running-execution state
|
||||
// intact — the user can retry Cancel or just wait for it to settle
|
||||
// — but don't leave the Cancel button silently usable again with
|
||||
// zero explanation of what happened.
|
||||
//
|
||||
// Only surface this if the execution is still the one being tracked:
|
||||
// by the time this arrives, confirmDropTable/confirmEmptyTable/
|
||||
// confirmTruncateTable may have already resolved (success or an
|
||||
// unrelated failure) and moved on, in which case this stale warning
|
||||
// would contradict the outcome the user already saw.
|
||||
toast(t("contextMenu.tableOperationCancelPending", { name: nodeLabel }), 6000);
|
||||
}
|
||||
return confirmed;
|
||||
})();
|
||||
}
|
||||
const currentConfirmation = cancelConfirmationPromise;
|
||||
try {
|
||||
await currentConfirmation;
|
||||
} finally {
|
||||
if (cancelConfirmationPromise === currentConfirmation) {
|
||||
cancelConfirmationPromise = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -322,6 +420,7 @@ export function useSidebarTableMutationRuntime(options: SidebarTableMutationRunt
|
||||
},
|
||||
wasCancelled: () => cancelledByUser,
|
||||
cancelConfirmed: () => cancelConfirmedFlag,
|
||||
waitForCancelConfirmation: () => cancelConfirmationPromise ?? Promise.resolve(cancelConfirmedFlag),
|
||||
markHandedOff: () => {
|
||||
handedOff = true;
|
||||
},
|
||||
@@ -345,31 +444,36 @@ export function useSidebarTableMutationRuntime(options: SidebarTableMutationRunt
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDropTable() {
|
||||
const node = sidebarDangerTarget.value ?? activeNode.value;
|
||||
interface DangerOperationConfig {
|
||||
node: TreeNode;
|
||||
buildSql: () => string | Promise<string>;
|
||||
onSuccess: (node: TreeNode) => void | Promise<void>;
|
||||
}
|
||||
|
||||
async function runDangerOperation(config: DangerOperationConfig) {
|
||||
const { node, buildSql, onSuccess } = config;
|
||||
if (!node.connectionId || node.database == null) return;
|
||||
const { executionId, isCancelledBeforeDispatch, markDispatched, wasCancelled, cancelConfirmed, markHandedOff } = beginDangerRunningExecution(node.label);
|
||||
const { executionId, isCancelledBeforeDispatch, markDispatched, wasCancelled, cancelConfirmed, waitForCancelConfirmation, markHandedOff } = beginDangerRunningExecution(node.label);
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const sql = dropTablePreviewSql.value || (await buildDropTableSql(tableAdminSqlOptionsForNode(node, { cascade: dropTableCascade.value && supportsDropTableCascade(databaseTypeForNode(node)) })));
|
||||
const sql = await buildSql();
|
||||
if (isCancelledBeforeDispatch()) throw new Error(DANGER_OPERATION_CANCELLED_BEFORE_DISPATCH_MESSAGE);
|
||||
const executed = await options.executeWithProductionGuard(node, sql, { database: node.database, schema: node.schema, executionId, isCancelledBeforeDispatch, markDispatched });
|
||||
if (executed === undefined) {
|
||||
// The user declined the production-safety confirmation: the SQL was
|
||||
// never sent, so this must not be reported as a successful drop.
|
||||
// never sent, so this must not be reported as a successful operation.
|
||||
endDangerRunningExecution();
|
||||
return;
|
||||
}
|
||||
toast(t("contextMenu.dropTableSuccess", { name: node.label }), 3000);
|
||||
options.closeDroppedTableObjectTabsForNode(node);
|
||||
connectionStore.removeTreeNode(node.id);
|
||||
options.releaseActiveNodeReference([node.id]);
|
||||
await onSuccess(node);
|
||||
endDangerRunningExecution();
|
||||
} catch (error: any) {
|
||||
const message = error?.message || String(error);
|
||||
const backendError = normalizeBackendError(error) ?? undefined;
|
||||
toastDangerOperationError(node.label, message, wasCancelled(), cancelConfirmed(), backendError);
|
||||
if (cancelConfirmed() || !isQueryTimeoutErrorMessage(message, backendError)) {
|
||||
const cancellationWasRequested = wasCancelled();
|
||||
const cancellationWasConfirmed = cancelConfirmed() || (cancellationWasRequested && (await waitForCancelConfirmation()));
|
||||
toastDangerOperationError(node.label, message, cancellationWasRequested, cancellationWasConfirmed, backendError);
|
||||
if (cancellationWasConfirmed || !isQueryTimeoutErrorMessage(message, backendError)) {
|
||||
endDangerRunningExecution();
|
||||
} else {
|
||||
markHandedOff();
|
||||
@@ -377,6 +481,20 @@ export function useSidebarTableMutationRuntime(options: SidebarTableMutationRunt
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDropTable() {
|
||||
const node = sidebarDangerTarget.value ?? activeNode.value;
|
||||
await runDangerOperation({
|
||||
node,
|
||||
buildSql: () => dropTablePreviewSql.value || buildDropTableSql(tableAdminSqlOptionsForNode(node, { cascade: dropTableCascade.value && supportsDropTableCascade(databaseTypeForNode(node)) })),
|
||||
onSuccess: (node) => {
|
||||
toast(t("contextMenu.dropTableSuccess", { name: node.label }), 3000);
|
||||
options.closeDroppedTableObjectTabsForNode(node);
|
||||
connectionStore.removeTreeNode(node.id);
|
||||
options.releaseActiveNodeReference([node.id]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function emptyTable() {
|
||||
void refreshEmptyTablePreviewSql();
|
||||
showEmptyTableConfirm.value = true;
|
||||
@@ -384,33 +502,15 @@ export function useSidebarTableMutationRuntime(options: SidebarTableMutationRunt
|
||||
|
||||
async function confirmEmptyTable() {
|
||||
const node = sidebarDangerTarget.value ?? activeNode.value;
|
||||
if (!node.connectionId || node.database == null) return;
|
||||
const { executionId, isCancelledBeforeDispatch, markDispatched, wasCancelled, cancelConfirmed, markHandedOff } = beginDangerRunningExecution(node.label);
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const sql = emptyTablePreviewSql.value || (await buildEmptyTableSql(tableAdminSqlOptionsForNode(node)));
|
||||
if (isCancelledBeforeDispatch()) throw new Error(DANGER_OPERATION_CANCELLED_BEFORE_DISPATCH_MESSAGE);
|
||||
const executed = await options.executeWithProductionGuard(node, sql, { database: node.database, schema: node.schema, executionId, isCancelledBeforeDispatch, markDispatched });
|
||||
if (executed === undefined) {
|
||||
// The user declined the production-safety confirmation: the SQL was
|
||||
// never sent, so this must not be reported as a successful empty.
|
||||
endDangerRunningExecution();
|
||||
return;
|
||||
}
|
||||
const messageKey = databaseTypeForNode(node) === "clickhouse" ? "contextMenu.emptyTableSubmitted" : "contextMenu.emptyTableSuccess";
|
||||
toast(t(messageKey, { name: node.label }), 3000);
|
||||
await options.refreshMutatedTableDataTabsForNode(node);
|
||||
endDangerRunningExecution();
|
||||
} catch (error: any) {
|
||||
const message = error?.message || String(error);
|
||||
const backendError = normalizeBackendError(error) ?? undefined;
|
||||
toastDangerOperationError(node.label, message, wasCancelled(), cancelConfirmed(), backendError);
|
||||
if (cancelConfirmed() || !isQueryTimeoutErrorMessage(message, backendError)) {
|
||||
endDangerRunningExecution();
|
||||
} else {
|
||||
markHandedOff();
|
||||
}
|
||||
}
|
||||
await runDangerOperation({
|
||||
node,
|
||||
buildSql: () => emptyTablePreviewSql.value || buildEmptyTableSql(tableAdminSqlOptionsForNode(node)),
|
||||
onSuccess: async (node) => {
|
||||
const messageKey = databaseTypeForNode(node) === "clickhouse" ? "contextMenu.emptyTableSubmitted" : "contextMenu.emptyTableSuccess";
|
||||
toast(t(messageKey, { name: node.label }), 3000);
|
||||
await options.refreshMutatedTableDataTabsForNode(node);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function truncateTable() {
|
||||
@@ -421,32 +521,14 @@ export function useSidebarTableMutationRuntime(options: SidebarTableMutationRunt
|
||||
|
||||
async function confirmTruncateTable() {
|
||||
const node = sidebarDangerTarget.value ?? activeNode.value;
|
||||
if (!node.connectionId || node.database == null) return;
|
||||
const { executionId, isCancelledBeforeDispatch, markDispatched, wasCancelled, cancelConfirmed, markHandedOff } = beginDangerRunningExecution(node.label);
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const sql = truncateTablePreviewSql.value || (await buildTruncateTableSql(tableAdminSqlOptionsForNode(node, { cascade: truncateTableCascade.value && supportsTruncateTableCascade(databaseTypeForNode(node)) })));
|
||||
if (isCancelledBeforeDispatch()) throw new Error(DANGER_OPERATION_CANCELLED_BEFORE_DISPATCH_MESSAGE);
|
||||
const executed = await options.executeWithProductionGuard(node, sql, { database: node.database, schema: node.schema, executionId, isCancelledBeforeDispatch, markDispatched });
|
||||
if (executed === undefined) {
|
||||
// The user declined the production-safety confirmation: the SQL was
|
||||
// never sent, so this must not be reported as a successful truncate.
|
||||
endDangerRunningExecution();
|
||||
return;
|
||||
}
|
||||
toast(t("contextMenu.truncateTableSuccess", { name: node.label }), 3000);
|
||||
await options.refreshMutatedTableDataTabsForNode(node);
|
||||
endDangerRunningExecution();
|
||||
} catch (error: any) {
|
||||
const message = error?.message || String(error);
|
||||
const backendError = normalizeBackendError(error) ?? undefined;
|
||||
toastDangerOperationError(node.label, message, wasCancelled(), cancelConfirmed(), backendError);
|
||||
if (cancelConfirmed() || !isQueryTimeoutErrorMessage(message, backendError)) {
|
||||
endDangerRunningExecution();
|
||||
} else {
|
||||
markHandedOff();
|
||||
}
|
||||
}
|
||||
await runDangerOperation({
|
||||
node,
|
||||
buildSql: () => truncateTablePreviewSql.value || buildTruncateTableSql(tableAdminSqlOptionsForNode(node, { cascade: truncateTableCascade.value && supportsTruncateTableCascade(databaseTypeForNode(node)) })),
|
||||
onSuccess: async (node) => {
|
||||
toast(t("contextMenu.truncateTableSuccess", { name: node.label }), 3000);
|
||||
await options.refreshMutatedTableDataTabsForNode(node);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -2958,6 +2958,8 @@ export default {
|
||||
tableOperationTimedOut: 'The operation on "{name}" is taking longer than expected and has not been cancelled — it may still be running on the database. Check the result later, or click Cancel to stop it. ({message})',
|
||||
tableOperationCancelled: 'Operation on "{name}" cancelled',
|
||||
tableOperationCancelUnconfirmed: 'The cancel request for "{name}" was not confirmed by the database — the operation may have already finished or may still be running. Check the result to be sure. ({message})',
|
||||
tableOperationCancelPending: 'The cancel request for "{name}" has not been confirmed yet — it may still be running on the database. You can try Cancel again, or check back shortly.',
|
||||
dangerOperationAlreadyRunning: "Another dangerous operation is still running. Wait for it to finish or cancel it before starting a new one.",
|
||||
objectDropRefreshFailed: "Objects were deleted, but refreshing the sidebar failed: {message}",
|
||||
duplicateNameTitle: "Clone as New Table",
|
||||
duplicateNamePlaceholder: "New table name",
|
||||
|
||||
@@ -2861,6 +2861,8 @@ export default withEnglishFallback({
|
||||
tableOperationTimedOut: 'La operación en "{name}" está tardando más de lo esperado y no se ha cancelado; es posible que siga ejecutándose en la base de datos. Compruebe el resultado más tarde o haga clic en Cancelar para detenerla. ({message})',
|
||||
tableOperationCancelled: 'Operación en "{name}" cancelada',
|
||||
tableOperationCancelUnconfirmed: 'La solicitud de cancelación de "{name}" no fue confirmada por la base de datos; la operación puede haber terminado o seguir en ejecución. Compruebe el resultado real. ({message})',
|
||||
tableOperationCancelPending: 'La solicitud de cancelación de "{name}" aún no ha sido confirmada; puede que la operación siga en ejecución en la base de datos. Puedes intentar cancelar de nuevo o comprobarlo en unos instantes.',
|
||||
dangerOperationAlreadyRunning: "Otra operación peligrosa sigue en ejecución. Espera a que finalice o cancélala antes de iniciar una nueva.",
|
||||
objectDropRefreshFailed: "Los objetos se eliminaron, pero no se pudo actualizar la barra lateral: {message}",
|
||||
duplicateNameTitle: "Clonar como tabla nueva",
|
||||
duplicateNamePlaceholder: "Nombre de la nueva tabla",
|
||||
|
||||
@@ -2859,6 +2859,8 @@ export default withEnglishFallback({
|
||||
tableOperationTimedOut: 'L\'operazione su "{name}" sta impiegando più tempo del previsto e non è stata annullata: potrebbe essere ancora in esecuzione sul database. Controlla il risultato più tardi oppure fai clic su Annulla per interromperla. ({message})',
|
||||
tableOperationCancelled: 'Operazione su "{name}" annullata',
|
||||
tableOperationCancelUnconfirmed: 'La richiesta di annullamento per "{name}" non è stata confermata dal database: l\'operazione potrebbe essere già terminata o essere ancora in esecuzione. Verifica il risultato effettivo. ({message})',
|
||||
tableOperationCancelPending: 'La richiesta di annullamento per "{name}" non è stata ancora confermata: l\'operazione potrebbe essere ancora in esecuzione sul database. Puoi riprovare ad annullare oppure controllare tra poco.',
|
||||
dangerOperationAlreadyRunning: "Un'altra operazione pericolosa è ancora in esecuzione. Attendi che finisca o annullala prima di iniziarne una nuova.",
|
||||
objectDropRefreshFailed: "Gli oggetti sono stati eliminati, ma l'aggiornamento della barra laterale non è riuscito: {message}",
|
||||
duplicateNameTitle: "Clona as Nuova Tabella",
|
||||
duplicateNamePlaceholder: "Nuovo nome tabella",
|
||||
|
||||
@@ -2882,6 +2882,8 @@ export default withEnglishFallback({
|
||||
tableOperationTimedOut: "「{name}」の操作に予想より時間がかかっており、まだキャンセルされていません。データベース側では実行が続いている可能性があります。後で結果を確認するか、キャンセルをクリックして停止してください。({message})",
|
||||
tableOperationCancelled: "「{name}」の操作をキャンセルしました",
|
||||
tableOperationCancelUnconfirmed: "「{name}」のキャンセル要求はデータベース側で確認されませんでした。操作はすでに完了しているか、まだ実行中の可能性があります。実際の結果をご確認ください。({message})",
|
||||
tableOperationCancelPending: "「{name}」のキャンセル要求はまだ確認されていません。データベース側でまだ実行中の可能性があります。もう一度キャンセルを試すか、しばらくしてから確認してください。",
|
||||
dangerOperationAlreadyRunning: "別の危険な操作がまだ実行中です。完了を待つか、キャンセルしてから新しい操作を開始してください。",
|
||||
objectDropRefreshFailed: "オブジェクトは削除されましたが、サイドバーの更新に失敗しました: {message}",
|
||||
duplicateNameTitle: "新しいテーブルとして複製",
|
||||
duplicateNamePlaceholder: "新しいテーブル名",
|
||||
|
||||
@@ -2778,6 +2778,8 @@ export default withEnglishFallback({
|
||||
tableOperationTimedOut: '"{name}"에 대한 작업이 예상보다 오래 걸리고 있으며 취소되지 않았습니다. 데이터베이스에서 계속 실행 중일 수 있습니다. 나중에 결과를 확인하거나 취소를 클릭하여 중지하세요. ({message})',
|
||||
tableOperationCancelled: '"{name}" 작업이 취소되었습니다',
|
||||
tableOperationCancelUnconfirmed: '"{name}"에 대한 취소 요청이 데이터베이스에서 확인되지 않았습니다. 작업이 이미 완료되었거나 계속 실행 중일 수 있습니다. 실제 결과를 확인하세요. ({message})',
|
||||
tableOperationCancelPending: '"{name}"에 대한 취소 요청이 아직 확인되지 않았습니다. 데이터베이스에서 계속 실행 중일 수 있습니다. 다시 취소를 시도하거나 잠시 후 확인해 보세요.',
|
||||
dangerOperationAlreadyRunning: "다른 위험한 작업이 아직 실행 중입니다. 완료되기를 기다리거나 취소한 후 새 작업을 시작하세요.",
|
||||
objectDropRefreshFailed: "개체는 삭제되었지만 사이드바 새로 고침에 실패했습니다: {message}",
|
||||
duplicateNameTitle: "새 테이블로 복제",
|
||||
duplicateNamePlaceholder: "새 테이블 이름",
|
||||
|
||||
@@ -2861,6 +2861,8 @@ export default withEnglishFallback({
|
||||
tableOperationTimedOut: 'A operação em "{name}" está demorando mais do que o esperado e não foi cancelada — ela pode continuar em execução no banco de dados. Verifique o resultado mais tarde ou clique em Cancelar para interrompê-la. ({message})',
|
||||
tableOperationCancelled: 'Operação em "{name}" cancelada',
|
||||
tableOperationCancelUnconfirmed: 'A solicitação de cancelamento de "{name}" não foi confirmada pelo banco de dados — a operação pode já ter terminado ou ainda estar em execução. Verifique o resultado real. ({message})',
|
||||
tableOperationCancelPending: 'A solicitação de cancelamento de "{name}" ainda não foi confirmada — a operação pode continuar em execução no banco de dados. Você pode tentar cancelar novamente ou verificar em instantes.',
|
||||
dangerOperationAlreadyRunning: "Outra operação perigosa ainda está em execução. Aguarde a conclusão ou cancele-a antes de iniciar uma nova.",
|
||||
objectDropRefreshFailed: "Os objetos foram excluídos, mas não foi possível atualizar a barra lateral: {message}",
|
||||
duplicateNameTitle: "Clonar como Nova Tabela",
|
||||
duplicateNamePlaceholder: "Nome da nova tabela",
|
||||
|
||||
@@ -2882,6 +2882,8 @@ export default withEnglishFallback({
|
||||
tableOperationTimedOut: "「{name}」的操作耗时较长,尚未被取消,可能仍在数据库端继续执行。请稍后自行确认执行结果,或点击「取消」手动终止。({message})",
|
||||
tableOperationCancelled: "「{name}」的操作已取消",
|
||||
tableOperationCancelUnconfirmed: "「{name}」的取消请求未被数据库确认,该操作可能已经完成,也可能仍在继续执行,请自行核实实际结果。({message})",
|
||||
tableOperationCancelPending: "「{name}」的取消请求尚未得到确认,该操作可能仍在数据库中执行。可以再次点击取消,或稍后再查看结果。",
|
||||
dangerOperationAlreadyRunning: "还有一个危险操作正在执行,请等待其完成或取消后再开始新的操作。",
|
||||
objectDropRefreshFailed: "对象已删除,但侧边栏刷新失败:{message}",
|
||||
duplicateNameTitle: "克隆为新表",
|
||||
duplicateNamePlaceholder: "新表名",
|
||||
|
||||
@@ -2860,6 +2860,8 @@ export default withEnglishFallback({
|
||||
tableOperationTimedOut: "「{name}」的操作耗時較長,尚未被取消,可能仍在資料庫端繼續執行。請稍後自行確認執行結果,或點擊「取消」手動終止。({message})",
|
||||
tableOperationCancelled: "「{name}」的操作已取消",
|
||||
tableOperationCancelUnconfirmed: "「{name}」的取消請求未被資料庫確認,該操作可能已經完成,也可能仍在繼續執行,請自行核實實際結果。({message})",
|
||||
tableOperationCancelPending: "「{name}」的取消請求尚未得到確認,該操作可能仍在資料庫中執行。可以再次點擊取消,或稍後再查看結果。",
|
||||
dangerOperationAlreadyRunning: "還有一個危險操作正在執行,請等待其完成或取消後再開始新的操作。",
|
||||
objectDropRefreshFailed: "物件已刪除,但側邊欄重新整理失敗:{message}",
|
||||
duplicateNameTitle: "克隆為新資料表",
|
||||
duplicateNamePlaceholder: "新資料表名稱",
|
||||
|
||||
Reference in new issue
Block a user