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; releaseActiveNodeReference: (nodeIds: readonly string[]) => void; connectionStore: ReturnType; currentDatabaseType: () => DatabaseType | undefined; databaseTypeForNode: (node: TreeNode) => DatabaseType | undefined; executeWithProductionGuard: (node: Pick, sql: string, options?: { database?: string; schema?: string; executionId?: string; isCancelledBeforeDispatch?: () => boolean; markDispatched?: () => void }) => Promise; closeDroppedTableObjectTabsForNode: (node: TreeNode) => void; refreshMutatedTableDataTabsForNode: (node: TreeNode) => Promise; } 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 { 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 { // 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) { 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((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((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; retryPromise: Promise } { const retryPromise = confirmCancelWithRetry(executionId); let timeoutId: ReturnType; const timeout = new Promise((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; markHandedOff: () => void; } function beginDangerRunningExecution(nodeLabel: string): DangerRunningExecution { const executionId = uuid(); let dispatched = false; let cancelledByUser = false; let cancelConfirmedFlag = false; let cancelConfirmationPromise: Promise | 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; onSuccess: (node: TreeNode) => void | Promise; } 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, }; }