Files
dbx/apps/desktop/src/composables/useSidebarTableMutationRuntime.ts
T

558 lines
25 KiB
TypeScript

import { computed, type ShallowRef } from "vue";
import { useI18n } from "vue-i18n";
import { useToast } from "@/composables/useToast";
import { useConnectionStore } from "@/stores/connectionStore";
import type { DatabaseType, TreeNode } from "@/types/database";
import { supportsTableTruncate, supportsTableVacuum } from "@/lib/database/databaseCapabilities";
import {
buildDropTableSql,
buildEmptyTableSql,
buildMysqlAutoIncrementSql,
buildTruncateTableSql,
buildVacuumTableSql,
supportsDropTableCascade,
supportsNativeMysqlAutoIncrement,
supportsTruncateTableCascade,
type MysqlAutoIncrementSqlOptions,
type TableAdminSqlOptions,
type VacuumTableSqlOptions,
} from "@/lib/database/dbAdminSql";
import { isSqlServerLinkedNode } from "@/lib/database/sqlServerLinkedServers";
import { isQueryTimeoutErrorMessage } from "@/lib/sql/queryError";
import { uuid } from "@/lib/common/utils";
import * as api from "@/lib/backend/api";
import { normalizeBackendError, type BackendError } from "@/lib/backend/errorUtils";
import {
sidebarDangerTarget,
sidebarDangerRunningExecutionId,
sidebarDangerRunningCancel,
showDropTableConfirm,
showEmptyTableConfirm,
showMysqlAutoIncrementConfirm,
showTruncateTableConfirm,
showVacuumTableConfirm,
dropTablePreviewSql,
dropTableCascade,
emptyTablePreviewSql,
mysqlAutoIncrementPreviewKey,
mysqlAutoIncrementPreviewSql,
mysqlAutoIncrementValue,
truncateTablePreviewSql,
truncateTableCascade,
vacuumTableFull,
vacuumTableAnalyze,
vacuumTablePreviewSql,
vacuumTablePreviewKey,
vacuumTableExecuting,
} from "@/components/sidebar/sidebarTreeDialogState";
interface SidebarTableMutationRuntimeOptions {
activeNode: ShallowRef<TreeNode>;
releaseActiveNodeReference: (nodeIds: readonly string[]) => void;
connectionStore: ReturnType<typeof useConnectionStore>;
currentDatabaseType: () => DatabaseType | undefined;
databaseTypeForNode: (node: TreeNode) => DatabaseType | undefined;
executeWithProductionGuard: (node: Pick<TreeNode, "connectionId" | "database" | "schema">, sql: string, options?: { database?: string; schema?: string; executionId?: string; isCancelledBeforeDispatch?: () => boolean; markDispatched?: () => void }) => Promise<unknown>;
closeDroppedTableObjectTabsForNode: (node: TreeNode) => void;
refreshMutatedTableDataTabsForNode: (node: TreeNode) => Promise<void>;
}
export function useSidebarTableMutationRuntime(options: SidebarTableMutationRuntimeOptions) {
const { t } = useI18n();
const { toast } = useToast();
const { activeNode, connectionStore, currentDatabaseType, databaseTypeForNode } = options;
const isTableNotView = computed(() => activeNode.value.type === "table" && !isSqlServerLinkedNode(activeNode.value));
const supportsTruncate = computed(() => supportsTableTruncate(currentDatabaseType()));
const supportsVacuum = computed(() => {
const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined;
return activeNode.value.type === "table" && !config?.read_only && supportsTableVacuum(currentDatabaseType());
});
const canDropTableCascade = computed(() => activeNode.value.type === "table" && supportsDropTableCascade(currentDatabaseType()));
const canTruncateTableCascade = computed(() => activeNode.value.type === "table" && supportsTruncateTableCascade(currentDatabaseType()));
const supportsMysqlAutoIncrement = computed(() => activeNode.value.type === "table" && supportsNativeMysqlAutoIncrement(activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined));
function tableAdminSqlOptions(optionsOverride?: { cascade?: boolean }): TableAdminSqlOptions {
const result: TableAdminSqlOptions = {
databaseType: currentDatabaseType(),
schema: activeNode.value.schema,
tableName: activeNode.value.label,
// Cloud Spanner's two dialects quote differently, so admin SQL needs the quote the connected
// agent reported rather than the static per-type mapping.
identifierQuote: connectionStore.connectionIdentifierQuote?.(activeNode.value.connectionId),
};
if (optionsOverride?.cascade) result.cascade = true;
return result;
}
function tableAdminSqlOptionsForNode(node: TreeNode, optionsOverride?: { cascade?: boolean }): TableAdminSqlOptions {
const result: TableAdminSqlOptions = {
databaseType: databaseTypeForNode(node),
schema: node.schema,
tableName: node.label,
identifierQuote: connectionStore.connectionIdentifierQuote?.(node.connectionId),
};
if (optionsOverride?.cascade) result.cascade = true;
return result;
}
function dropTableSqlOptions(): TableAdminSqlOptions {
return tableAdminSqlOptions({ cascade: canDropTableCascade.value && dropTableCascade.value });
}
function truncateTableSqlOptions(): TableAdminSqlOptions {
return tableAdminSqlOptions({ cascade: canTruncateTableCascade.value && truncateTableCascade.value });
}
async function refreshDropTablePreviewSql() {
dropTablePreviewSql.value = "";
dropTablePreviewSql.value = await buildDropTableSql(dropTableSqlOptions()).catch(() => "");
}
async function refreshEmptyTablePreviewSql() {
emptyTablePreviewSql.value = "";
emptyTablePreviewSql.value = await buildEmptyTableSql(tableAdminSqlOptions()).catch(() => "");
}
async function refreshTruncateTablePreviewSql() {
truncateTablePreviewSql.value = "";
truncateTablePreviewSql.value = await buildTruncateTableSql(truncateTableSqlOptions()).catch(() => "");
}
function mysqlAutoIncrementSqlOptionsForNode(node: TreeNode): MysqlAutoIncrementSqlOptions {
const config = node.connectionId ? connectionStore.getConfig(node.connectionId) : undefined;
return {
databaseType: config?.db_type ?? databaseTypeForNode(node) ?? "mysql",
driverProfile: config?.driver_profile,
schema: node.database || node.schema,
tableName: node.label,
value: mysqlAutoIncrementValue.value,
};
}
function mysqlAutoIncrementPreviewKeyForNode(node: TreeNode, sqlOptions = mysqlAutoIncrementSqlOptionsForNode(node)): string {
return JSON.stringify([node.id, node.connectionId, node.database, sqlOptions.databaseType, sqlOptions.driverProfile?.trim().toLowerCase() || "", sqlOptions.schema || "", sqlOptions.tableName, sqlOptions.value]);
}
async function refreshMysqlAutoIncrementPreviewSql() {
const node = activeNode.value;
const sqlOptions = mysqlAutoIncrementSqlOptionsForNode(node);
const previewKey = mysqlAutoIncrementPreviewKeyForNode(node, sqlOptions);
mysqlAutoIncrementPreviewSql.value = "";
mysqlAutoIncrementPreviewKey.value = "";
const sql = await buildMysqlAutoIncrementSql(sqlOptions).catch(() => "");
if (previewKey !== mysqlAutoIncrementPreviewKeyForNode(activeNode.value)) return;
mysqlAutoIncrementPreviewSql.value = sql;
mysqlAutoIncrementPreviewKey.value = sql ? previewKey : "";
}
function mysqlAutoIncrement() {
if (!supportsMysqlAutoIncrement.value) return;
mysqlAutoIncrementValue.value = "1";
mysqlAutoIncrementPreviewKey.value = "";
void refreshMysqlAutoIncrementPreviewSql();
showMysqlAutoIncrementConfirm.value = true;
}
async function confirmMysqlAutoIncrement() {
const node = sidebarDangerTarget.value ?? activeNode.value;
if (!node.connectionId || !node.database) return;
try {
const config = connectionStore.getConfig(node.connectionId);
if (!supportsNativeMysqlAutoIncrement(config)) throw new Error("Setting AUTO_INCREMENT is supported only for native MySQL connections.");
await connectionStore.ensureConnected(node.connectionId);
const sqlOptions = mysqlAutoIncrementSqlOptionsForNode(node);
const previewKey = mysqlAutoIncrementPreviewKeyForNode(node, sqlOptions);
const sql = mysqlAutoIncrementPreviewKey.value === previewKey && mysqlAutoIncrementPreviewSql.value ? mysqlAutoIncrementPreviewSql.value : await buildMysqlAutoIncrementSql(sqlOptions);
await options.executeWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
toast(t("contextMenu.mysqlAutoIncrementSuccess", { name: node.label, value: mysqlAutoIncrementValue.value }), 3000);
await options.refreshMutatedTableDataTabsForNode(node);
} catch (error: any) {
toast(t("contextMenu.tableOperationFailed", { message: error?.message || String(error) }), 5000);
}
}
function vacuumTableSqlOptions(node: TreeNode, full = vacuumTableFull.value, analyze = vacuumTableAnalyze.value): VacuumTableSqlOptions {
return {
databaseType: databaseTypeForNode(node),
schema: node.schema,
tableName: node.label,
full,
analyze,
};
}
function vacuumTablePreviewKeyForNode(node: TreeNode, full = vacuumTableFull.value, analyze = vacuumTableAnalyze.value): string {
return JSON.stringify([node.id, databaseTypeForNode(node), node.schema || "", node.label, full, analyze]);
}
async function refreshVacuumTablePreviewSql() {
const node = sidebarDangerTarget.value ?? activeNode.value;
const full = vacuumTableFull.value;
const analyze = vacuumTableAnalyze.value;
const previewKey = vacuumTablePreviewKeyForNode(node, full, analyze);
vacuumTablePreviewSql.value = "";
vacuumTablePreviewKey.value = "";
const sql = await buildVacuumTableSql(vacuumTableSqlOptions(node, full, analyze)).catch(() => "");
const currentNode = sidebarDangerTarget.value ?? activeNode.value;
if (previewKey !== vacuumTablePreviewKeyForNode(currentNode)) return;
vacuumTablePreviewSql.value = sql;
vacuumTablePreviewKey.value = sql ? previewKey : "";
}
function vacuumTable() {
if (!supportsVacuum.value) return;
vacuumTableFull.value = false;
vacuumTableAnalyze.value = false;
vacuumTablePreviewSql.value = "";
vacuumTablePreviewKey.value = "";
void refreshVacuumTablePreviewSql();
showVacuumTableConfirm.value = true;
}
async function confirmVacuumTable(): Promise<boolean> {
const node = sidebarDangerTarget.value ?? activeNode.value;
if (!node.connectionId || node.database == null) return false;
vacuumTableExecuting.value = true;
try {
await connectionStore.ensureConnected(node.connectionId);
const optionsForNode = vacuumTableSqlOptions(node, vacuumTableFull.value, vacuumTableAnalyze.value);
const previewKey = vacuumTablePreviewKeyForNode(node);
const sql = vacuumTablePreviewKey.value === previewKey && vacuumTablePreviewSql.value ? vacuumTablePreviewSql.value : await buildVacuumTableSql(optionsForNode);
const executed = await options.executeWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
if (executed === undefined) return false;
toast(t("contextMenu.vacuumTableSuccess", { name: node.label }), 3000);
return true;
} catch (error: any) {
toast(t("contextMenu.tableOperationFailed", { message: error?.message || String(error) }), 5000);
return false;
} finally {
vacuumTableExecuting.value = false;
}
}
async function refreshVacuumPreviewForOptions() {
await refreshVacuumTablePreviewSql();
}
function dropTable() {
dropTableCascade.value = false;
void refreshDropTablePreviewSql();
showDropTableConfirm.value = true;
}
async function refreshTableList(node: TreeNode) {
if (!node.connectionId || !node.database) return;
await connectionStore.refreshObjectListTreeNode(node.connectionId, node.database, node.schema);
}
// Bounded, undelayed retry: register_task on the backend runs as the very
// first statement of the execute_query command, so if cancelQuery races a
// just-dispatched execution, a couple of retries are enough for the
// registration to land — no artificial delay needed.
const CANCEL_QUERY_MAX_ATTEMPTS = 5;
// Overall cap on the whole retry sequence, mirroring queryStore.ts's
// withCancelQueryTimeout/CANCEL_QUERY_TIMEOUT_MS for the main editor. If
// api.cancelQuery itself hangs (e.g. a wedged connection), the retry loop
// alone would never settle, which would permanently disable every future
// 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> {
// 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?.();
});
}
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;
}
// 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) => {
timeoutId = setTimeout(() => resolve(false), CANCEL_QUERY_OVERALL_TIMEOUT_MS);
});
// 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.";
interface DangerRunningExecution {
executionId: string;
isCancelledBeforeDispatch: () => boolean;
markDispatched: () => void;
wasCancelled: () => boolean;
cancelConfirmed: () => boolean;
waitForCancelConfirmation: () => Promise<boolean>;
markHandedOff: () => void;
}
function beginDangerRunningExecution(nodeLabel: string): DangerRunningExecution {
const executionId = uuid();
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.
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;
}
}
};
return {
executionId,
isCancelledBeforeDispatch: () => cancelledByUser && !dispatched,
markDispatched: () => {
dispatched = true;
},
wasCancelled: () => cancelledByUser,
cancelConfirmed: () => cancelConfirmedFlag,
waitForCancelConfirmation: () => cancelConfirmationPromise ?? Promise.resolve(cancelConfirmedFlag),
markHandedOff: () => {
handedOff = true;
},
};
}
function endDangerRunningExecution() {
sidebarDangerRunningExecutionId.value = "";
sidebarDangerRunningCancel.value = null;
}
function toastDangerOperationError(name: string, message: string, wasCancelled: boolean, cancelConfirmed: boolean, backendError?: BackendError) {
if (cancelConfirmed) {
toast(t("contextMenu.tableOperationCancelled", { name }), 3000);
} else if (wasCancelled) {
toast(t("contextMenu.tableOperationCancelUnconfirmed", { name, message }), 8000);
} else if (isQueryTimeoutErrorMessage(message, backendError)) {
toast(t("contextMenu.tableOperationTimedOut", { name, message }), 8000);
} else {
toast(t("contextMenu.tableOperationFailed", { message }), 5000);
}
}
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, waitForCancelConfirmation, markHandedOff } = beginDangerRunningExecution(node.label);
try {
await connectionStore.ensureConnected(node.connectionId);
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 operation.
endDangerRunningExecution();
return;
}
await onSuccess(node);
endDangerRunningExecution();
} catch (error: any) {
const message = error?.message || String(error);
const backendError = normalizeBackendError(error) ?? undefined;
const cancellationWasRequested = wasCancelled();
const cancellationWasConfirmed = cancelConfirmed() || (cancellationWasRequested && (await waitForCancelConfirmation()));
toastDangerOperationError(node.label, message, cancellationWasRequested, cancellationWasConfirmed, backendError);
if (cancellationWasConfirmed || !isQueryTimeoutErrorMessage(message, backendError)) {
endDangerRunningExecution();
} else {
markHandedOff();
}
}
}
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;
}
async function confirmEmptyTable() {
const node = sidebarDangerTarget.value ?? activeNode.value;
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() {
truncateTableCascade.value = false;
void refreshTruncateTablePreviewSql();
showTruncateTableConfirm.value = true;
}
async function confirmTruncateTable() {
const node = sidebarDangerTarget.value ?? activeNode.value;
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 {
isTableNotView,
supportsTruncate,
supportsVacuum,
canDropTableCascade,
canTruncateTableCascade,
supportsMysqlAutoIncrement,
refreshDropTablePreviewSql,
refreshTruncateTablePreviewSql,
dropTable,
refreshTableList,
confirmDropTable,
emptyTable,
confirmEmptyTable,
truncateTable,
confirmTruncateTable,
vacuumTable,
refreshVacuumPreviewForOptions,
confirmVacuumTable,
mysqlAutoIncrement,
refreshMysqlAutoIncrementPreviewSql,
confirmMysqlAutoIncrement,
};
}