Files
dbx/apps/desktop/src/stores/queryStore.ts
T

9429 lines
448 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createQueryRequestTiming } from "@/lib/queryRequestTiming";
import { UPDATE_RESTORE_KEY, assertUpdateAllowsInteraction } from "@/lib/app/updatePreparation";
import { defineStore } from "pinia";
import { isRedisMonitorCommand, startRedisMonitor } from "@/lib/redis/redisMonitor";
import { uuid } from "@/lib/common/utils";
import { computed, markRaw, nextTick, onScopeDispose, reactive, ref, toRaw, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useToast } from "@/composables/useToast";
import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors";
import { sanitizeTabPageUiState } from "@/lib/tabs/tabUiState";
import type { DeletedConnectionTabKeepMode } from "@/lib/tabs/deletedConnectionTabs";
import type {
BatchSqlExecution,
BatchStatementExecutionItem,
ConnectionConfig,
DatabaseType,
IndexInfo,
NacosConfigEditorViewport,
ObjectBrowserFilter,
ObjectBrowserViewport,
ObjectSource,
ObjectSourceKind,
QueryResult,
QueryResultSourceColumnRef,
QueryTab,
TableInfoTab,
TableStructureEditorTarget,
} from "@/types/database";
import { orderPinnedFirst } from "@/lib/app/pinnedItems";
import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState";
import { isSqlErrorPositionDebugEnabled, logSqlErrorPosition, sqlErrorHasMessagePosition, sqlErrorMessageText } from "@/lib/sql/errorPosition";
import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText, sqlServerExplainResult, type BuildExplainSqlResult, type ExplainPlanDatabaseType } from "@/lib/diagram/explainPlan";
import { mysqlExplainCompatibilityHint } from "@/lib/diagram/mysqlExplainCompatibility";
import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQueryEditability, analyzeSelectStructureForDisplay, resolveMetadataColumnName, resolveSourceColumnsByOrdinal, sourceColumnsForResult, type EditableQueryInfo, type EditableQuerySource } from "@/lib/sql/sqlAnalysis";
import { buildQueryWithHiddenPrimaryKeys, hiddenResultColumnIndexes, type HiddenPrimaryKeyProjection } from "@/lib/sql/editableQueryHiddenKeys";
import { ACTIVE_TAB_STORAGE_KEY, OPEN_TABS_STORAGE_KEY, restoreOpenTabsPayload, restoreOpenTabsState, serializeOpenTabs, type OpenTabsStatePayload } from "@/lib/app/openTabsPersistence";
import {
evaluateMongoAggregateSafety,
evaluateMongoWriteSafety,
mongoCollectionStatsToQueryResult,
mongoCountToQueryResult,
mongoDatabasesToQueryResult,
mongoDistinctToQueryResult,
mongoCreateIndexToQueryResult,
mongoDocumentsToQueryResult,
describeMongoCommandParseFailure,
mongoDroppedIndexesToQueryResult,
mongoFindLogicalTotal,
mongoIndexesToQueryResult,
planMongoFindPagination,
mongoUseToQueryResult,
mongoVersionToQueryResult,
mongoBulkWriteToQueryResult,
mongoScalarToQueryResult,
mongoWriteToQueryResult,
splitMongoCommandRanges,
splitSiblingDbPrefix,
type MongoAggregateSafetyOptions,
} from "@/lib/mongo/mongoShellCommand";
import { refreshLoadedMongoIndexes } from "@/lib/mongo/mongoIndexMetadata";
import { redisCommandResultToQueryResult } from "@/lib/redis/redisQueryResult";
import { nextRedisCommandDb } from "@/lib/redis/redisCommandSession";
import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable";
import { usesAgentCursorForQuery } from "@/lib/database/databaseDriverManifest";
import { connectionIsDorisFamilyCatalogCapable, defaultAutoCommitForDbType, supportsClearableQuerySchema, supportsTransaction, usesOracleStickyTransactionState, usesProvenReadOnlyStickyTransactionState } from "@/lib/database/databaseFeatureSupport";
import { canInsertTableRows, canUseKeylessRowPredicate, DBX_ROWID_COLUMN, editablePrimaryKeys, shouldIncludeSyntheticRowId, usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
import { TABLE_DATA_EXPORT_PAGE_SIZE } from "@/lib/table/tableDataExport";
import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta";
import { isDataTabMetadataLifecycleStale } from "@/lib/sidebar/dataTabOpenPolicy";
import type { SqlInsertMode } from "@/lib/export/sqlInsertMode";
import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit";
import { getCachedTableMetadata, loadTableColumns, loadTableIndexes, loadTableMetadata, tableMetadataToDataTabMeta, updateCachedTableMetadataType, type TableMetadataRequest } from "@/lib/metadata/tableMetadataCache";
import { loadObjectDdl } from "@/lib/metadata/objectDdlCache";
import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter";
import { formatDdlForDisplay } from "@/lib/sql/ddlDisplay";
import { buildTableSelectSql, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql";
import { connectionObjectTreeNodeSchema, connectionQueryExecutionSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, gaussdbCountQueryDopHint, jdbcConnectionUsesDriverRowOffset, metadataSchemaForConnection } from "@/lib/database/jdbcDialect";
import { frontendQueryTimeoutDelayMs, frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
import { queryResultNameFromPreamble, queryResultSourceLabel, queryResultSourceNameParts } from "@/lib/sql/queryResultSource";
import { sqlServerCountUsesLocalTempTable } from "@/lib/query/queryResultCountSession";
import { stripPaginationRowNumber } from "@/lib/query/queryPaginationResult";
import { beginDataGridNativeSelectionBlock, finishDataGridNativeSelectionBlock } from "@/lib/dataGrid/dataGridNativeSelection";
import { appendLargeValueCells, canUseTableDataLargeValuePreview, remapLargeValueCells, tableDataLargeValuePreviewOptions, TABLE_DATA_RESULT_MAX_BYTES } from "@/lib/dataGrid/dataGridLargeValues";
import { simpleDataGridOrderByReferencesMissingColumn, sortDataGridRowIndexes, type DataGridSortDirection } from "@/lib/dataGrid/dataGridSort";
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
import { agentProtocolQueryResultMaxRows, capQueryResultTotal, effectiveQueryResultMaxRows, limitQueryPagination, queryResultLimitReached } from "@/lib/dataGrid/queryResultRowLimit";
import { elasticsearchRestRequestRanges, executableStatementRanges, splitSqlStatementRanges, sqlStatementParameterOptionsForCompatibility, stripMysqlClientDisplayCommand } from "@/lib/sql/sqlStatementRanges";
import type { SqlParameterOptions } from "@/lib/sql/sqlParameters";
import { replaceSqlServerLeadingUseQuery, sqlServerLeadingUseScript, switchesDatabaseWithUseStatement, useDatabaseFromStatement } from "@/lib/sql/sqlCompletionLookupTarget";
import { classifySqlRisk } from "@/lib/sql/sqlRisk";
import { externalSqlFileDisplayTitles, normalizeExternalSqlPath } from "@/lib/sql/sqlFileOpen";
import { clearDataGridPendingSnapshot, clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor";
import { beginClosingDataGridViewSnapshotsForTab, clearDataGridViewSnapshot, clearDataGridViewSnapshotsForTab } from "@/lib/dataGrid/dataGridViewStateCache";
import { beginClosingBrowserState } from "@/lib/tabs/documentBrowserStateCache";
import { clearDataGridStructuredFilterStatesForTab } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
import { clearDataGridSearchStatesForTab } from "@/lib/dataGrid/dataGridSearchStatePersistence";
import { buildTabResultSnapshot, deleteTabResultSnapshot, pruneTabResultSnapshots, readTabResultSnapshot, tabResultCacheKey, writeTabResultSnapshot } from "@/lib/tabs/tabResultCache";
import { estimateQueryResultsBytes, selectInactiveResultEvictions } from "@/lib/tabs/queryResultSize";
import { queryResultBaseSql, queryResultExecutionSql, resultGridInstanceKey, syncTabTitleNumbers, tabDisplayTitle } from "@/lib/tabs/tabPresentation";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import { batchSqlRecoverySql, batchSqlRecoveryState, mergeBatchQueryResults, offsetBatchQueryResultIndexes, prepareBatchSqlRecovery, type BatchSqlRecoveryAction } from "@/lib/query/batchSqlRecovery";
import { decodeQueryResultArchive, encodeQueryResultArchive, type DecodedQueryResultArchive } from "@/lib/query/queryResultArchive";
import * as api from "@/lib/backend/api";
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
import { snapshotPluginWorkbenchContext } from "@/lib/plugins/pluginData";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { useExportTracker } from "@/composables/useExportTracker";
import { recordQueryCancellationLatency, resourceLifecycleDiagnostics } from "@/lib/diagnostics/resourceLifecycleDiagnostics";
import { appendDebugLog } from "@/lib/backend/debugLog";
import { BackendErrorException, formatError, isManualTransactionSessionExpired, isUnsupportedManualTransactionMethod, normalizeBackendError, type BackendError } from "@/lib/backend/errorUtils";
import { createSavedSqlEditorPosition, initSavedSqlEditorPositions, restoreSavedSqlEditorPosition, saveSavedSqlEditorPosition } from "@/lib/app/savedSqlEditorPosition";
import { isDetachedWindow, resolveWindowContext } from "@/lib/app/windowContext";
import { normalizeDetachedTabRuntime, type DetachedTabHandoff, type DetachedTabRuntimeState } from "@/lib/app/detachedTabHandoff";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { resolveSavedSqlExecutionTarget, savedSqlExecutionTargetFromTab, type SavedSqlExecutionTarget, type SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget";
import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage";
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
import { loadEditableObjectSourceForEditor } from "@/lib/table/objectSourceLoad";
import { buildEditableObjectSource } from "@/lib/table/objectSourceEditor";
import { disposeAllSqlServerActivityTraces, disposeSqlServerActivityTrace } from "@/lib/sqlserver/sqlServerActivityTraceRuntime";
import type { SavedSqlFile } from "@/types/database";
import i18n, { currentLocale } from "@/i18n";
import { translateBackendError } from "@/i18n/backend-errors";
import type { SqlExecutionTargetContext } from "@/lib/database/sqlExecutionTargetRegistry";
import type { DriverProfileWorkspaceScope } from "@/lib/database/driverProfileExtensions";
import type { MultiDbExecutionTarget, MultiDbResultRunExecution } from "@/types/sqlExecution";
const QUERY_SURFACE_ACTIVATION_EVENT = "dbx:activate-query-surface";
const ORACLE_LIKE_METADATA_TYPES = new Set<string>(["oracle", "dameng", "oceanbase-oracle"]);
const ORACLE_DEFERRED_LOB_TYPES = new Set<string>(["CLOB", "NCLOB", "BLOB", "BFILE", "XMLTYPE", "SYS.XMLTYPE"]);
// Bounded concurrency for grouped-query display column loads, scoped per
// connection so different connections never block each other. Matches the
// project's existing completion-metadata concurrency policy (<2>).
const GROUPED_DISPLAY_METADATA_CONCURRENCY = 2;
const GROUPED_DISPLAY_LIMITER_SCOPE_PREFIX = "query-column-comments:";
const groupedDisplayMetadataLimiter = new MetadataTaskLimiter(GROUPED_DISPLAY_METADATA_CONCURRENCY, (event) => {
console.debug("[DBX][metadata-load:grouped-display-limiter]", event);
});
const UPPERCASE_FOLDED_METADATA_TYPES = new Set<string>([...ORACLE_LIKE_METADATA_TYPES, "saphana"]);
const HIDDEN_QUERY_KEY_DATABASE_TYPES = new Set<DatabaseType>(["mysql", "postgres", "sqlserver", "oracle", "xugu"]);
const QUERY_RESULT_EXPORT_UNSUPPORTED_ERROR = "Streaming export is unsupported for this query. Simplify it or use a supported driver.";
const BACKGROUND_CLIENT_SESSION_SUFFIXES = ["count", "explain", "export"] as const;
const CANCEL_QUERY_TIMEOUT_MS = 10_000;
const CANCEL_ACK_SETTLE_TIMEOUT_MS = 2_000;
const ORACLE_QUERY_METADATA_PREFLIGHT_BUDGET_MS = 1_000;
const ORACLE_QUERY_METADATA_PREFLIGHT_TIMEOUT = Symbol("oracle-query-metadata-preflight-timeout");
const SAVED_SQL_EDITOR_POSITION_PERSIST_DELAY_MS = 500;
type CloseConfirmContext = "tab" | "batch" | "app";
export interface EditorGroup {
id: string;
tabIds: string[];
activeTabId: string | null;
}
export interface EditorWorkspacePersistState {
groups: EditorGroup[];
focusedGroupId: string;
orientation: "vertical" | "horizontal";
sizes: number[];
}
export type QueryMetadataPatch = Pick<QueryTab, "queryAnalysis" | "querySourceColumns" | "queryWriteTargets" | "queryEditabilityReason" | "tableMeta" | "resultColumnComments" | "queryDisplaySourceColumns">;
interface BatchSqlResumeOptions {
batch: BatchSqlExecution;
previousResults: QueryResult[];
startStatementIndex: number;
continueOnError: boolean;
}
function hasHiddenPhysicalRowKey(databaseType: DatabaseType | undefined, hiddenPrimaryKeys: HiddenPrimaryKeyProjection[]): boolean {
return hiddenPrimaryKeys.some((projection) => !usesSyntheticRowIdKey(databaseType, [projection.sourceName]));
}
function cloneTabDraft<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
interface BuildQueryResultExportRequestOptions {
exportId: string;
filePath: string;
format: "csv" | "xlsx" | "json" | "txt" | "sql";
includeSqlSheet?: boolean;
exportTableName?: string;
exportColumnTypes?: Array<string | null | undefined>;
exportColumnExtras?: Array<string | null | undefined>;
insertMode?: SqlInsertMode;
}
interface OpenSavedSqlOptions {
targetMode?: SavedSqlOpenTargetMode;
}
interface OpenObjectSourceTabOptions {
connectionId: string;
database: string;
title: string;
schema?: string;
catalog?: string;
sql: string;
objectSource: NonNullable<QueryTab["objectSource"]>;
}
/**
* 请求身份:`objectSource.objectType` 要等 routine fallback 跑完才知道,所以
* pending 去重只能按请求时的身份判定。请求身份必须含 objectType —— PACKAGE 与
* PACKAGE_BODY 同名同 schema,仅靠 name+schema 会错误合并。
*/
interface ObjectSourceRequestIdentity {
name: string;
objectType: ObjectSourceKind;
signature?: string;
}
interface OpenPendingObjectSourceTabOptions {
connectionId: string;
database: string;
title: string;
schema?: string;
catalog?: string;
initialEditing?: boolean;
request: ObjectSourceRequestIdentity;
}
/**
* 拿到源码也没有可编辑形态的对象类型:只填内容,不挂 objectSource。
* (`App.vue` 的 Ctrl+click 路径有一份少了 `JOB` 的旧副本,是既有不一致。)
*/
const OBJECT_SOURCE_READ_ONLY_TYPES: readonly ObjectSourceKind[] = ["SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY", "JOB"];
/**
* 「查看」与「编辑」拿到的文本本就不同的对象类型:OceanBase Oracle 的序列在查看态
* 展示原生 `CREATE SEQUENCE`(信息量更大),编辑态才是可执行的 `ALTER SEQUENCE`,
* 所以查看态不能落成可保存的源码 tab —— 否则 Ctrl+S 会把 `CREATE SEQUENCE` 当增量
* 修改执行。其余类型(存储过程/函数/视图等)查看态与编辑态文本一致,查看态沿用
* v0.6.17 行为,仍是带 objectSource 的可保存源码 tab。
*/
function isViewOnlySourceWithoutEditablePayload(initialEditing: boolean | undefined, objectType: ObjectSourceKind): boolean {
return initialEditing === false && objectType === "SEQUENCE";
}
interface UpdateExecutionTargetOptions {
persistSavedSqlTarget?: boolean;
}
type DroppedTableObjectType = "TABLE" | "VIEW" | "MATERIALIZED_VIEW";
interface DroppedTableObjectTarget {
connectionId: string;
database: string;
schema?: string;
schemaCandidates?: Array<string | undefined>;
name: string;
objectType?: DroppedTableObjectType;
}
interface TableDataRefreshTarget {
connectionId: string;
database: string;
schema?: string;
schemaCandidates?: Array<string | undefined>;
catalog?: string;
name: string;
}
function tabClientSessionId(tab: Pick<QueryTab, "id">, suffix?: (typeof BACKGROUND_CLIENT_SESSION_SUFFIXES)[number]): string {
return suffix ? `${tab.id}:${suffix}` : tab.id;
}
function resultRunCacheKey(tabId: string, runId: string): string {
return `tab:${tabId}:run:${runId}`;
}
function normalizeOptionalSchema(schema: string | null | undefined): string {
return schema?.trim() ?? "";
}
function droppedTableObjectSchemaCandidates(target: DroppedTableObjectTarget): Set<string> {
const schemas = target.schemaCandidates?.length ? target.schemaCandidates : [target.schema];
return new Set(schemas.map(normalizeOptionalSchema));
}
function markQueryResultRowsRaw(result: QueryResult): QueryResult {
markRaw(result.rows);
if (result.large_value_cells) markRaw(result.large_value_cells);
if (result.mongo_documents) markRaw(result.mongo_documents);
if (result.mongo_copy_documents) markRaw(result.mongo_copy_documents);
return result;
}
function markQueryResultsRowsRaw(results: QueryResult[]): QueryResult[] {
for (const result of results) markQueryResultRowsRaw(result);
return results;
}
function exactTotalFromIncompletePage(result: QueryResult, pageLimit: number | undefined, pageOffset: number | undefined, useAgentResultSession: boolean | undefined): number | undefined {
if (typeof pageLimit !== "number" || result.rows.length >= pageLimit || result.truncated === true) return undefined;
// Cursor-backed drivers must explicitly confirm exhaustion; an omitted
// has_more value may come from an older driver or an exhausted row cap.
if (useAgentResultSession && result.has_more !== false) return undefined;
return (pageOffset ?? 0) + result.rows.length;
}
export function appendQueryResultSegment(previous: QueryResult, segment: QueryResult, maxRows: number): QueryResult {
if (segment.execution_error) {
throw segment.error ? new BackendErrorException(segment.error) : new BackendErrorException(String(segment.rows[0]?.[0] ?? "Failed to load the next result segment"));
}
if (previous.columns.length !== segment.columns.length || previous.columns.some((column, index) => column !== segment.columns[index])) {
throw new Error("Result columns changed while loading the next segment");
}
const remainingRows = Math.max(0, maxRows - previous.rows.length);
const appendedRowCount = Math.min(remainingRows, segment.rows.length);
const appendParallelValues = <T>(existing: T[] | undefined, next: T[] | undefined): T[] | undefined => {
if (!existing || !next) return undefined;
if (existing.length !== previous.rows.length || next.length !== segment.rows.length) return undefined;
return [...existing, ...next.slice(0, appendedRowCount)];
};
const sridByColumn = new Map<number, number | null>();
for (const column of previous.spatial_columns ?? []) {
sridByColumn.set(column.column_index, column.srid);
}
for (const column of segment.spatial_columns ?? []) {
const existing = sridByColumn.get(column.column_index) ?? null;
sridByColumn.set(column.column_index, existing ?? column.srid);
}
const spatial_columns = Array.from(sridByColumn.entries())
.map(([column_index, srid]) => ({ column_index, srid }))
.sort((a, b) => a.column_index - b.column_index);
// Keep prior row objects intact so source-index based dirty/new/deleted state
// remains valid, while bounding the in-memory result by the configured cap.
return markQueryResultRowsRaw({
...segment,
appended_from_row_count: previous.rows.length,
rows: [...previous.rows, ...segment.rows.slice(0, appendedRowCount)],
spatial_columns: spatial_columns.length > 0 ? spatial_columns : undefined,
spatial_values: appendParallelValues(previous.spatial_values, segment.spatial_values),
large_value_cells: appendLargeValueCells(previous.large_value_cells, segment.large_value_cells, previous.rows.length, appendedRowCount),
mongo_documents: appendParallelValues(previous.mongo_documents, segment.mongo_documents),
mongo_copy_documents: appendParallelValues(previous.mongo_copy_documents, segment.mongo_copy_documents),
execution_time_ms: (previous.execution_time_ms ?? 0) + (segment.execution_time_ms ?? 0),
// A JDBC cursor's terminal page audits the entire original statement.
// Independent page SQLs need every page sampled before a total is shown.
server_execute_time_us: previous.session_id ? segment.server_execute_time_us : previous.server_execute_time_us !== undefined && segment.server_execute_time_us !== undefined ? previous.server_execute_time_us + segment.server_execute_time_us : undefined,
client_request_wait_ms: previous.client_request_wait_ms !== undefined && segment.client_request_wait_ms !== undefined ? previous.client_request_wait_ms + segment.client_request_wait_ms : undefined,
query_timings_ms:
previous.query_timings_ms && segment.query_timings_ms ? Object.fromEntries([...new Set([...Object.keys(previous.query_timings_ms), ...Object.keys(segment.query_timings_ms)])].map((key) => [key, (previous.query_timings_ms![key] ?? 0) + (segment.query_timings_ms![key] ?? 0)])) : undefined,
client_prepare_ms: previous.client_prepare_ms !== undefined && segment.client_prepare_ms !== undefined ? previous.client_prepare_ms + segment.client_prepare_ms : undefined,
client_result_ms: previous.client_result_ms !== undefined && segment.client_result_ms !== undefined ? previous.client_result_ms + segment.client_result_ms : undefined,
timing_page_count: (previous.timing_page_count ?? 1) + (segment.timing_page_count ?? 1),
has_more: previous.rows.length + appendedRowCount >= maxRows ? false : segment.has_more,
});
}
function markQueryResultRunsRowsRaw(resultRuns: NonNullable<QueryTab["resultRuns"]>): NonNullable<QueryTab["resultRuns"]> {
for (const run of resultRuns) {
if (run.result) markQueryResultRowsRaw(run.result);
if (run.results) markQueryResultsRowsRaw(run.results);
if (run.resultLocalSortOriginalRows) markRaw(run.resultLocalSortOriginalRows);
if (run.resultLocalSortOriginalLargeValueCells) markRaw(run.resultLocalSortOriginalLargeValueCells);
if (run.resultLocalSortOriginalMongoDocuments) markRaw(run.resultLocalSortOriginalMongoDocuments);
if (run.resultLocalSortOriginalMongoCopyDocuments) markRaw(run.resultLocalSortOriginalMongoCopyDocuments);
}
return resultRuns;
}
/** Drop heavyweight fields even when a deactivated DataGrid still retains the result object. */
function releaseResultObjectPayload(result: QueryResult): void {
result.columns = [];
result.rows = [];
result.column_types = undefined;
result.column_sortables = undefined;
result.spatial_columns = undefined;
result.spatial_values = undefined;
result.hidden_column_indexes = undefined;
result.local_column_filters = undefined;
result.local_hidden_column_keys = undefined;
result.mongo_documents = undefined;
result.mongo_copy_documents = undefined;
result.large_value_cells = undefined;
result.elasticsearch_raw_body = undefined;
result.messages = undefined;
result.error = undefined;
result.sourceLabel = undefined;
result.sourceQualifier = undefined;
result.sourceName = undefined;
result.sourceStatement = undefined;
}
function preservedResultIndex(results: QueryResult[], currentIndex: number | undefined, preserve: boolean | undefined): number | undefined {
if (preserve !== true || typeof currentIndex !== "number" || currentIndex < 0 || currentIndex >= results.length) return undefined;
return currentIndex;
}
function findSourceDocumentStatement(statements: ReturnType<typeof splitSqlStatementRanges>, sourceFrom: number) {
let lower = 0;
let upper = statements.length;
while (lower < upper) {
const middle = Math.floor((lower + upper) / 2);
if (statements[middle].to < sourceFrom) lower = middle + 1;
else upper = middle;
}
const statement = statements[lower];
return statement && statement.from <= sourceFrom ? statement : undefined;
}
function annotateQueryResultSources(results: QueryResult[], sql: string, database: string | undefined, databaseType?: DatabaseType, sourceOffset?: number, parameterOptions?: SqlParameterOptions, executedSql?: string, sourceDocumentSql?: string): { results: QueryResult[]; useDatabase?: string } {
const statements = splitSqlStatementRanges(sql, databaseType, parameterOptions);
// The backend positions errors against the SQL it actually received. When the
// sent SQL was rewritten (pagination wrapper, injected hidden keys…), record
// each statement's executed text so the error mapper can project the position
// back onto `sourceStatement`.
const executedStatements = executedSql && executedSql !== sql ? splitSqlStatementRanges(executedSql, databaseType, parameterOptions) : undefined;
const alignedExecutedStatements = executedStatements && executedStatements.length === statements.length ? executedStatements : undefined;
const documentStatements = sourceDocumentSql && sourceOffset !== undefined ? splitSqlStatementRanges(sourceDocumentSql, databaseType, parameterOptions) : [];
let statementIndex = 0;
let sourceDatabase = database;
let useDatabase: string | undefined;
for (const result of results) {
const explicitIndex = Number.isInteger(result.statement_index) && result.statement_index! >= 0 ? result.statement_index : undefined;
const sourceIndex = explicitIndex ?? statementIndex;
statementIndex = Math.max(statementIndex, sourceIndex + 1);
const statement = statements[sourceIndex];
if (!statement) continue;
annotateQueryResultSource(result, statement.sql, sourceDatabase, databaseType, sourceOffset === undefined ? undefined : { from: sourceOffset + statement.from, to: sourceOffset + statement.to });
const executedStatement = alignedExecutedStatements?.[sourceIndex]?.sql;
if (executedStatement && executedStatement !== statement.sql) {
result.executedStatement = executedStatement;
if (isSqlErrorPositionDebugEnabled()) {
logSqlErrorPosition("executed-statement-drift", {
statementIndex: sourceIndex,
sourceStatement: statement.sql,
executedStatement,
resultIsError: result.execution_error === true,
errorPosition: result.error?.errorPosition ?? null,
});
}
}
const documentStatement = sourceOffset === undefined ? undefined : findSourceDocumentStatement(documentStatements, sourceOffset + statement.from);
const preamble = documentStatement ? sourceDocumentSql!.slice(documentStatement.hitFrom, documentStatement.from) : sql.slice(statement.hitFrom, statement.from);
const customName = queryResultNameFromPreamble(preamble, { databaseType });
if (customName) {
result.sourceLabel = customName;
// 自定义名称(-- name: xxx)优先:清除结构化来源,避免“结果集名称包含数据库名”设置把它替换成表名
result.sourceQualifier = undefined;
result.sourceName = undefined;
}
const successfulUseDatabase = result.execution_error !== true ? useDatabaseFromStatement(statement.sql, databaseType) : undefined;
if (successfulUseDatabase) {
sourceDatabase = successfulUseDatabase;
useDatabase = successfulUseDatabase;
}
}
return { results, useDatabase };
}
/**
* Annotate the synthesized error result of a thrown single-statement execution
* so the row/column locate flow can map the backend position back to the editor.
*
* The core returns per-statement error results for batches, but a
* single-statement failure aborts the whole execute-multi command, leaving the
* frontend to synthesize the error result here. Only annotated when the error
* actually carries a position — either typed (PostgreSQL) or parseable from the
* message text (Oracle's Agent offset) — and the submission is a single statement
* (a multi-statement thrown error's position cannot be attributed to one statement).
*/
function annotateSingleStatementErrorResult(errorResult: QueryResult, sourceSql: string, databaseType: DatabaseType | undefined, sourceOffset: number | undefined, parameterOptions: SqlParameterOptions | undefined, executedSql: string | undefined): void {
if (!errorResult.error?.errorPosition && !sqlErrorHasMessagePosition(sqlErrorMessageText(errorResult))) return;
if (splitSqlStatementRanges(sourceSql, databaseType, parameterOptions).length !== 1) return;
annotateQueryResultSources([errorResult], sourceSql, undefined, databaseType, sourceOffset, parameterOptions, executedSql);
}
const NON_STREAMING_BATCH_DATABASE_TYPES = new Set<DatabaseType>(["sqlserver", "turso", "cloudflare-d1"]);
const liveBatchSqlExecutions = new WeakMap<QueryTab, BatchSqlExecution>();
// Per running batch: statements before this index are already settled by an
// earlier progress event of the same run.
const batchSqlProgressSettledEnds = new WeakMap<BatchSqlExecution, number>();
function isCompletedBatchStatement(item: BatchStatementExecutionItem): boolean {
return item.status === "success" || item.status === "error";
}
function cloneBatchSqlExecution(batch: BatchSqlExecution | undefined): BatchSqlExecution | undefined {
return batch ? { ...batch, executionTarget: batch.executionTarget ? { ...batch.executionTarget } : undefined, items: batch.items.map((item) => ({ ...item })) } : undefined;
}
function batchSqlExecutionFor(tab: QueryTab, executionId: string): BatchSqlExecution | undefined {
const liveBatch = liveBatchSqlExecutions.get(tab);
if (liveBatch?.executionId === executionId) return liveBatch;
return tab.batchSqlExecution?.executionId === executionId ? tab.batchSqlExecution : undefined;
}
function clearLiveBatchSqlExecution(tab: QueryTab, executionId: string) {
if (liveBatchSqlExecutions.get(tab)?.executionId === executionId) liveBatchSqlExecutions.delete(tab);
}
function createBatchSqlExecution(executionId: string, editorSql: string, submittedSql: string, databaseType: DatabaseType | undefined, sourceOffset: number | undefined, executionTarget: MultiDbExecutionTarget, parameterOptions?: SqlParameterOptions): BatchSqlExecution | undefined {
const statements = databaseType === "mongodb" ? splitMongoCommandRanges(submittedSql).map(({ from, to, text }) => ({ from, to, sql: text })) : splitSqlStatementRanges(submittedSql, databaseType, parameterOptions);
if (statements.length === 0) return undefined;
if (statements.length > 1 && databaseType && NON_STREAMING_BATCH_DATABASE_TYPES.has(databaseType)) return undefined;
const offset = sourceOffset ?? 0;
return {
executionId,
submittedSql,
editorFingerprint: sqlTextFingerprint(editorSql),
sourceOffset: offset,
completed: 0,
total: statements.length,
startedAt: Date.now(),
executionTarget: { ...executionTarget },
items: statements.map((statement, statementIndex) => ({
statementIndex,
sql: statement.sql,
from: offset + statement.from,
to: offset + statement.to,
status: statementIndex === 0 ? "running" : "pending",
})),
};
}
function applyBatchSqlProgress(
tab: QueryTab,
progress: {
executionId: string;
statementIndex: number;
completed: number;
total: number;
success: boolean;
executionTimeMs: number;
affectedRows: number;
error?: BackendError;
},
continueOnError: boolean,
statementOffset = 0,
) {
const batch = batchSqlExecutionFor(tab, progress.executionId);
if (!batch) return;
const statementIndex = statementOffset + progress.statementIndex;
const item = batch.items[statementIndex];
if (!item) return;
// Statements complete in order and the desktop backend coalesces successful
// ones, so an event also settles every statement since the previous event.
// Walking only that gap and counting incrementally keeps a batch linear;
// rescanning every statement per event made large scripts O(N²).
const rawBatch = toRaw(batch);
const settledStart = Math.max(statementOffset, batchSqlProgressSettledEnds.get(rawBatch) ?? statementOffset);
const settledEnd = statementOffset + progress.completed - 1;
let completed = batch.completed;
for (let index = settledStart; index < settledEnd; index += 1) {
const completedItem = batch.items[index];
if (completedItem && (completedItem.status === "pending" || completedItem.status === "running")) {
completedItem.status = "success";
completed += 1;
}
}
if (settledEnd > settledStart) batchSqlProgressSettledEnds.set(rawBatch, settledEnd);
if (!isCompletedBatchStatement(item)) completed += 1;
item.status = progress.success ? "success" : "error";
item.executionTimeMs = progress.executionTimeMs;
item.affectedRows = progress.affectedRows;
item.errorDetails = progress.error;
item.error = progress.error ? translateBackendError(i18n.global.t, progress.error) : undefined;
batch.completed = completed;
if ((progress.success || continueOnError) && progress.completed < progress.total) {
const next = batch.items[statementOffset + progress.completed];
if (next?.status === "pending") next.status = "running";
}
}
function reconcileBatchSqlResults(tab: QueryTab, executionId: string, results: QueryResult[]) {
const batch = batchSqlExecutionFor(tab, executionId);
if (!batch) return;
let fallbackIndex = 0;
for (const result of results) {
const statementIndex = Number.isInteger(result.statement_index) && result.statement_index! >= 0 ? result.statement_index! : fallbackIndex;
fallbackIndex = Math.max(fallbackIndex, statementIndex + 1);
const item = batch.items[statementIndex];
if (!item) continue;
const failed = result.execution_error === true;
item.status = failed ? "error" : "success";
item.executionTimeMs = result.execution_time_ms;
item.affectedRows = result.affected_rows;
item.errorDetails = failed ? result.error : undefined;
item.error = failed ? (result.error ? translateBackendError(i18n.global.t, result.error, result.rows[0]?.[0]) : String(result.rows[0]?.[0] ?? "")) : undefined;
}
batch.completed = batch.items.filter(isCompletedBatchStatement).length;
}
function failBatchSqlExecution(tab: QueryTab, executionId: string, error: unknown, cancelled: boolean) {
const batch = batchSqlExecutionFor(tab, executionId);
if (!batch) return;
const item = batch.items.find((candidate) => candidate.status === "running") ?? batch.items.find((candidate) => candidate.status === "pending");
if (!item) return;
item.status = cancelled ? "cancelled" : "error";
item.errorDetails = cancelled ? undefined : (normalizeBackendError(error) ?? undefined);
item.error = cancelled ? undefined : translateBackendError(i18n.global.t, error, error instanceof Error ? error.message : undefined);
batch.completed = batch.items.filter(isCompletedBatchStatement).length;
}
function finishBatchSqlExecution(tab: QueryTab, executionId: string, cancelled: boolean) {
const batch = batchSqlExecutionFor(tab, executionId);
if (!batch) return;
if (cancelled) {
const cancelledError = [...batch.items].reverse().find((item) => item.status === "error" && (item.errorDetails?.code === "DBX-JDBC-2003" || /cancel|取消/i.test(item.error ?? "")));
if (cancelledError) {
cancelledError.status = "cancelled";
cancelledError.error = undefined;
}
}
let markedCancelled = false;
for (const item of batch.items) {
if (item.status === "running" && cancelled && !markedCancelled) {
item.status = "cancelled";
markedCancelled = true;
} else if (item.status === "running" || item.status === "pending") {
item.status = "skipped";
}
}
batch.completed = batch.items.filter(isCompletedBatchStatement).length;
batch.finishedAt = Date.now();
}
function sqlStatementWithoutLeadingComments(statement: string | undefined): string {
let remaining = statement?.trimStart() ?? "";
while (remaining) {
if (remaining.startsWith("--")) {
const newline = remaining.indexOf("\n");
remaining = newline < 0 ? "" : remaining.slice(newline + 1).trimStart();
continue;
}
if (remaining.startsWith("/*")) {
const end = remaining.indexOf("*/", 2);
if (end < 0) return "";
remaining = remaining.slice(end + 2).trimStart();
continue;
}
break;
}
return remaining;
}
function isOracleCurrentSchemaStatement(statement: string | undefined): boolean {
return /^ALTER\s+SESSION\s+SET\s+CURRENT_SCHEMA\s*=/i.test(sqlStatementWithoutLeadingComments(statement));
}
function isSapHanaSetSchemaStatement(statement: string | undefined): boolean {
return /^SET\s+SCHEMA\s+(?:"(?:[^"]|"")*"|[A-Za-z_][\w$#]*)\s*;?\s*$/i.test(sqlStatementWithoutLeadingComments(statement));
}
function sapHanaCurrentSchemaFromResult(result: QueryResult): string | undefined {
const schema = result.rows[0]?.[0];
return typeof schema === "string" && schema.trim() ? schema.trim() : undefined;
}
function annotateQueryResultSource(result: QueryResult, sourceStatement: string, database?: string, databaseType?: DatabaseType, sourceRange?: { from: number; to: number }): QueryResult {
result.sourceStatement = sourceStatement;
if (sourceRange) {
result.sourceFrom = sourceRange.from;
result.sourceTo = sourceRange.to;
}
const parts = databaseType ? queryResultSourceNameParts(sourceStatement, { database, databaseType }) : undefined;
if (parts) {
// 同时保留结构化来源信息:结果集页签可以按设置只展示对象名,避免退化成字符串切割
result.sourceQualifier = parts.qualifier;
result.sourceName = parts.name;
const label = parts.qualifier ? `${parts.qualifier}.${parts.name}` : parts.name;
if (label) result.sourceLabel = label;
}
return result;
}
export function canonicalizeQueryResultSourceLabel(currentLabel: string, sourceStatement: string, tableMeta: { tableName: string; schema?: string; database?: string }, options: { database?: string; databaseType?: DatabaseType } = {}): string | undefined {
const canonicalTableName = tableMeta.tableName?.trim();
if (!canonicalTableName) return undefined;
const autoLabel = queryResultSourceLabel(sourceStatement, options);
if (!autoLabel || autoLabel.toLowerCase() !== currentLabel.toLowerCase()) {
return undefined;
}
const lastDot = currentLabel.lastIndexOf(".");
if (lastDot < 0) {
return canonicalTableName;
}
const existingQualifier = currentLabel.slice(0, lastDot);
const lowerQualifier = existingQualifier.toLowerCase();
let qualifier = existingQualifier;
if (tableMeta.schema?.trim() && tableMeta.schema.trim().toLowerCase() === lowerQualifier) {
qualifier = tableMeta.schema.trim();
} else if (tableMeta.database?.trim() && tableMeta.database.trim().toLowerCase() === lowerQualifier) {
qualifier = tableMeta.database.trim();
}
return `${qualifier}.${canonicalTableName}`;
}
function elasticsearchHttpErrorStatus(result: QueryResult): number | undefined {
const statusIndex = result.columns.findIndex((column) => column.toLowerCase() === "status");
if (statusIndex < 0) return undefined;
const value = result.rows[0]?.[statusIndex];
const status = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
return Number.isInteger(status) && status >= 400 ? status : undefined;
}
function displayedQueryMetadataSql(tab: QueryTab, fallbackSql: string): string {
return tab.results?.length ? (tab.result?.sourceStatement ?? fallbackSql) : fallbackSql;
}
async function withFrontendQueryTimeout<T>(promise: Promise<T>, timeoutSecs: number, message: string, onTimeout?: () => void): Promise<T> {
const timeoutMs = frontendQueryTimeoutDelayMs(timeoutSecs);
if (timeoutMs === undefined) return promise;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => {
onTimeout?.();
reject(new Error(message));
}, timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
async function withCancelQueryTimeout<T>(promise: Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("Cancel request timed out after 10s.")), CANCEL_QUERY_TIMEOUT_MS);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
async function waitForOracleQueryMetadataPreflight<T>(promise: Promise<T>): Promise<T | typeof ORACLE_QUERY_METADATA_PREFLIGHT_TIMEOUT> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<typeof ORACLE_QUERY_METADATA_PREFLIGHT_TIMEOUT>((resolve) => {
timer = setTimeout(() => resolve(ORACLE_QUERY_METADATA_PREFLIGHT_TIMEOUT), ORACLE_QUERY_METADATA_PREFLIGHT_BUDGET_MS);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
function normalizeUppercaseFoldedMetadataIdentifier(dbType: string, identifier: string | undefined, quoted?: boolean) {
if (!identifier || quoted || !UPPERCASE_FOLDED_METADATA_TYPES.has(dbType)) return identifier;
return identifier.toUpperCase();
}
function normalizeUppercaseFoldedQueryAnalysis(dbType: string, analysis: EditableQueryInfo, schema: string | undefined, tableName: string): EditableQueryInfo {
if (!UPPERCASE_FOLDED_METADATA_TYPES.has(dbType)) return analysis;
return {
...analysis,
schema,
tableName,
sources: analysis.sources?.map((source) => ({
...source,
schema: normalizeUppercaseFoldedMetadataIdentifier(dbType, source.schema, source.schemaQuoted),
tableName: normalizeUppercaseFoldedMetadataIdentifier(dbType, source.tableName, source.tableNameQuoted)!,
})),
columns: analysis.columns.map((column) => ({
...column,
sourceName: normalizeUppercaseFoldedMetadataIdentifier(dbType, column.sourceName, column.sourceNameQuoted),
})),
};
}
function editableQuerySources(analysis: EditableQueryInfo): EditableQuerySource[] {
return analysis.sources?.length
? analysis.sources
: [
{
key: `${analysis.tableAlias ?? analysis.tableName}:0`,
catalog: analysis.catalog,
catalogQuoted: analysis.catalogQuoted,
schema: analysis.schema,
schemaQuoted: analysis.schemaQuoted,
tableName: analysis.tableName,
tableNameQuoted: analysis.tableNameQuoted,
alias: analysis.tableAlias,
},
];
}
function projectsAllColumnsForSource(analysis: EditableQueryInfo, sourceKey: string): boolean {
return analysis.selectStar || analysis.columns.some((column) => column.star && (!column.sourceKey || column.sourceKey === sourceKey));
}
function oracleQueryProjectsDeferredLob(analysis: EditableQueryInfo, sourceKey: string, columns: readonly { name: string; data_type: string }[]): boolean {
const deferredColumns = new Set(columns.filter((column) => ORACLE_DEFERRED_LOB_TYPES.has(column.data_type.trim().toUpperCase())).map((column) => column.name.toLowerCase()));
if (deferredColumns.size === 0) return false;
if (projectsAllColumnsForSource(analysis, sourceKey)) return true;
return analysis.columns.some((column) => column.sourceName && column.sourceKey === sourceKey && deferredColumns.has(column.sourceName.toLowerCase()));
}
function oracleColumnsAllowDeferredLobMarkers(columns: readonly { name: string }[]): boolean {
return !columns.some((column) => column.name.toUpperCase().startsWith("__DBX_LARGE_VALUE_BYTES_"));
}
function cloneAnalysisForSource(analysis: EditableQueryInfo, source: EditableQuerySource): EditableQueryInfo {
return {
...analysis,
catalog: source.catalog,
catalogQuoted: source.catalogQuoted,
schema: source.schema,
schemaQuoted: source.schemaQuoted,
tableName: source.tableName,
tableNameQuoted: source.tableNameQuoted,
tableAlias: source.alias,
editableSourceKey: source.key,
allowInsertDelete: analysis.sources?.length || analysis.distinct ? false : analysis.allowInsertDelete,
};
}
function resolveSourceColumnName(dbType: string, columnName: string, quoted: boolean | undefined, tableColumns: readonly { name: string }[]): string | undefined {
return resolveMetadataColumnName(
dbType,
columnName,
quoted,
tableColumns.map((column) => column.name),
);
}
function bindColumnsForSource(
dbType: string,
analysis: EditableQueryInfo,
source: EditableQuerySource,
tableColumns: readonly { name: string }[],
allSourceColumns: Array<{ source: EditableQuerySource; columns: readonly { name: string }[] }> = [{ source, columns: tableColumns }],
): EditableQueryInfo {
return {
...analysis,
columns: analysis.columns.map((column) => {
if (!column.sourceName) return column;
if (column.sourceKey) {
if (column.sourceKey !== source.key) return column;
if (dbType === "oracle" && !column.sourceNameQuoted && column.sourceName.toUpperCase() === "ROWID") return column;
const canonicalName = resolveSourceColumnName(dbType, column.sourceName, column.sourceNameQuoted, tableColumns);
return { ...column, sourceName: canonicalName };
}
if (column.sourceQualifier) return column;
const matchingSources = allSourceColumns.flatMap((entry) => {
const canonicalName = resolveSourceColumnName(dbType, column.sourceName!, column.sourceNameQuoted, entry.columns);
return canonicalName ? [{ source: entry.source, canonicalName }] : [];
});
if (matchingSources.length !== 1 || matchingSources[0]?.source.key !== source.key) return column;
return { ...column, sourceName: matchingSources[0].canonicalName, sourceKey: source.key };
}),
};
}
function primaryKeysPresentForSource(dbType: string, primaryKeys: string[], resultColumns: string[], analysis: EditableQueryInfo, sourceKey: string, tableColumns: readonly { name: string }[]): boolean {
if (!analysis.selectStar) return allPrimaryKeysPresent(primaryKeys, resultColumns, analysis, sourceKey, dbType as DatabaseType);
const metadataNames = tableColumns.map((column) => column.name);
const canonicalResultColumns = resultColumns.flatMap((column) => {
const canonicalName = resolveMetadataColumnName(dbType, column, undefined, metadataNames);
return canonicalName ? [canonicalName] : [];
});
return allPrimaryKeysPresent(primaryKeys, canonicalResultColumns);
}
function expandStarProjectionColumnsForSource(analysis: EditableQueryInfo, source: EditableQuerySource, tableColumns: readonly { name: string }[]): EditableQueryInfo {
if (analysis.selectStar || !analysis.columns.some((column) => column.star)) return analysis;
return {
...analysis,
columns: analysis.columns.flatMap((column) => {
if (!column.star) return [column];
if (column.sourceKey && column.sourceKey !== source.key) return [column];
return tableColumns.map((tableColumn) => ({
sourceName: tableColumn.name,
sourceNameQuoted: false,
...(column.sourceQualifier ? { sourceQualifier: column.sourceQualifier } : {}),
sourceKey: source.key,
resultName: tableColumn.name,
expression: column.sourceQualifier ? `${column.sourceQualifier}.${tableColumn.name}` : tableColumn.name,
}));
}),
};
}
let saveTabsQueue = Promise.resolve();
let persistTimer: ReturnType<typeof setTimeout> | null = null;
let persistGeneration = 0;
/** Monotonic id for content-search jump requests; lets repeated clicks on the same result re-trigger the editor reveal. */
let contentRevealSeq = 0;
function saveTabs(tabs: QueryTab[], activeTabId: string | null, workspace?: EditorWorkspacePersistState): Promise<void> {
if (isDetachedWindow()) return Promise.resolve();
const payload: OpenTabsStatePayload = {
tabs: serializeOpenTabs(tabs),
activeTabId,
...(workspace && tabs.length > 0
? {
groups: workspace.groups.map((group) => ({ ...group, tabIds: [...group.tabIds] })),
focusedGroupId: workspace.focusedGroupId,
orientation: workspace.orientation,
sizes: [...workspace.sizes],
}
: {}),
};
saveTabsQueue = saveTabsQueue.catch(() => undefined).then(() => api.saveOpenTabsState(payload));
return saveTabsQueue;
}
function loadLegacySavedTabs(): { rawTabs: string | null; rawActiveTabId: string | null } {
return {
rawTabs: safeLocalStorageGet(OPEN_TABS_STORAGE_KEY),
rawActiveTabId: safeLocalStorageGet(ACTIVE_TAB_STORAGE_KEY),
};
}
function clearLegacySavedTabs() {
safeLocalStorageRemove(OPEN_TABS_STORAGE_KEY);
safeLocalStorageRemove(ACTIVE_TAB_STORAGE_KEY);
}
function restoreSavedTabsFromPayload(
payload: { tabs?: unknown; activeTabId?: unknown; groups?: unknown; focusedGroupId?: unknown; orientation?: unknown; sizes?: unknown } | null | undefined,
options: { validConnectionIds?: Iterable<string> } = {},
): { tabs: QueryTab[]; activeTabId: string | null; workspace?: unknown } {
const restoreMode = safeLocalStorageGet(UPDATE_RESTORE_KEY) === "1" ? "all" : useSettingsStore().editorSettings.openTabsRestoreMode;
if (restoreMode === "none") {
return { tabs: [], activeTabId: null, workspace: undefined };
}
const restored = restoreOpenTabsPayload(payload, {
filter: restoreMode === "pinned" ? "pinned" : "all",
validConnectionIds: options.validConnectionIds,
});
return {
...restored,
workspace:
payload && "groups" in payload
? {
groups: payload.groups,
focusedGroupId: payload.focusedGroupId,
orientation: payload.orientation,
sizes: payload.sizes,
}
: undefined,
};
}
function restoreLegacySavedTabs(options: { validConnectionIds?: Iterable<string> } = {}): { tabs: QueryTab[]; activeTabId: string | null; workspace?: undefined } {
const restoreMode = safeLocalStorageGet(UPDATE_RESTORE_KEY) === "1" ? "all" : useSettingsStore().editorSettings.openTabsRestoreMode;
if (restoreMode === "none") {
return { tabs: [], activeTabId: null, workspace: undefined };
}
const legacy = loadLegacySavedTabs();
return {
...restoreOpenTabsState(legacy.rawTabs, legacy.rawActiveTabId, {
filter: restoreMode === "pinned" ? "pinned" : "all",
validConnectionIds: options.validConnectionIds,
}),
workspace: undefined,
};
}
function getI18nT() {
try {
return useI18n().t;
} catch {
return ((key: string, ..._args: unknown[]) => key) as ReturnType<typeof useI18n>["t"];
}
}
/** Mirrors the MySQL auto-commit settlement the backend reported for this
* execution onto the tab. Absent markers (non-MySQL connections, executions
* that never touched a tab connection) leave the previous state untouched.
*
* The two rollback notices are deliberately different: a `BEGIN` the user
* typed is reported on every execution (the tab just lost real work), while a
* session sitting on `SET autocommit = 0` has an implicit transaction rolled
* back after *every* execution — that one is raised once per connection and
* re-armed only after the connection stops reporting it, so dismissing it
* does not bring it back on the next keystroke batch. */
export function applyAutoCommitTransactionReport(tab: QueryTab, results: QueryResult[]) {
const openTransaction = results.find((result) => result.auto_commit_open_transaction !== undefined)?.auto_commit_open_transaction;
if (openTransaction !== undefined) tab.autoCommitOpenTransaction = openTransaction;
const explicitRolledBack = results.some((result) => result.auto_commit_explicit_transaction_rolled_back === true);
const sessionRolledBack = results.some((result) => result.auto_commit_session_autocommit_rolled_back === true);
if (explicitRolledBack) {
tab.autoCommitTxnRolledBack = true;
tab.autoCommitSessionTxnRolledBackNotified = false;
return;
}
if (sessionRolledBack) {
if (!tab.autoCommitSessionTxnRolledBackNotified) {
tab.autoCommitSessionTxnRolledBack = true;
tab.autoCommitSessionTxnRolledBackNotified = true;
}
return;
}
// This execution rolled nothing back: the auto-commit-off session may be gone
// (or the execution never used the tab connection), so re-arm the notice for
// the next time it happens.
tab.autoCommitSessionTxnRolledBackNotified = false;
}
export const useQueryStore = defineStore("query", () => {
const redisMonitors = new Map<string, () => void>();
const t = getI18nT();
const settingsStore = useSettingsStore();
const tabs = ref<QueryTab[]>([]);
// New tabs are created by many feature-specific entry points. Tracking id
// changes here gives every one a stable timestamp without duplicating the
// field at each call site; restored legacy tabs retain their saved order.
function ensureTabCreationTimes() {
const migrationBase = Date.now() - tabs.value.length;
tabs.value.forEach((tab, index) => {
if (typeof tab.createdAt !== "number" || !Number.isFinite(tab.createdAt)) tab.createdAt = migrationBase + index;
});
}
watch(() => tabs.value.map((tab) => tab.id), ensureTabCreationTimes, { immediate: true, flush: "sync" });
// 重名标签的编号同样在这里集中分配:编号只在标签首次重名时发一次,关闭标签不会
// 让幸存标签改名(#9938),新建的标签接着最大编号往后排。放在 store 的 watcher
// 里而不是渲染函数里,是为了避免在 computed 求值过程中写标签状态;getter 只读
// 参与标题计算的字段(id + 显示标题),分配编号本身不会再次触发它。
watch(
() => tabs.value.map((tab) => `${tab.id}\u0000${tabDisplayTitle(tab, t)}`),
() => syncTabTitleNumbers(tabs.value, t),
{ immediate: true, flush: "sync" },
);
// Default auto-commit for a fresh query tab follows the user's global
// "默认事务提交方式" setting (Settings > Editor), not a hard-coded constant.
const defaultAutoCommitForDbTypeWithSetting = (dbType?: string) => defaultAutoCommitForDbType(dbType, settingsStore.editorSettings.defaultTransactionMode);
// A stable Set of "connectionId\x00database" keys. Computed only from the
// minimal tab identity fields so that it does NOT invalidate when other
// properties change (isExecuting, result, sql, tableMeta...). Previously
// isDatabaseOpen() called tabs.value.some() which tracked the full reactive
// array — every mutation during openData() forced all database-type sidebar
// TreeItems to recompute showsDatabaseOpenIndicator.
const openDatabaseKeys = computed(() => {
const keys = new Set<string>();
for (const tab of tabs.value) {
if (tab.connectionId && tab.database != null) {
keys.add(`${tab.connectionId}\x00${tab.database}`);
}
}
return keys;
});
const activeTabId = ref<string | null>(null);
const groups = ref<EditorGroup[]>([{ id: "main", tabIds: [], activeTabId: null }]);
const focusedGroupId = ref("main");
const orientation = ref<"vertical" | "horizontal">("vertical");
const sizes = ref<number[]>([100]);
const isOpenTabsLoaded = ref(false);
const activeTabHistory = ref<string[]>([]);
// Most-recently-activated tab ids, oldest first. Read-only view for the
// Ctrl+Tab switcher, which renders them in reverse.
const recentTabIds = computed(() => activeTabHistory.value);
function findGroup(groupId: string): EditorGroup | undefined {
return groups.value.find((group) => group.id === groupId);
}
function focusedGroup(): EditorGroup {
return findGroup(focusedGroupId.value) ?? groups.value[0];
}
function groupForTab(tabId: string): EditorGroup | undefined {
return groups.value.find((group) => group.tabIds.includes(tabId));
}
function syncActiveTabFromFocusedGroup() {
const group = focusedGroup();
if (!group) {
return;
}
if (group.activeTabId && tabs.value.some((tab) => tab.id === group.activeTabId)) {
activeTabId.value = group.activeTabId;
return;
}
const firstAvailable = groups.value.find((candidate) => candidate.tabIds.length > 0)?.tabIds.find((id) => tabs.value.some((tab) => tab.id === id)) ?? tabs.value[0]?.id ?? null;
if (firstAvailable) {
activeTabId.value = firstAvailable;
if (group.tabIds.includes(firstAvailable)) {
group.activeTabId = firstAvailable;
}
} else {
group.activeTabId = null;
activeTabId.value = null;
}
}
function normalizeGroups() {
const validIds = new Set(tabs.value.map((tab) => tab.id));
// Group ids must be unique and non-empty; keep only the first occurrence.
const seenGroupIds = new Set<string>();
groups.value = groups.value.filter((group) => {
if (!group.id || seenGroupIds.has(group.id)) {
return false;
}
seenGroupIds.add(group.id);
return true;
});
// Enforce the four-group limit before assigning missing tabs, so tabs from
// dropped groups are merged into a kept group instead of becoming ownerless.
if (groups.value.length > 1) {
groups.value = groups.value.filter((group) => group.tabIds.length > 0);
}
if (groups.value.length > 4) {
const target = groups.value[0];
for (const group of groups.value.slice(4)) {
target.tabIds.push(...group.tabIds);
}
groups.value = groups.value.slice(0, 4);
}
// A tab may only belong to the first group that claims it.
const globallyAssigned = new Set<string>();
for (const group of groups.value) {
const nextTabIds: string[] = [];
for (const id of group.tabIds) {
if (!validIds.has(id) || globallyAssigned.has(id)) {
continue;
}
globallyAssigned.add(id);
nextTabIds.push(id);
}
group.tabIds = nextTabIds;
if (group.activeTabId && !group.tabIds.includes(group.activeTabId)) {
group.activeTabId = group.tabIds[0] ?? null;
}
}
const assigned = new Set(groups.value.flatMap((group) => group.tabIds));
const missing = tabs.value.filter((tab) => !assigned.has(tab.id));
if (missing.length > 0) {
const target = focusedGroup() ?? groups.value[0];
if (target) {
for (const tab of missing) {
target.tabIds.push(tab.id);
}
}
}
if (groups.value.length > 1) {
groups.value = groups.value.filter((group) => group.tabIds.length > 0);
}
if (groups.value.length === 0) {
groups.value = [{ id: "main", tabIds: [], activeTabId: null }];
}
if (!groups.value.some((group) => group.id === focusedGroupId.value)) {
focusedGroupId.value = groups.value[0].id;
}
// Restore boundary order (guide §3.9): repair each group's active tab,
// then repair sizes, and only at the very end derive the global active tab
// from the focused group. A stale persisted activeTabId must never pull
// focus (or the group active) toward another group.
for (const group of groups.value) {
if (!group.activeTabId || !group.tabIds.includes(group.activeTabId)) {
group.activeTabId = group.tabIds[0] ?? null;
}
}
repairGroupSizes();
syncActiveTabFromFocusedGroup();
}
/** Keeps `sizes` aligned with the current group count after structural changes. */
function repairGroupSizes() {
const sizeCount = groups.value.length;
const validSizes = Array.isArray(sizes.value) && sizes.value.length === sizeCount && sizes.value.every((size) => Number.isFinite(size) && size > 0);
if (!validSizes) {
const each = Math.floor(100 / sizeCount);
sizes.value = groups.value.map((_, index) => (index === sizeCount - 1 ? 100 - each * (sizeCount - 1) : each));
return;
}
const total = sizes.value.reduce((sum, size) => sum + size, 0);
if (total <= 0) {
const each = Math.floor(100 / sizeCount);
sizes.value = groups.value.map((_, index) => (index === sizeCount - 1 ? 100 - each * (sizeCount - 1) : each));
} else if (Math.abs(total - 100) > 0.01) {
sizes.value = sizes.value.map((size) => (size / total) * 100);
}
}
/**
* Drops groups that no longer own any tab, keeps one (possibly empty) main
* group, re-points the focused group, and re-pairs sizes. Structural store
* operations call this directly instead of leaning on a full normalization
* pass or a post-flush watcher.
*/
function pruneEmptyGroups() {
if (groups.value.length > 1) {
groups.value = groups.value.filter((group) => group.tabIds.length > 0);
}
if (groups.value.length === 0) {
groups.value = [{ id: "main", tabIds: [], activeTabId: null }];
}
if (!groups.value.some((group) => group.id === focusedGroupId.value)) {
focusedGroupId.value = groups.value[0].id;
}
repairGroupSizes();
}
function focusGroup(groupId: string) {
const group = findGroup(groupId) ?? groups.value[0];
if (!group) {
return;
}
focusedGroupId.value = group.id;
const candidate = group.activeTabId && tabs.value.some((tab) => tab.id === group.activeTabId) ? group.activeTabId : (group.tabIds.find((tabId) => tabs.value.some((tab) => tab.id === tabId)) ?? null);
if (candidate) {
group.activeTabId = candidate;
activeTabId.value = candidate;
}
// If the focused group has no valid active tab, keep the current global
// active tab rather than clearing it and unmounting the workspace.
settingsStore.settingsPageActive = false;
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(QUERY_SURFACE_ACTIVATION_EVENT));
}
}
function activateTabInGroup(groupId: string, tabId: string) {
const group = findGroup(groupId);
if (!group || !group.tabIds.includes(tabId)) {
return;
}
group.activeTabId = tabId;
focusedGroupId.value = group.id;
activeTabId.value = tabId;
settingsStore.settingsPageActive = false;
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(QUERY_SURFACE_ACTIVATION_EVENT));
}
}
function activateTab(tabId: string): boolean {
const owner = groupForTab(tabId);
if (!owner) {
return false;
}
owner.activeTabId = tabId;
focusedGroupId.value = owner.id;
activeTabId.value = tabId;
settingsStore.settingsPageActive = false;
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(QUERY_SURFACE_ACTIVATION_EVENT));
}
return true;
}
function initializeResultAutoSave(tab: QueryTab) {
if (tab.mode === "query" && tab.resultAutoSave === undefined && settingsStore.editorSettings.defaultAutoKeepResults === true) {
tab.resultAutoSave = true;
}
}
// Apply after the settings mutation completes, never during tab registration
// or restoration while the workspace still has incomplete group membership.
watch(
() => settingsStore.editorSettings.defaultAutoKeepResults,
(enabled) => {
for (const tab of tabs.value) {
if (tab.mode === "query") setResultAutoSave(tab, enabled === true);
}
},
);
/**
* Atomically registers a freshly built tab: the tab joins the focused group
* and (by default) becomes that group's active tab and the global active tab
* in one synchronous step. Programmatic open paths must use this instead of
* pushing to `tabs` and writing `activeTabId` separately, which used to leave
* the new tab ownerless until a post-flush watcher repaired the groups.
*/
function registerOpenTab(tab: QueryTab, options: { activate?: boolean; insertAfterTabId?: string } = {}): string {
assertUpdateAllowsInteraction();
initializeResultAutoSave(tab);
const anchorIndex = options.insertAfterTabId ? tabs.value.findIndex((item) => item.id === options.insertAfterTabId) : -1;
if (anchorIndex >= 0) {
tabs.value.splice(anchorIndex + 1, 0, tab);
} else {
tabs.value.push(tab);
}
const group = focusedGroup() ?? groups.value[0];
if (group && !group.tabIds.includes(tab.id)) {
const groupAnchorIndex = options.insertAfterTabId ? group.tabIds.indexOf(options.insertAfterTabId) : -1;
if (groupAnchorIndex >= 0) {
// Fresh tabs are never pinned; keep them behind the group's pinned block.
const pinnedCount = group.tabIds.filter((id) => tabs.value.find((item) => item.id === id)?.pinned).length;
const insertAt = tab.pinned ? groupAnchorIndex + 1 : Math.max(groupAnchorIndex + 1, pinnedCount);
group.tabIds.splice(insertAt, 0, tab.id);
} else {
group.tabIds.push(tab.id);
}
}
if (options.activate !== false) {
activateTab(tab.id);
}
return tab.id;
}
function splitTab(tabId: string, direction: "right" | "down"): boolean {
if (groups.value.length >= 4) {
return false;
}
// With a single open tab the split is a no-op: the tab moves into the new
// group, the emptied source group is pruned, and the layout returns to one
// group holding the same tab (only the group identity churns).
if (tabs.value.length <= 1) {
return false;
}
const tab = tabs.value.find((item) => item.id === tabId);
if (!tab) {
return false;
}
const owner = groupForTab(tabId);
if (!owner) {
return false;
}
orientation.value = direction === "right" ? "vertical" : "horizontal";
const newGroupId = uuid();
const newGroup: EditorGroup = { id: newGroupId, tabIds: [tabId], activeTabId: tabId };
const ownerIndex = groups.value.findIndex((group) => group.id === owner.id);
owner.tabIds = owner.tabIds.filter((id) => id !== tabId);
if (owner.activeTabId === tabId) {
owner.activeTabId = owner.tabIds[0] ?? null;
}
groups.value.splice(ownerIndex + 1, 0, newGroup);
// Removing the tab may leave the source group empty; pruning keeps sizes
// paired with the surviving groups without a full normalization pass.
pruneEmptyGroups();
focusedGroupId.value = newGroup.id;
activeTabId.value = tabId;
return true;
}
function splitTabRight(tabId: string): boolean {
return splitTab(tabId, "right");
}
function splitTabDown(tabId: string): boolean {
return splitTab(tabId, "down");
}
function setOrientation(value: "vertical" | "horizontal") {
orientation.value = value;
}
function moveTabToGroup(tabId: string, targetGroupId: string, index?: number): boolean {
const tab = tabs.value.find((item) => item.id === tabId);
const target = findGroup(targetGroupId);
if (!tab || !target) {
return false;
}
const source = groupForTab(tabId);
if (!source) {
return false;
}
function pinnedCountIn(tabIds: string[]): number {
return tabIds.filter((id) => tabs.value.find((item) => item.id === id)?.pinned).length;
}
if (source.id === target.id) {
if (index === undefined) {
return false;
}
const fromIndex = target.tabIds.indexOf(tabId);
if (fromIndex < 0) {
return false;
}
const next = [...target.tabIds];
next.splice(fromIndex, 1);
let insertAt = index;
if (fromIndex < insertAt) {
insertAt -= 1;
}
const pinnedCount = pinnedCountIn(next);
if (tab.pinned) {
insertAt = Math.max(0, Math.min(insertAt, pinnedCount));
} else {
insertAt = Math.max(pinnedCount, Math.min(insertAt, next.length));
}
next.splice(insertAt, 0, tabId);
target.tabIds = next;
return true;
}
source.tabIds = source.tabIds.filter((id) => id !== tabId);
if (source.activeTabId === tabId) {
source.activeTabId = source.tabIds[0] ?? null;
}
const pinnedCount = pinnedCountIn(target.tabIds);
const rawIndex = index ?? target.tabIds.length;
const insertAt = tab.pinned ? Math.max(0, Math.min(rawIndex, pinnedCount)) : Math.max(pinnedCount, Math.min(rawIndex, target.tabIds.length));
target.tabIds.splice(insertAt, 0, tabId);
target.activeTabId = tabId;
focusedGroupId.value = target.id;
activeTabId.value = tabId;
pruneEmptyGroups();
return true;
}
function unsplitTab(tabId: string): boolean {
const main = groups.value[0];
if (!main) {
return false;
}
if (main.tabIds.includes(tabId)) {
focusGroup(main.id);
return true;
}
const source = groupForTab(tabId);
if (!source) {
return false;
}
source.tabIds = source.tabIds.filter((id) => id !== tabId);
if (source.activeTabId === tabId) {
source.activeTabId = source.tabIds[0] ?? null;
}
main.tabIds.push(tabId);
main.activeTabId = tabId;
focusedGroupId.value = main.id;
activeTabId.value = tabId;
pruneEmptyGroups();
return true;
}
const showCloseConfirm = ref(false);
const pendingCloseTabId = ref<string | null>(null);
const pendingBatchCloseTabIds = ref<string[] | null>(null);
const pendingBatchCloseFinalActiveTabId = ref<string | null | undefined>(undefined);
let pendingBatchCloseComplete: (() => void) | null = null;
const isConfirmingAppClose = ref(false);
const closeConfirmContext = ref<CloseConfirmContext>("tab");
const tableStructureRefreshVersions = ref<Record<string, number>>({});
const savedSqlEditorPositionTimers = new Map<string, ReturnType<typeof setTimeout>>();
const pendingTabSessionResets = new Map<string, Promise<void>>();
const pendingResultRunRestores = new Map<string, string>();
const pendingResultRunPreparations = new WeakMap<QueryTab, string>();
const multiDbExecutionWorkers = new Map<string, QueryTab>();
const multiDbExecutionWorkerScopes = new Map<string, Set<string>>();
let resultCacheTrimScheduled = false;
let resultCacheTrimRunning = false;
let resultCacheTrimRequested = false;
let detachedHandoffRevision = 0;
function tableStructureKey(connectionId: string, database: string, schema: string | undefined, tableName: string): string {
return [connectionId, database, schema || "", tableName].map((part) => part.toLowerCase()).join("\u0000");
}
function invalidateTableStructure(connectionId: string, database: string, schema: string | undefined, tableName: string) {
if (!tableName) return;
const key = tableStructureKey(connectionId, database, schema, tableName);
tableStructureRefreshVersions.value = {
...tableStructureRefreshVersions.value,
[key]: (tableStructureRefreshVersions.value[key] ?? 0) + 1,
};
for (const tab of tabs.value) {
if (tab.mode !== "query" || tab.connectionId !== connectionId || tab.database !== database) continue;
tab.completionContextVersion = (tab.completionContextVersion ?? 0) + 1;
}
}
function tableStructureRefreshVersion(connectionId: string, database: string, schema: string | undefined, tableName: string): number {
return tableStructureRefreshVersions.value[tableStructureKey(connectionId, database, schema, tableName)] ?? 0;
}
const MAX_CACHED_RESULTS = 5;
const MAX_CACHED_RESULT_BYTES = 128 * 1024 * 1024;
function queryExecutionLog(level: "debug" | "info" | "warn" | "error", event: string, details: Record<string, unknown>) {
appendDebugLog(level, `[DBX][executeTabSql:${event}]`, details);
}
function findExecutionTab(id: string): QueryTab | undefined {
return tabs.value.find((tab) => tab.id === id) ?? multiDbExecutionWorkers.get(id);
}
function getExecutionTab(id: string): QueryTab | undefined {
return findExecutionTab(id);
}
function activeResultExecutionTarget(id: string): MultiDbExecutionTarget | undefined {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab?.activeResultRunId) return undefined;
const run = tab.resultRuns?.find((candidate) => candidate.id === tab.activeResultRunId);
return run?.multiDbExecution?.kind === "multi-db" ? run.multiDbExecution.target : undefined;
}
function queryResultExecutionLocation(tab: QueryTab) {
const target = activeResultExecutionTarget(tab.id);
return {
connectionId: target?.connectionId ?? tab.connectionId,
database: target?.database ?? tab.database,
schema: target?.schema ?? tab.schema,
catalog: target?.catalog ?? tab.catalog,
};
}
function createMultiDbExecutionWorker(sourceTabId: string, target: MultiDbExecutionTarget, scopeId: string): string | undefined {
const source = tabs.value.find((tab) => tab.id === sourceTabId);
if (!source) return undefined;
const id = `multi-worker-${uuid()}`;
const worker = reactive<QueryTab>({
...source,
id,
connectionId: target.connectionId,
catalog: target.catalog,
database: target.database,
schema: target.schema,
sql: source.sql,
result: undefined,
results: undefined,
activeResultIndex: undefined,
resultRuns: undefined,
activeResultRunId: undefined,
resultCacheKey: undefined,
resultSessionId: undefined,
resultClientSessionId: undefined,
resultCacheState: undefined,
resultEvicted: undefined,
isExecuting: false,
isCancelling: false,
executionId: undefined,
executingResultRunId: undefined,
queryExecutionStartedAt: undefined,
batchSqlExecution: undefined,
cancelRequestCount: 0,
autoCommit: true,
txnSessionId: undefined,
txnAutoRolledBack: undefined,
});
// Workers must not inherit result navigation or write targets from the
// source editor, including when a dedicated transaction supplies results.
clearResultPayload(worker);
clearResultNavigationState(worker);
worker.resultBaseSql = undefined;
multiDbExecutionWorkers.set(id, worker);
const workers = multiDbExecutionWorkerScopes.get(scopeId) ?? new Set<string>();
workers.add(id);
multiDbExecutionWorkerScopes.set(scopeId, workers);
return id;
}
async function removeMultiDbExecutionWorker(workerId: string, scopeId?: string): Promise<void> {
const worker = multiDbExecutionWorkers.get(workerId);
const transferredResultSessionId = worker?.resultSessionId && tabs.value.some((tab) => tab.resultRuns?.some((run) => run.resultSessionId === worker.resultSessionId)) ? worker.resultSessionId : undefined;
await closeResultSession(worker, transferredResultSessionId);
if (transferredResultSessionId && worker) {
for (const suffix of BACKGROUND_CLIENT_SESSION_SUFFIXES) {
await closeClientSessionId(worker.connectionId, worker.database, tabClientSessionId(worker, suffix), worker.catalog, { tabId: worker.id });
}
} else {
await closeClientConnectionSession(worker);
}
pendingTabSessionResets.delete(workerId);
liveBatchSqlExecutions.delete(worker as QueryTab);
multiDbExecutionWorkers.delete(workerId);
if (scopeId) {
const workers = multiDbExecutionWorkerScopes.get(scopeId);
workers?.delete(workerId);
if (workers?.size === 0) multiDbExecutionWorkerScopes.delete(scopeId);
} else {
for (const [scope, workers] of multiDbExecutionWorkerScopes) {
workers.delete(workerId);
if (workers.size === 0) multiDbExecutionWorkerScopes.delete(scope);
}
}
}
async function cancelMultiDbExecutionScope(scopeId: string): Promise<void> {
const workers = [...(multiDbExecutionWorkerScopes.get(scopeId) ?? [])];
await Promise.all(workers.map((workerId) => cancelTabExecution(workerId)));
}
async function refreshLoadedMongoIndexesAfterMutation(connectionId: string, database: string, collection: string, traceId: string) {
const connStore = useConnectionStore();
try {
await refreshLoadedMongoIndexes(connStore, { connectionId, database, collection });
} catch (error) {
queryExecutionLog("warn", "mongo-indexes:refresh-failed", {
traceId,
connectionId,
database,
collection,
error: formatError(error),
});
}
}
async function closeResultSession(tab: QueryTab | undefined, preserveSessionId?: string, throwOnError = false) {
const sessionId = tab?.resultSessionId ?? tab?.result?.session_id;
if (!tab || !sessionId || sessionId === preserveSessionId) return;
const resultClientSessionId = tab.resultClientSessionId;
const catalog = tab.mode === "data" ? tab.tableMeta?.catalog : tab.catalog;
const location = tab.mode === "query" ? queryResultExecutionLocation(tab) : { connectionId: tab.connectionId, database: tab.database, catalog };
try {
const clientSessionId = tab.resultClientSessionId ?? tab.id;
if (location.catalog) await api.closeQuerySession(location.connectionId, location.database, sessionId, clientSessionId, location.catalog);
else await api.closeQuerySession(location.connectionId, location.database, sessionId, clientSessionId);
} catch (error) {
console.warn("[DBX][query-session:close:error]", { tabId: tab.id, sessionId, error });
if (throwOnError) throw error;
} finally {
if (tab.resultSessionId === sessionId) tab.resultSessionId = undefined;
if (!tab.resultSessionId) tab.resultClientSessionId = undefined;
if (tab.result?.session_id === sessionId) {
tab.result.session_id = undefined;
// 原地修改了负载,让持有它的 tab 与 run 的估算值都失效
invalidateResultEstimateForPayload(tab.result);
}
if (resultClientSessionId && resultClientSessionId !== tab.id) {
await closeClientSessionId(location.connectionId, location.database, resultClientSessionId, location.catalog, { tabId: tab.id }, throwOnError);
}
}
}
async function closeClientSessionId(connectionId: string, database: string, clientSessionId: string, catalog: string | undefined, logContext: Record<string, unknown> = {}, throwOnError = false) {
try {
if (catalog) await api.closeClientConnectionSession(connectionId, database, clientSessionId, catalog);
else await api.closeClientConnectionSession(connectionId, database, clientSessionId);
} catch (error) {
console.warn("[DBX][client-session:close:error]", { ...logContext, clientSessionId, error });
if (throwOnError) throw error;
}
}
async function closeClientConnectionSession(tab: QueryTab | undefined, throwOnError = false) {
if (!tab?.connectionId) return;
const catalog = tab.mode === "data" ? tab.tableMeta?.catalog : tab.catalog;
const clientSessionIds = [...new Set([tabClientSessionId(tab), ...BACKGROUND_CLIENT_SESSION_SUFFIXES.map((suffix) => tabClientSessionId(tab, suffix)), tab.explainClientSessionId].filter((sessionId): sessionId is string => !!sessionId))];
for (const clientSessionId of clientSessionIds) {
await closeClientSessionId(tab.connectionId, tab.database, clientSessionId, catalog, { tabId: tab.id }, throwOnError);
}
}
function queueTabSessionReset(tab: QueryTab) {
tab.completionContextVersion = (tab.completionContextVersion ?? 0) + 1;
const previousReset = pendingTabSessionResets.get(tab.id);
const reset = (async () => {
if (previousReset) await previousReset;
// A schema reset must fail closed: reusing the old session would retain Oracle CURRENT_SCHEMA.
await closeResultSession(tab, undefined, true);
await closeClientConnectionSession(tab, true);
})();
pendingTabSessionResets.set(tab.id, reset);
const clearPendingReset = () => {
if (pendingTabSessionResets.get(tab.id) === reset) pendingTabSessionResets.delete(tab.id);
};
void reset.then(clearPendingReset, clearPendingReset);
}
async function waitForTabSessionReset(tabId: string) {
while (true) {
const pendingReset = pendingTabSessionResets.get(tabId);
if (!pendingReset) return;
await pendingReset;
if (pendingTabSessionResets.get(tabId) === pendingReset) pendingTabSessionResets.delete(tabId);
}
}
function touchResult(tab: QueryTab | undefined, accessedAt = Date.now(), options: { reuseEstimatedBytes?: boolean } = {}) {
if (tab?.result || tab?.results) {
tab.resultAccessedAt = accessedAt;
// 纯访问路径(如切换标签页)可复用已算好的估算值:estimateQueryResultsBytes
// 会同步深遍历整份结果集,挂在 sync watch 上会直接阻塞切页交互。
if (!options.reuseEstimatedBytes || tab.resultEstimatedBytes === undefined) {
tab.resultEstimatedBytes = estimateQueryResultsBytes(tab.result, tab.results);
}
tab.resultCacheState = "memory";
tab.resultEvicted = undefined;
}
}
/** 结果负载被原地修改(如保存后写回单元格)时,让持有它的 tab/run 的字节估算失效,下次访问按需重算。 */
function invalidateResultEstimateForPayload(result: QueryResult | undefined) {
if (!result) return;
for (const tab of tabs.value) {
if (tab.result === result || tab.results?.includes(result)) tab.resultEstimatedBytes = undefined;
for (const run of tab.resultRuns ?? []) {
if (run.result === result || run.results?.includes(result)) run.resultEstimatedBytes = undefined;
}
}
}
function clearResultPayload(tab: QueryTab, options: { evicted?: boolean; preserveCacheSnapshot?: boolean } = {}) {
tab.result = undefined;
tab.results = undefined;
tab.activeResultIndex = undefined;
tab.batchSqlExecution = undefined;
tab.resultEditorFingerprint = undefined;
tab.resultLocalSortOriginalRows = undefined;
tab.resultLocalSortOriginalLargeValueCells = undefined;
tab.resultLocalSortOriginalMongoDocuments = undefined;
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
tab.resultSortMode = undefined;
tab.resultSessionId = undefined;
tab.resultClientSessionId = undefined;
tab.resultAccessedAt = undefined;
tab.resultEstimatedBytes = undefined;
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.queryWriteTargets = undefined;
tab.resultColumnComments = undefined;
tab.queryDisplaySourceColumns = undefined;
tab.queryEditabilityReason = undefined;
tab.mongoEditTarget = undefined;
if (tab.mode === "query") tab.tableMeta = undefined;
tab.resultEvicted = options.evicted ? true : undefined;
tab.resultCacheState = options.evicted ? tab.resultCacheState : undefined;
if (!options.evicted) {
if (tab.resultCacheKey && !options.preserveCacheSnapshot) void deleteTabResultSnapshot(tab.resultCacheKey);
tab.resultCacheKey = undefined;
// Drop the stale view snapshot but do NOT tombstone: ordinary execution
// clears the payload before running, and the replacement result must stay
// free to capture a fresh snapshot when the user switches away. Tab
// closure (closeTab/closeTabsWhere/releaseTabsWhere) uses the tombstone.
// An evicted result keeps its snapshot so returning to the tab can replay it.
clearDataGridViewSnapshotsForTab(tab.id);
}
}
function clearResultNavigationState(tab: QueryTab) {
tab.resultSortedSql = undefined;
tab.resultSortColumn = undefined;
tab.resultSortColumnIndex = undefined;
tab.resultSortDirection = undefined;
tab.resultSortMode = undefined;
tab.resultLocalSortOriginalRows = undefined;
tab.resultLocalSortOriginalLargeValueCells = undefined;
tab.resultLocalSortOriginalMongoDocuments = undefined;
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
tab.orderByInput = undefined;
tab.resultPageSql = undefined;
tab.resultPageLimit = undefined;
tab.resultPageOffset = undefined;
tab.resultCountSql = undefined;
tab.resultTotalRowCount = undefined;
tab.resultTotalRowCountLoading = false;
tab.resultSessionId = undefined;
tab.resultClientSessionId = undefined;
}
function clearResultRunSnapshots(tab: QueryTab) {
for (const run of tab.resultRuns ?? []) {
if (run.resultSessionId) void closeResultRunSession(tab, run);
if (run.resultCacheKey) void deleteTabResultSnapshot(run.resultCacheKey);
}
}
function releaseTabResultObjectPayloads(tab: QueryTab) {
const results = new Set<QueryResult>();
if (tab.result) results.add(tab.result);
for (const result of tab.results ?? []) results.add(result);
for (const run of tab.resultRuns ?? []) {
if (run.result) results.add(run.result);
for (const result of run.results ?? []) results.add(result);
}
for (const result of results) releaseResultObjectPayload(result);
}
function clearResultRuns(tab: QueryTab) {
for (const run of tab.resultRuns ?? []) clearResultRunPayload(run);
tab.resultRuns = undefined;
tab.activeResultRunId = undefined;
}
function clearResultRunPayload(run: NonNullable<QueryTab["resultRuns"]>[number], options: { evicted?: boolean } = {}) {
if (run.result) releaseResultObjectPayload(run.result);
for (const result of run.results ?? []) releaseResultObjectPayload(result);
run.result = undefined;
run.results = undefined;
run.resultLocalSortOriginalRows = undefined;
run.resultLocalSortOriginalLargeValueCells = undefined;
run.resultLocalSortOriginalMongoDocuments = undefined;
run.resultLocalSortOriginalMongoCopyDocuments = undefined;
run.resultSessionId = undefined;
run.resultClientSessionId = undefined;
run.resultEstimatedBytes = undefined;
run.queryAnalysis = undefined;
run.querySourceColumns = undefined;
run.queryWriteTargets = undefined;
run.resultColumnComments = undefined;
run.queryDisplaySourceColumns = undefined;
run.queryEditabilityReason = undefined;
run.mongoEditTarget = undefined;
run.tableMeta = undefined;
run.resultEvicted = options.evicted ? true : undefined;
run.resultCacheState = options.evicted ? "disk" : undefined;
}
function projectResultRun(tab: QueryTab, run: NonNullable<QueryTab["resultRuns"]>[number]) {
const activeIndex = run.activeResultIndex ?? 0;
tab.activeResultRunId = run.id;
tab.result = run.result ?? run.results?.[activeIndex];
tab.results = run.results;
tab.activeResultIndex = run.activeResultIndex;
tab.resultGridRevision = run.resultGridRevision;
// A legacy run without the token must not inherit a stale tab value: fail
// safe by starting a fresh logical result.
tab.resultViewGeneration = run.resultViewGeneration ?? uuid();
tab.batchSqlExecution = cloneBatchSqlExecution(run.batchSqlExecution);
tab.resultBaseSql = run.resultBaseSql;
tab.resultEditorFingerprint = run.resultEditorFingerprint;
tab.resultSortedSql = run.resultSortedSql;
tab.resultSortColumn = run.resultSortColumn;
tab.resultSortColumnIndex = run.resultSortColumnIndex;
tab.resultSortDirection = run.resultSortDirection;
tab.resultSortMode = run.resultSortMode;
tab.resultLocalSortOriginalRows = run.resultLocalSortOriginalRows;
tab.resultLocalSortOriginalLargeValueCells = run.resultLocalSortOriginalLargeValueCells;
tab.resultLocalSortOriginalMongoDocuments = run.resultLocalSortOriginalMongoDocuments;
tab.resultLocalSortOriginalMongoCopyDocuments = run.resultLocalSortOriginalMongoCopyDocuments;
tab.orderByInput = run.orderByInput;
tab.resultPageSql = run.resultPageSql;
tab.resultPageLimit = run.resultPageLimit;
tab.resultPageOffset = run.resultPageOffset;
tab.resultCountSql = run.resultCountSql;
tab.resultTotalRowCount = run.resultTotalRowCount;
tab.resultTotalRowCountLoading = run.resultTotalRowCountLoading;
tab.resultSessionId = run.resultSessionId;
tab.resultClientSessionId = run.resultClientSessionId;
tab.resultAccessedAt = run.resultAccessedAt;
tab.resultCacheKey = run.resultCacheKey;
tab.resultCacheState = run.resultCacheState;
tab.resultEstimatedBytes = run.resultEstimatedBytes ?? estimateQueryResultsBytes(run.result, run.results);
tab.resultEvicted = run.resultEvicted;
tab.queryAnalysis = run.queryAnalysis;
tab.querySourceColumns = run.querySourceColumns;
tab.queryWriteTargets = run.queryWriteTargets;
tab.resultColumnComments = run.resultColumnComments;
tab.queryDisplaySourceColumns = run.queryDisplaySourceColumns;
tab.queryEditabilityReason = run.queryEditabilityReason;
tab.mongoEditTarget = run.mongoEditTarget;
tab.tableMeta = run.tableMeta;
touchResult(tab, Date.now(), { reuseEstimatedBytes: true });
}
function restorePendingResultRun(tab: QueryTab, executionId: string): boolean {
const runId = pendingResultRunRestores.get(executionId);
pendingResultRunRestores.delete(executionId);
if (!runId) return false;
const run = tab.resultRuns?.find((item) => item.id === runId);
if (!run || !resultRunHasPayload(run)) return false;
projectResultRun(tab, run);
evictInactiveResultRunPayloads(tab);
return true;
}
async function restoreResultRunPayload(tab: QueryTab, runId: string, isCurrent?: () => boolean) {
const run = tab.resultRuns?.find((item) => item.id === runId);
if (!run || run.result || run.results?.length) return run;
const cacheKey = run.resultCacheKey ?? tab.resultCacheKey;
if (!cacheKey) return run;
const snapshot = await readTabResultSnapshot(cacheKey);
if (isCurrent && !isCurrent()) return undefined;
const snapshotRun = snapshot?.resultRuns?.find((item) => item.id === runId);
if (!snapshotRun) return run;
const restoredRun = markQueryResultRunsRowsRaw([
{
...run,
...snapshotRun,
id: run.id,
title: run.title,
sequence: run.sequence,
sql: run.sql,
createdAt: run.createdAt,
pinned: run.pinned,
activeResultIndex: run.activeResultIndex,
resultCacheKey: run.resultCacheKey ?? snapshotRun.resultCacheKey,
result: snapshotRun.result ? markQueryResultRowsRaw(snapshotRun.result) : undefined,
results: snapshotRun.results ? markQueryResultsRowsRaw(snapshotRun.results) : undefined,
resultCacheState: "memory" as const,
resultEvicted: undefined,
// 快照编解码会重建负载(如省略 session_id),落盘前的估算值不再对应
// 恢复后的对象,置空以便 projectResultRun 按当前负载重算
resultEstimatedBytes: undefined,
},
])[0]!;
tab.resultRuns = tab.resultRuns?.map((item) => (item.id === runId ? restoredRun : item));
return restoredRun;
}
async function setActiveResultRun(id: string, runId: string, options: { evictInactive?: boolean; isCurrent?: () => boolean } = {}) {
const tab = findExecutionTab(id);
if (!tab) return false;
const existingRun = tab.resultRuns?.find((item) => item.id === runId);
const run = existingRun && resultRunHasPayload(existingRun) ? existingRun : await restoreResultRunPayload(tab, runId, options.isCurrent);
if (options.isCurrent && !options.isCurrent()) return false;
if (!run?.result && !run?.results?.length) return false;
projectResultRun(tab, run);
if (options.evictInactive !== false) evictInactiveResultRunPayloads(tab);
return true;
}
function toggleResultRunPinned(id: string, runId: string): boolean | undefined {
const tab = tabs.value.find((item) => item.id === id);
const runIndex = tab?.resultRuns?.findIndex((run) => run.id === runId) ?? -1;
if (!tab?.resultRuns || runIndex < 0) return undefined;
const run = { ...tab.resultRuns[runIndex]!, pinned: tab.resultRuns[runIndex]!.pinned ? undefined : true };
tab.resultRuns[runIndex] = run;
void persistResultRun(tab, run);
return run.pinned === true;
}
function renameResultRun(id: string, runId: string, title: string): boolean {
const trimmed = title.trim();
if (!trimmed) return false;
const tab = tabs.value.find((item) => item.id === id);
const run = tab?.resultRuns?.find((item) => item.id === runId);
if (!tab || !run) return false;
run.title = trimmed;
run.customTitle = true;
void persistResultRun(tab, run);
return true;
}
function unpinAllResultRuns(id: string): number {
const tab = tabs.value.find((item) => item.id === id);
if (!tab?.resultRuns?.length) return 0;
let changed = 0;
tab.resultRuns = tab.resultRuns.map((run) => {
if (!run.pinned) return run;
changed += 1;
const updated = { ...run, pinned: undefined };
void persistResultRun(tab, updated);
return updated;
});
return changed;
}
async function closeOtherResultRuns(id: string, keepRunId: string): Promise<boolean> {
const tab = tabs.value.find((item) => item.id === id);
if (!tab?.resultRuns?.some((run) => run.id === keepRunId)) return false;
const runIds = tab.resultRuns.filter((run) => run.id !== keepRunId).map((run) => run.id);
if (runIds.length === 0) return false;
// Do not delete otherwise usable runs until the run the user chose to keep
// has been restored successfully. Disk-backed snapshots can be unavailable.
if (!(await setActiveResultRun(id, keepRunId, { evictInactive: false }))) return false;
for (const runId of runIds) {
await removeResultRun(id, runId);
}
return true;
}
async function closeResultRunsToLeft(id: string, runId: string): Promise<boolean> {
const tab = tabs.value.find((item) => item.id === id);
const runIndex = tab?.resultRuns?.findIndex((run) => run.id === runId) ?? -1;
if (!tab?.resultRuns || runIndex <= 0) return false;
if (!(await setActiveResultRun(id, runId, { evictInactive: false }))) return false;
for (const run of tab.resultRuns.slice(0, runIndex)) {
await removeResultRun(id, run.id);
}
return true;
}
async function closeResultRunsToRight(id: string, runId: string): Promise<boolean> {
const tab = tabs.value.find((item) => item.id === id);
const runIndex = tab?.resultRuns?.findIndex((run) => run.id === runId) ?? -1;
if (!tab?.resultRuns || runIndex < 0 || runIndex >= tab.resultRuns.length - 1) return false;
if (!(await setActiveResultRun(id, runId, { evictInactive: false }))) return false;
for (const run of tab.resultRuns.slice(runIndex + 1)) {
await removeResultRun(id, run.id);
}
return true;
}
async function removeResultRun(id: string, runId: string) {
const tab = tabs.value.find((t) => t.id === id);
const runIndex = tab?.resultRuns?.findIndex((run) => run.id === runId) ?? -1;
if (!tab || !tab.resultRuns || runIndex < 0) return false;
const removedRun = tab.resultRuns[runIndex];
if (removedRun?.resultSessionId) void closeResultRunSession(tab, removedRun);
if (removedRun?.resultCacheKey) void deleteTabResultSnapshot(removedRun.resultCacheKey);
if (removedRun) clearResultRunPayload(removedRun);
const wasActive = tab.activeResultRunId === runId;
const remainingRuns = tab.resultRuns.filter((run) => run.id !== runId);
tab.resultRuns = remainingRuns;
if (!wasActive) return true;
const adjacentIndex = Math.min(runIndex, remainingRuns.length - 1);
for (let offset = 0; offset < remainingRuns.length; offset += 1) {
const candidate = remainingRuns[(adjacentIndex + offset) % remainingRuns.length];
// Disk-backed runs may have missing or unreadable snapshots; keep searching before clearing output.
if (candidate && (await setActiveResultRun(id, candidate.id))) return true;
}
tab.activeResultRunId = undefined;
clearResultPayload(tab);
return true;
}
async function closeQueryResult(id: string) {
const tab = tabs.value.find((item) => item.id === id);
if (!tab || tab.mode !== "query" || tab.isExecuting) return false;
if (tab.activeResultRunId) return removeResultRun(id, tab.activeResultRunId);
if (!tab.result && !tab.results?.length && !tab.resultEvicted) return false;
const closeSession = closeResultSession(tab);
releaseTabResultObjectPayloads(tab);
clearResultPayload(tab);
await closeSession;
return true;
}
async function clearQueryResults(id: string) {
const tab = tabs.value.find((item) => item.id === id);
if (!tab || tab.mode !== "query" || tab.isExecuting) return false;
const resultRuns = tab.resultRuns ?? [];
if (!tab.result && !tab.results?.length && !tab.resultEvicted && resultRuns.length === 0) return false;
const closedSessionIds = new Set<string>();
const currentSessionId = tab.resultSessionId ?? tab.result?.session_id;
if (currentSessionId) closedSessionIds.add(currentSessionId);
const closeOperations = [closeResultSession(tab)];
for (const run of resultRuns) {
if (run.resultCacheKey) void deleteTabResultSnapshot(run.resultCacheKey);
if (!run.resultSessionId || closedSessionIds.has(run.resultSessionId)) continue;
closedSessionIds.add(run.resultSessionId);
closeOperations.push(closeResultRunSession(tab, run));
}
releaseTabResultObjectPayloads(tab);
clearResultRuns(tab);
clearResultPayload(tab);
await Promise.all(closeOperations);
return true;
}
function nextResultRunSequence(tab: QueryTab): number {
return (tab.resultRuns?.reduce((max, run) => Math.max(max, run.sequence), 0) ?? 0) + 1;
}
async function closeResultRunSession(tab: QueryTab, run: NonNullable<QueryTab["resultRuns"]>[number]) {
if (!run.resultSessionId) return;
const target = run.multiDbExecution?.target;
const sessionOwner = {
...tab,
id: `result-run:${run.id}`,
connectionId: target?.connectionId ?? tab.connectionId,
database: target?.database ?? tab.database,
schema: target?.schema ?? tab.schema,
catalog: target?.catalog ?? tab.catalog,
result: run.result,
resultSessionId: run.resultSessionId,
resultClientSessionId: run.resultClientSessionId,
activeResultRunId: undefined,
} as QueryTab;
await closeResultSession(sessionOwner);
run.resultSessionId = undefined;
run.resultClientSessionId = undefined;
}
function persistResultRun(tab: QueryTab, run: NonNullable<QueryTab["resultRuns"]>[number]): Promise<boolean> {
// An evicted run only has metadata in memory. Writing it back here would
// replace its valid disk snapshot with an empty payload.
if (!resultRunHasPayload(run)) return Promise.resolve(false);
const key = run.resultCacheKey ?? resultRunCacheKey(tab.id, run.id);
run.resultCacheKey = key;
run.resultCacheState = "memory";
return writeTabResultSnapshot(
key,
{
result: run.result,
results: run.results,
activeResultIndex: run.activeResultIndex,
resultEditorFingerprint: run.resultEditorFingerprint,
resultRuns: [run],
activeResultRunId: run.id,
queryAnalysis: run.queryAnalysis,
querySourceColumns: run.querySourceColumns,
queryWriteTargets: run.queryWriteTargets,
resultColumnComments: run.resultColumnComments,
queryDisplaySourceColumns: run.queryDisplaySourceColumns,
queryEditabilityReason: run.queryEditabilityReason,
tableMeta: run.tableMeta,
resultPageSql: run.resultPageSql,
resultPageLimit: run.resultPageLimit,
resultPageOffset: run.resultPageOffset,
resultCountSql: run.resultCountSql,
resultTotalRowCount: run.resultTotalRowCount,
cachedAt: Date.now(),
},
tab.connectionId,
);
}
function evictInactiveResultRunPayloads(tab: QueryTab) {
const activeRunId = tab.activeResultRunId;
if (!activeRunId || !tab.resultRuns?.length) return;
for (const run of tab.resultRuns) {
if (run.id === activeRunId || !resultRunHasPayload(run)) continue;
const runId = run.id;
void closeResultRunSession(tab, run)
.then(() => persistResultRun(tab, run))
.then((cached) => {
const currentRun = tab.resultRuns?.find((item) => item.id === runId);
if (!cached || !currentRun || currentRun.id === tab.activeResultRunId || !resultRunHasPayload(currentRun)) return;
if (tab.result === currentRun.result || (currentRun.results && tab.results === currentRun.results)) return;
clearResultRunPayload(currentRun, { evicted: true });
});
}
}
type ResultRunCaptureOptions = {
reuseResultCacheKey?: boolean;
title?: string;
multiDbExecution?: MultiDbResultRunExecution;
persist?: boolean;
};
function captureDisplayedResultRun(tab: QueryTab, sql: string, createdAt = Date.now(), options: ResultRunCaptureOptions = {}) {
if (tab.mode !== "query" || !tab.result) return;
const sequence = nextResultRunSequence(tab);
// 批次级来源:非活动批次的 payload 会被回收,结果标签命名需要独立保存来源
const primaryResult = tab.results?.[0] ?? tab.result;
const run: NonNullable<QueryTab["resultRuns"]>[number] = {
id: uuid(),
title: options.title ?? `Run ${sequence}`,
customTitle: !!options.title,
sequence,
sql,
createdAt,
sourceLabel: primaryResult?.sourceLabel,
sourceName: primaryResult?.sourceName,
result: tab.result,
results: tab.results,
activeResultIndex: tab.activeResultIndex,
resultGridRevision: tab.resultGridRevision,
resultViewGeneration: tab.resultViewGeneration,
batchSqlExecution: cloneBatchSqlExecution(tab.batchSqlExecution),
resultBaseSql: tab.resultBaseSql,
resultEditorFingerprint: tab.resultEditorFingerprint,
resultSortedSql: tab.resultSortedSql,
resultSortColumn: tab.resultSortColumn,
resultSortColumnIndex: tab.resultSortColumnIndex,
resultSortDirection: tab.resultSortDirection,
resultSortMode: tab.resultSortMode,
resultLocalSortOriginalRows: tab.resultLocalSortOriginalRows,
resultLocalSortOriginalLargeValueCells: tab.resultLocalSortOriginalLargeValueCells,
resultLocalSortOriginalMongoDocuments: tab.resultLocalSortOriginalMongoDocuments,
resultLocalSortOriginalMongoCopyDocuments: tab.resultLocalSortOriginalMongoCopyDocuments,
orderByInput: tab.orderByInput,
resultPageSql: tab.resultPageSql,
resultPageLimit: tab.resultPageLimit,
resultPageOffset: tab.resultPageOffset,
resultCountSql: tab.resultCountSql,
resultTotalRowCount: tab.resultTotalRowCount,
resultTotalRowCountLoading: tab.resultTotalRowCountLoading,
resultSessionId: tab.resultSessionId,
resultClientSessionId: tab.resultClientSessionId,
resultAccessedAt: tab.resultAccessedAt,
resultEstimatedBytes: tab.resultEstimatedBytes,
resultCacheKey: options.reuseResultCacheKey === false ? undefined : tab.resultCacheKey,
resultCacheState: tab.resultCacheState,
resultEvicted: tab.resultEvicted,
queryAnalysis: tab.queryAnalysis,
querySourceColumns: tab.querySourceColumns,
queryWriteTargets: tab.queryWriteTargets,
resultColumnComments: tab.resultColumnComments,
queryDisplaySourceColumns: tab.queryDisplaySourceColumns,
queryEditabilityReason: tab.queryEditabilityReason,
mongoEditTarget: tab.mongoEditTarget,
tableMeta: tab.tableMeta,
multiDbExecution: options.multiDbExecution,
};
if (options.persist !== false) void persistResultRun(tab, run);
tab.resultRuns = [...(tab.resultRuns ?? []), run];
tab.activeResultRunId = run.id;
if (options.reuseResultCacheKey === false) {
tab.resultCacheKey = run.resultCacheKey;
tab.resultCacheState = run.resultCacheState;
}
evictInactiveResultRunPayloads(tab);
}
function captureMultiDbExecutionWorkerResult(sourceTabId: string, workerId: string, sql: string, execution: MultiDbResultRunExecution): string | undefined {
const source = tabs.value.find((tab) => tab.id === sourceTabId);
const worker = multiDbExecutionWorkers.get(workerId);
if (!source || !worker?.result) return undefined;
// Preserve a pre-existing displayed result once, so the source tab can
// show the original result alongside the multi-db result pages.
if (source.result && !source.activeResultRunId) {
captureDisplayedResultRun(source, source.resultBaseSql ?? source.lastExecutedSql ?? source.sql);
}
const title = execution.title ?? (execution.target.database || execution.target.connectionId);
captureDisplayedResultRun(worker, sql, Date.now(), { title, multiDbExecution: execution, persist: false });
const workerRun = worker.resultRuns?.find((run) => run.id === worker.activeResultRunId);
if (!workerRun) return undefined;
const runId = uuid();
const run = {
...workerRun,
id: runId,
title,
customTitle: true,
sequence: nextResultRunSequence(source),
multiDbExecution: execution,
resultCacheKey: undefined,
resultCacheState: "memory" as const,
resultEvicted: undefined,
};
source.resultRuns = [...(source.resultRuns ?? []), run];
projectResultRun(source, run);
void persistResultRun(source, run);
evictInactiveResultRunPayloads(source);
return runId;
}
function toggleResultAutoSave(id: string): boolean {
const tab = tabs.value.find((t) => t.id === id);
if (!tab || tab.mode !== "query") return false;
setResultAutoSave(tab, tab.resultAutoSave !== true);
return tab.resultAutoSave === true;
}
function setResultAutoSave(tab: QueryTab, enabled: boolean) {
tab.resultAutoSave = enabled;
if (enabled && !tab.isExecuting && tab.result && !tab.activeResultRunId) {
captureDisplayedResultRun(tab, tab.resultBaseSql ?? tab.lastExecutedSql ?? tab.sql);
}
}
function syncActiveResultRunFromDisplayed(tab: QueryTab, sql?: string) {
if (!tab.activeResultRunId || !tab.resultRuns?.length) return;
const index = tab.resultRuns.findIndex((run) => run.id === tab.activeResultRunId);
if (index < 0) return;
const run = {
...tab.resultRuns[index],
...(sql ? { sql } : {}),
result: tab.result,
results: tab.results,
activeResultIndex: tab.activeResultIndex,
resultGridRevision: tab.resultGridRevision,
resultViewGeneration: tab.resultViewGeneration,
batchSqlExecution: cloneBatchSqlExecution(tab.batchSqlExecution),
resultBaseSql: tab.resultBaseSql,
resultEditorFingerprint: tab.resultEditorFingerprint,
resultSortedSql: tab.resultSortedSql,
resultSortColumn: tab.resultSortColumn,
resultSortColumnIndex: tab.resultSortColumnIndex,
resultSortDirection: tab.resultSortDirection,
resultSortMode: tab.resultSortMode,
resultLocalSortOriginalRows: tab.resultLocalSortOriginalRows,
resultLocalSortOriginalLargeValueCells: tab.resultLocalSortOriginalLargeValueCells,
resultLocalSortOriginalMongoDocuments: tab.resultLocalSortOriginalMongoDocuments,
resultLocalSortOriginalMongoCopyDocuments: tab.resultLocalSortOriginalMongoCopyDocuments,
orderByInput: tab.orderByInput,
resultPageSql: tab.resultPageSql,
resultPageLimit: tab.resultPageLimit,
resultPageOffset: tab.resultPageOffset,
resultCountSql: tab.resultCountSql,
resultTotalRowCount: tab.resultTotalRowCount,
resultTotalRowCountLoading: tab.resultTotalRowCountLoading,
resultSessionId: tab.resultSessionId,
resultClientSessionId: tab.resultClientSessionId,
resultAccessedAt: tab.resultAccessedAt,
resultEstimatedBytes: tab.resultEstimatedBytes,
resultCacheKey: tab.resultCacheKey,
resultCacheState: tab.resultCacheState,
resultEvicted: tab.resultEvicted,
queryAnalysis: tab.queryAnalysis,
querySourceColumns: tab.querySourceColumns,
queryWriteTargets: tab.queryWriteTargets,
resultColumnComments: tab.resultColumnComments,
queryDisplaySourceColumns: tab.queryDisplaySourceColumns,
queryEditabilityReason: tab.queryEditabilityReason,
mongoEditTarget: tab.mongoEditTarget,
tableMeta: tab.tableMeta,
};
void persistResultRun(tab, run);
tab.resultRuns[index] = run;
}
function syncDisplayedResultRun(tab: QueryTab, sql: string, captureNewRun = false, options: ResultRunCaptureOptions = {}) {
if (tab.mode !== "query" || !tab.result) return;
if (captureNewRun) {
captureDisplayedResultRun(tab, sql, Date.now(), { ...options, reuseResultCacheKey: false });
} else if (tab.activeResultRunId) {
syncActiveResultRunFromDisplayed(tab, sql);
} else if (tab.resultAutoSave) {
captureDisplayedResultRun(tab, sql);
}
}
function assignDisplayedResult(tab: QueryTab, result: QueryResult) {
tab.result = markQueryResultRowsRaw(result);
if (tab.results?.length) {
const activeIndex = tab.activeResultIndex ?? 0;
if (activeIndex >= 0 && activeIndex < tab.results.length) {
tab.results[activeIndex] = tab.result;
}
}
}
type ResultPublicationOrigin = "execute" | "refresh" | "page" | "sort" | "local-sort" | "append" | "disk-restore";
/**
* Single writer of `resultViewGeneration`, the logical-result identity used by
* the tab-switch view snapshot cache (`dataGridViewStateCache.ts`).
*
* New value by default, so a path that forgets to classify itself still fails
* safe (the old view snapshot stops matching) instead of replaying a stale
* viewport. `append` extends the current dataset; `disk-restore` inherits, so
* an evicted-then-restored payload keeps its captured view. No other code path
* may assign the token directly.
*/
function publishResultGeneration(tab: QueryTab, origin: ResultPublicationOrigin) {
if (origin === "disk-restore") return;
if (origin === "append") {
tab.resultViewGeneration ??= uuid();
return;
}
tab.resultViewGeneration = uuid();
}
function sortTabResultLocally(id: string, column: string, columnIndex: number, direction: DataGridSortDirection | null) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab?.result) return;
if (!tab.resultLocalSortOriginalRows) {
tab.resultLocalSortOriginalRows = tab.result.rows.slice();
tab.resultLocalSortOriginalLargeValueCells = tab.result.large_value_cells?.map((cell) => ({ ...cell }));
tab.resultLocalSortOriginalMongoDocuments = tab.result.mongo_documents?.slice();
tab.resultLocalSortOriginalMongoCopyDocuments = tab.result.mongo_copy_documents?.slice();
}
const originalRows = tab.resultLocalSortOriginalRows;
const columnType = tab.result.column_types?.[columnIndex];
const rowIndexes = direction ? sortDataGridRowIndexes(originalRows, columnIndex, direction, columnType) : originalRows.map((_, index) => index);
const rows = rowIndexes.map((index) => originalRows[index]!);
const originalMongoDocuments = tab.resultLocalSortOriginalMongoDocuments;
const mongo_documents = originalMongoDocuments ? rowIndexes.map((index) => originalMongoDocuments[index]) : undefined;
const originalMongoCopyDocuments = tab.resultLocalSortOriginalMongoCopyDocuments;
const mongo_copy_documents = originalMongoCopyDocuments ? rowIndexes.map((index) => originalMongoCopyDocuments[index]) : undefined;
const large_value_cells = remapLargeValueCells(tab.resultLocalSortOriginalLargeValueCells, rowIndexes);
assignDisplayedResult(tab, { ...tab.result, rows, large_value_cells, mongo_documents, mongo_copy_documents });
// Reordering rows invalidates source-index view snapshots.
publishResultGeneration(tab, "local-sort");
tab.resultSortColumn = direction ? column : undefined;
tab.resultSortColumnIndex = direction ? columnIndex : undefined;
tab.resultSortDirection = direction ?? undefined;
tab.resultSortMode = direction ? "local" : undefined;
tab.resultSortedSql = undefined;
if (!direction) {
tab.resultLocalSortOriginalRows = undefined;
tab.resultLocalSortOriginalLargeValueCells = undefined;
tab.resultLocalSortOriginalMongoDocuments = undefined;
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
}
// 本地排序只是重排既有行/文档,字节规模不变,可复用估算值
touchResult(tab, Date.now(), { reuseEstimatedBytes: true });
syncDisplayedResultRun(tab, tab.resultBaseSql ?? tab.lastExecutedSql ?? tab.sql);
}
function resultRunHasPayload(run: NonNullable<QueryTab["resultRuns"]>[number]): boolean {
return !!run.result || !!run.results?.length;
}
function resultSnapshotHasPayload(snapshot: NonNullable<ReturnType<typeof buildTabResultSnapshot>>): boolean {
return !!snapshot.result || !!snapshot.results?.length || !!snapshot.resultRuns?.some(resultRunHasPayload);
}
async function evictCachedResult(tab: QueryTab) {
await closeResultSession(tab);
const cacheKey = tabResultCacheKey(tab.id);
const cached = await writeTabResultSnapshot(cacheKey, buildTabResultSnapshot(tab), tab.connectionId);
tab.resultCacheKey = cached ? cacheKey : undefined;
tab.resultCacheState = cached ? "disk" : "missing";
clearResultPayload(tab, { evicted: true });
}
function applyRestoredOpenTabs(restored: { tabs: QueryTab[]; activeTabId: string | null; workspace?: unknown }) {
const connectionStore = useConnectionStore();
for (const tab of restored.tabs) {
const connection = connectionStore.getConfig(tab.connectionId);
if (tab.mode === "query" && tab.autoCommit === undefined) {
tab.autoCommit = defaultAutoCommitForDbTypeWithSetting(connection?.db_type);
} else if (tab.mode === "data" && connection) {
tab.schema = connectionObjectTreeNodeSchema(connection, tab.database, tab.schema);
}
}
restored.tabs.forEach(initializeResultAutoSave);
tabs.value = restored.tabs;
for (const tab of restored.tabs) {
if (tab.mode === "data") {
void deleteTabResultSnapshot(tabResultCacheKey(tab.id));
}
}
// Restore order (guide §3.9): the persisted global active tab is only a
// hint for legacy tabs-only payloads. Group membership, group actives and
// the focused group are repaired first; the global active tab is derived
// from the focused group last, so a stale payload value can never move
// focus to a different group.
const workspace = restored.workspace && typeof restored.workspace === "object" ? (restored.workspace as Record<string, unknown>) : undefined;
if (!Array.isArray(workspace?.groups) && restored.activeTabId && tabs.value.some((tab) => tab.id === restored.activeTabId)) {
groups.value = [{ id: "main", tabIds: tabs.value.map((tab) => tab.id), activeTabId: restored.activeTabId }];
focusedGroupId.value = "main";
} else if (workspace) {
if (Array.isArray(workspace.groups)) {
groups.value = (workspace.groups as Array<Record<string, unknown>>)
.filter((group) => group && typeof group.id === "string")
.map((group) => ({
id: group.id as string,
tabIds: Array.isArray(group.tabIds) ? (group.tabIds as unknown[]).map(String) : [],
activeTabId: typeof group.activeTabId === "string" ? group.activeTabId : null,
}));
}
if (typeof workspace.focusedGroupId === "string") {
focusedGroupId.value = workspace.focusedGroupId;
}
if (workspace.orientation === "vertical" || workspace.orientation === "horizontal") {
orientation.value = workspace.orientation;
}
if (Array.isArray(workspace.sizes)) {
sizes.value = (workspace.sizes as unknown[]).map(Number);
}
}
// Start from no global active so normalization cannot treat the persisted
// value as authoritative over the restored focused group.
activeTabId.value = null;
normalizeGroups();
activeTabHistory.value = activeTabId.value ? [activeTabId.value] : [];
}
async function recoverDetachedTabsToMain(options: { validConnectionIds?: Iterable<string> } = {}) {
if (!isTauriRuntime() || useSettingsStore().editorSettings.openTabsRestoreMode === "none") return;
const validConnectionIds = options.validConnectionIds ? new Set(options.validConnectionIds) : undefined;
const handoffs = await api.listDetachedTabHandoffs().catch(() => []);
for (const handoff of handoffs) {
if (!handoff || handoff.schemaVersion !== 1 || !handoff.tabId || !handoff.tab || typeof handoff.tab.id !== "string" || handoff.tab.id !== handoff.tabId) continue;
if (validConnectionIds && handoff.tab.mode !== "query" && !validConnectionIds.has(handoff.tab.connectionId)) continue;
try {
await adoptDetachedTab(handoff);
await api.deleteDetachedTabHandoff(handoff.tabId);
} catch (error) {
console.warn("[DBX][detached-tab:restore:error]", error);
}
}
}
function scheduleResultCacheMaintenance() {
const maintain = () => {
const liveKeys = tabs.value.flatMap((tab) => [tab.resultCacheKey, ...(tab.resultRuns?.map((run) => run.resultCacheKey) ?? [])]).filter((key): key is string => !!key);
void pruneTabResultSnapshots(liveKeys).catch((error) => console.warn("[DBX][result-cache:maintenance:error]", error));
};
if (typeof requestIdleCallback !== "undefined") requestIdleCallback(maintain, { timeout: 5000 });
else if (typeof window !== "undefined") window.setTimeout(maintain, 0);
else setTimeout(maintain, 0);
}
async function initOpenTabs(options: { validConnectionIds?: Iterable<string> } = {}) {
if (isDetachedWindow()) return;
if (isOpenTabsLoaded.value) return;
const saved = await api.loadOpenTabsState().catch(() => null);
if (saved?.tabs && Array.isArray(saved.tabs)) {
const restored = restoreSavedTabsFromPayload(saved, options);
applyRestoredOpenTabs(restored);
if (safeLocalStorageGet(UPDATE_RESTORE_KEY) !== "1" && useSettingsStore().editorSettings.openTabsRestoreMode === "none") {
// Restore is explicitly disabled, so stale saved payloads should not
// reappear if the user later changes the setting.
clearLegacySavedTabs();
await saveTabs(tabs.value, activeTabId.value, { groups: groups.value, focusedGroupId: focusedGroupId.value, orientation: orientation.value, sizes: sizes.value }).catch(() => undefined);
}
await recoverDetachedTabsToMain(options);
await saveTabs(tabs.value, activeTabId.value).catch(() => undefined);
safeLocalStorageRemove(UPDATE_RESTORE_KEY);
isOpenTabsLoaded.value = true;
scheduleResultCacheMaintenance();
return;
}
const legacy = loadLegacySavedTabs();
if (legacy.rawTabs || legacy.rawActiveTabId) {
const restored = restoreLegacySavedTabs(options);
applyRestoredOpenTabs(restored);
if (safeLocalStorageGet(UPDATE_RESTORE_KEY) !== "1" && useSettingsStore().editorSettings.openTabsRestoreMode === "none") {
// Restore is explicitly disabled, so keeping the legacy startup payload
// would resurrect old tabs if the user later changes the setting.
clearLegacySavedTabs();
isOpenTabsLoaded.value = true;
scheduleResultCacheMaintenance();
return;
}
await recoverDetachedTabsToMain(options);
try {
await saveTabs(tabs.value, activeTabId.value, { groups: groups.value, focusedGroupId: focusedGroupId.value, orientation: orientation.value, sizes: sizes.value });
// Keep old desktop installs readable until the async store has the
// migrated state; only then remove the synchronous startup payload.
clearLegacySavedTabs();
} catch {
/* keep legacy values for a later migration attempt */
}
}
await recoverDetachedTabsToMain(options);
await saveTabs(tabs.value, activeTabId.value).catch(() => undefined);
isOpenTabsLoaded.value = true;
scheduleResultCacheMaintenance();
}
const _persistSnapshot = computed(() =>
tabs.value.map((t) => ({
id: t.id,
title: t.title,
connectionId: t.connectionId,
database: t.database,
schema: t.schema,
sql: t.sql,
editorViewport: t.editorViewport,
editorSelection: t.editorSelection,
savedSqlId: t.savedSqlId,
externalSqlPath: t.externalSqlPath,
externalSqlFileVersion: t.externalSqlFileVersion,
externalSqlIgnoredFileVersion: t.externalSqlIgnoredFileVersion,
externalSqlFileMissing: t.externalSqlFileMissing,
lastExecutedSql: t.lastExecutedSql,
resultBaseSql: t.resultBaseSql,
resultSortedSql: t.resultSortedSql,
resultSortColumn: t.resultSortColumn,
resultSortColumnIndex: t.resultSortColumnIndex,
resultSortDirection: t.resultSortDirection,
resultSortMode: t.resultSortMode,
orderByInput: t.orderByInput,
whereInput: t.whereInput,
pinned: t.pinned,
mode: t.mode,
detachedConnectionName: t.detachedConnectionName,
autoCommit: t.autoCommit,
resultAutoSave: t.resultAutoSave,
uiState: t.uiState,
structureTableName: t.structureTableName,
structureTableType: t.structureTableType,
structureDraft: t.structureDraft,
objectBrowser: t.objectBrowser,
objectSource: t.objectSource,
sourceView: t.sourceView,
tableMeta: t.tableMeta,
mongoEditTarget: t.mongoEditTarget,
resultEvicted: t.resultEvicted,
resultCacheKey: t.resultCacheKey,
// Keep the watch dependency limited to the metadata that is serialized
// for each result run, without tracking the potentially large payload.
resultRuns: t.resultRuns?.map((run) => ({
id: run.id,
title: run.title,
sequence: run.sequence,
sql: run.sql,
createdAt: run.createdAt,
pinned: run.pinned,
customTitle: run.customTitle,
sourceLabel: run.sourceLabel,
sourceName: run.sourceName,
activeResultIndex: run.activeResultIndex,
resultCacheKey: run.resultCacheKey,
resultEvicted: run.resultEvicted,
})),
activeResultRunId: t.activeResultRunId,
})),
);
const storePersistGeneration = ++persistGeneration;
watch(
[_persistSnapshot, activeTabId, groups, focusedGroupId, orientation, sizes],
() => {
if (storePersistGeneration !== persistGeneration) return;
if (persistTimer) clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
if (isDetachedWindow()) {
void flushDetachedTabPersistence(activeTabId.value ?? undefined).catch(() => {});
} else {
void saveTabs(tabs.value, activeTabId.value, { groups: groups.value, focusedGroupId: focusedGroupId.value, orientation: orientation.value, sizes: sizes.value }).catch(() => {});
}
persistTimer = null;
}, 300);
},
{ flush: "post", deep: true },
);
// Group invariants are maintained by the atomic store operations themselves
// (registerOpenTab / activateTab / split / move / close); there is
// deliberately no post-flush watcher repairing them here. Normalization is
// reserved for the persistence restore boundary.
onScopeDispose(() => {
if (persistTimer) clearTimeout(persistTimer);
persistTimer = null;
void disposeAllSqlServerActivityTraces();
});
// Immediately flush any pending debounced persist so the on-disk content
// reflects the latest in-memory tabs without waiting for the 300ms debounce.
// Lets callers (e.g. tests that reload the store) read back persisted state
// deterministically instead of racing the debounce timer.
function flushPendingPersist(): Promise<void> {
if (isDetachedWindow()) return flushDetachedTabPersistence(activeTabId.value ?? undefined);
if (storePersistGeneration !== persistGeneration) return Promise.resolve();
if (persistTimer) {
clearTimeout(persistTimer);
persistTimer = null;
}
return saveTabs(tabs.value, activeTabId.value, { groups: groups.value, focusedGroupId: focusedGroupId.value, orientation: orientation.value, sizes: sizes.value });
}
async function prepareDetachedTab(tabId: string, runtime: DetachedTabRuntimeState = {}): Promise<DetachedTabHandoff> {
const tab = tabs.value.find((candidate) => candidate.id === tabId);
if (!tab) throw new Error("Tab is no longer open");
if (tab.mode !== "query" && tab.mode !== "data") throw new Error("Only SQL and data tabs can be detached");
if (tab.isExecuting || tab.isCancelling || tab.isExplaining) throw new Error("Wait for the current operation to finish before detaching this tab");
const savedTab = serializeOpenTabs([tab])[0];
if (!savedTab) throw new Error("Unable to serialize tab");
const resultCacheKey = tabResultCacheKey(tab.id);
const snapshot = buildTabResultSnapshot(tab) ?? (tab.resultCacheKey ? await readTabResultSnapshot(tab.resultCacheKey) : undefined);
if (snapshot) {
const cached = await writeTabResultSnapshot(resultCacheKey, snapshot, tab.connectionId);
if (!cached) throw new Error("Unable to cache the tab result for the detached window");
tab.resultCacheKey = resultCacheKey;
tab.resultCacheState = "disk";
}
detachedHandoffRevision += 1;
return {
schemaVersion: 1,
tabId,
sourceWindowLabel: resolveWindowContext().windowLabel,
revision: detachedHandoffRevision,
tab: savedTab,
runtime: {
editorViewport: tab.editorViewport,
editorSelection: tab.editorSelection,
txnSessionId: tab.txnSessionId,
txnAutoRolledBack: tab.txnAutoRolledBack,
txnPossiblyDirty: tab.txnPossiblyDirty,
...runtime,
},
...(snapshot ? { resultCacheKey } : {}),
updatedAt: Date.now(),
};
}
async function flushDetachedTabPersistence(tabId?: string, runtime: DetachedTabRuntimeState = {}): Promise<void> {
if (!isDetachedWindow() || !tabId) return;
const handoff = await prepareDetachedTab(tabId, runtime);
await api.saveDetachedTabHandoff(tabId, handoff);
}
/**
* Lands a workspace-adopted tab in the pane layout. The split workspace
* renders from group membership, so a tab arriving through a detached
* handoff must belong to a group to be visible at all. A group that still
* carries the id (a return to a pane that never cleaned up) just gets its
* active tab repaired; everything else adopts into the first group. Used by
* detached-window open, return-to-main, and startup recovery alike.
*/
function ensureTabInWorkspace(tabId: string) {
const owner = groups.value.find((group) => group.tabIds.includes(tabId));
if (owner) {
owner.activeTabId = tabId;
focusedGroupId.value = owner.id;
return;
}
const main = groups.value[0];
main.tabIds.push(tabId);
main.activeTabId = tabId;
focusedGroupId.value = main.id;
}
async function adoptDetachedTab(handoff: DetachedTabHandoff): Promise<string> {
if (!handoff || handoff.schemaVersion !== 1 || !handoff.tab || typeof handoff.tab.id !== "string" || handoff.tab.id !== handoff.tabId) throw new Error("Unsupported detached tab state");
const restored = restoreOpenTabsPayload({ tabs: [handoff.tab], activeTabId: handoff.tabId });
const restoredTab = restored.tabs[0];
if (!restoredTab) throw new Error("Unable to restore detached tab");
// serializeOpenTabs() blanks `sql` for a clean saved-SQL-library tab to avoid
// duplicating on-disk state across app restarts, relying on hydrateSavedSqlTabs()
// to refill it afterwards. The detach handoff goes through the same serialization
// but skips that hydration step, so do it here too or the new window opens empty.
// A freshly created detached window never runs the main-window bootstrap that
// populates savedSqlStore's local file index, so useSavedSqlStore().ensureFileContent()
// can't find the file locally either — go straight to the backend instead.
if (restoredTab.savedSqlId && restoredTab.mode === "query" && !restoredTab.sql && restoredTab.originalSql === undefined) {
const file = await api.loadSavedSqlFile(restoredTab.savedSqlId).catch(() => undefined);
if (file) {
restoredTab.title = restoredTab.customTitle ? restoredTab.title : file.name;
restoredTab.sql = file.sql;
restoredTab.originalSql = file.sql;
}
}
Object.assign(restoredTab, normalizeDetachedTabRuntime(handoff.runtime));
if (handoff.resultCacheKey) {
restoredTab.resultCacheKey = handoff.resultCacheKey;
const restoredResult = restoreCachedResultPayload(restoredTab, await readTabResultSnapshot(handoff.resultCacheKey));
if (!restoredResult) restoredTab.resultCacheState = "missing";
}
initializeResultAutoSave(restoredTab);
const existingIndex = tabs.value.findIndex((tab) => tab.id === handoff.tabId);
if (existingIndex >= 0) {
tabs.value.splice(existingIndex, 1, restoredTab);
} else {
tabs.value.push(restoredTab);
}
ensureTabInWorkspace(handoff.tabId);
activeTabId.value = handoff.tabId;
return handoff.tabId;
}
function removeTabAfterDetachedReady(tabId: string): boolean {
const index = tabs.value.findIndex((tab) => tab.id === tabId);
if (index < 0) return false;
// Group-aware removal: the split workspace renders from group membership,
// so the tab must leave its pane too. The emptied pane is pruned exactly
// like a bulk close, and a dangling pane (group entry without a tab) is
// never left behind for the return trip to trip over.
tabs.value.splice(index, 1);
removeTabsFromGroups(new Set([tabId]));
if (activeTabId.value === tabId) syncActiveTabFromFocusedGroup();
return true;
}
function findTabByIdentity(connectionId: string, database: string, title: string, mode: QueryTab["mode"], schema?: string, catalog?: string) {
if (mode === "meilisearch-system") {
return tabs.value.find((tab) => tab.connectionId === connectionId && tab.mode === mode);
}
return tabs.value.find((tab) => tab.connectionId === connectionId && tab.database === database && tab.title === title && tab.mode === mode && (tab.schema || "") === (schema || "") && (tab.catalog || "") === (catalog || ""));
}
function createTab(
connectionId: string,
database: string,
title?: string,
mode: QueryTab["mode"] = "query",
schema?: string,
initialSql?: string,
catalog?: string,
options: { forceNew?: boolean; activate?: boolean; forceWordWrap?: boolean; insertAfterActive?: boolean; sourceView?: boolean } = {},
) {
if (title && !options.forceNew) {
const existing = findTabByIdentity(connectionId, database, title, mode, schema, catalog);
if (existing) {
if (options.sourceView) existing.sourceView = true;
switchTab(existing.id);
return existing.id;
}
}
const id = uuid();
const dbType = useConnectionStore().getConfig(connectionId)?.db_type;
const tab: QueryTab = {
id,
title: title || `query_${tabs.value.length + 1}`,
customTitle: mode === "query" && title ? true : undefined,
forceWordWrap: options.forceWordWrap,
sourceView: options.sourceView,
connectionId,
database,
schema,
catalog,
sql: initialSql ?? "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode,
...(mode === "query" ? { autoCommit: defaultAutoCommitForDbTypeWithSetting(dbType) } : {}),
};
if (mode === "query") tab.originalSql = initialSql ?? "";
return registerOpenTab(tab, {
...(options.activate === false ? { activate: false } : {}),
...(options.insertAfterActive && activeTabId.value ? { insertAfterTabId: activeTabId.value } : {}),
});
}
/**
* 对象源码 tab 的判重键。裁决点是**解析后**的 objectSource.objectType:
* routine fallback 会把 PROCEDURE↔FUNCTION、PACKAGE↔PACKAGE_BODY 归一,
* 因此「PROCEDURE foo」与「FUNCTION foo」应当共用一个 tab。pending 阶段
* 拿不到解析结果,只能做请求身份去重(见 openObjectSourceTabPending),
* 解析完成后再回到这里落定,避免改变既有语义。
*/
function findMatchingObjectSourceTab(options: OpenObjectSourceTabOptions, excludeTabId?: string): QueryTab | undefined {
return tabs.value.find(
(tab) =>
tab.id !== excludeTabId &&
tab.mode === "query" &&
tab.connectionId === options.connectionId &&
tab.database === options.database &&
(tab.schema || "") === (options.schema || "") &&
(tab.catalog || "") === (options.catalog || "") &&
tab.objectSource?.name === options.objectSource.name &&
tab.objectSource.objectType === options.objectSource.objectType &&
(tab.objectSource.schema || "") === (options.objectSource.schema || "") &&
(tab.objectSource.signature || "") === (options.objectSource.signature || ""),
);
}
function openObjectSourceTab(options: OpenObjectSourceTabOptions) {
const existing = findMatchingObjectSourceTab(options);
if (existing) {
existing.sourceView = true;
switchTab(existing.id);
if (!isTabDirty(existing)) {
updateSql(existing.id, options.sql);
markTabClean(existing);
}
return existing.id;
}
const id = createTab(options.connectionId, options.database, options.title, "query", options.schema, options.sql, options.catalog, { forceNew: true, sourceView: true });
setObjectSource(id, options.objectSource);
return id;
}
/**
* 正在后台重新校验源码的 tab。非响应式:仅用于避免同一个 tab 上叠起多次
* 取源请求(Oracle 的 GET_DDL 正是慢的那一步)。
*/
const sourceRevalidateInFlight = new Set<string>();
function findPendingObjectSourceTab(options: OpenPendingObjectSourceTabOptions): QueryTab | undefined {
return tabs.value.find(
(tab) =>
!!tab.sourceLoad &&
tab.connectionId === options.connectionId &&
tab.database === options.database &&
(tab.schema || "") === (options.schema || "") &&
(tab.catalog || "") === (options.catalog || "") &&
(tab.sourceLoad.initialEditing ?? true) === (options.initialEditing ?? true) &&
tab.sourceLoad.request.name === options.request.name &&
tab.sourceLoad.request.objectType === options.request.objectType &&
(tab.sourceLoad.request.signature || "") === (options.request.signature || ""),
);
}
/**
* 立即建出源码 tab 并挂上加载态,再异步取源码(issue #9035)。
* 此前是「等连接 + 等源码都完成才建 tab」,等待期间没有任何可见 UI,
* 用户看到的是点击后毫无反应。
*/
function openObjectSourceTabPending(options: OpenPendingObjectSourceTabOptions): string {
// 这个对象已经打开过:立刻切过去,再在后台重新校验源码。
// 两条弯路都要避开 —— 再建一个 pending tab 会让界面上多出一个转圈 tab,
// 随后又被交接逻辑关掉;而只切过去不校验,会让重开看到的是旧 DDL
// (改动前每次打开都会重新取源,源码 tab 没有其它刷新入口)。
const loaded = isViewOnlySourceWithoutEditablePayload(options.initialEditing, options.request.objectType)
? undefined
: findMatchingObjectSourceTab({
connectionId: options.connectionId,
database: options.database,
title: options.title,
schema: options.schema,
catalog: options.catalog,
sql: "",
objectSource: { schema: options.schema, name: options.request.name, objectType: options.request.objectType, signature: options.request.signature },
});
if (loaded) {
loaded.sourceView = true;
switchTab(loaded.id);
// 这条路径不经过 ensureConnected,但树上的动作会把该连接设为当前连接
useConnectionStore().activeConnectionId = options.connectionId;
void revalidateObjectSourceTab(loaded.id);
return loaded.id;
}
const existing = findPendingObjectSourceTab(options);
if (existing) {
switchTab(existing.id);
// 再次点击同一对象 = 再试一次,不新开 tab、不重复占用一个 tab 位
if (existing.sourceLoad?.error) retryObjectSourceTab(existing.id);
return existing.id;
}
const id = createTab(options.connectionId, options.database, options.title, "query", options.schema, "", options.catalog, { forceNew: true, sourceView: true });
const tab = tabs.value.find((candidate) => candidate.id === id);
if (tab) tab.sourceLoad = { startedAt: Date.now(), initialEditing: options.initialEditing, request: { ...options.request } };
void loadObjectSourceIntoTab(id);
return id;
}
/**
* 后台重新校验一个已加载源码 tab 的 DDL:不占用加载态、不打断编辑,
* 失败就保持原内容(用户并没有在等这次请求)。
*/
async function revalidateObjectSourceTab(id: string) {
if (sourceRevalidateInFlight.has(id)) return;
const tab = tabs.value.find((candidate) => candidate.id === id);
const objectSource = tab?.objectSource;
if (!tab || !objectSource) return;
const { connectionId } = tab;
const { database } = tab;
const schema = objectSource.schema || tab.schema || database;
sourceRevalidateInFlight.add(id);
try {
const connectionStore = useConnectionStore();
await connectionStore.ensureConnected(connectionId);
const databaseType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(connectionId));
if (!databaseType) return;
const {
raw,
editableSource,
objectType: resolvedType,
} = await loadEditableObjectSourceForEditor(api.getObjectSource, buildEditableObjectSource, {
connectionId,
database,
schema,
name: objectSource.name,
objectType: objectSource.objectType,
databaseType,
signature: objectSource.signature,
});
// 期间 tab 可能被关闭、被复用或已被编辑:身份没变且用户没改过内容时才回填
const current = tabs.value.find((candidate) => candidate.id === id);
if (current?.objectSource !== objectSource || resolvedType !== objectSource.objectType) return;
if (raw.editable === false || (OBJECT_SOURCE_READ_ONLY_TYPES.includes(resolvedType) && !(databaseType === "oceanbase-oracle" && resolvedType === "SEQUENCE"))) return;
if (isTabDirty(current)) return;
updateSql(id, editableSource);
markTabClean(current);
} catch {
// 已有的源码依然可用,静默保留
} finally {
sourceRevalidateInFlight.delete(id);
}
}
function retryObjectSourceTab(id: string) {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab?.sourceLoad) return;
tab.sourceLoad.error = undefined;
tab.sourceLoad.startedAt = Date.now();
void loadObjectSourceIntoTab(id);
}
/**
* Force-reload an object-source tab from the database (user-triggered refresh).
* Callers should confirm before discarding dirty edits.
*/
function refreshObjectSourceTab(id: string): boolean {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab) return false;
if (tab.sourceLoad && !tab.sourceLoad.error) return true;
const request = tab.sourceLoad?.request ? { ...tab.sourceLoad.request } : tab.objectSource ? { name: tab.objectSource.name, objectType: tab.objectSource.objectType, signature: tab.objectSource.signature } : null;
if (!request) return false;
sourceRevalidateInFlight.delete(id);
tab.sourceLoad = {
startedAt: Date.now(),
initialEditing: tab.sourceLoad?.initialEditing ?? !!tab.objectSource,
request,
};
void loadObjectSourceIntoTab(id);
return true;
}
function clearObjectSourceLoad(tab: QueryTab) {
tab.sourceLoad = undefined;
}
async function loadObjectSourceIntoTab(id: string) {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab?.sourceLoad || !tab.connectionId) return;
const { connectionId } = tab;
const { database } = tab;
const schema = tab.schema || database;
const { request, initialEditing } = tab.sourceLoad;
try {
const connectionStore = useConnectionStore();
await connectionStore.ensureConnected(connectionId);
connectionStore.activeConnectionId = connectionId;
const databaseType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(connectionId));
if (!databaseType) throw new Error("Connection type is unavailable.");
const {
raw,
editableSource,
objectType: resolvedType,
} = await loadEditableObjectSourceForEditor(api.getObjectSource, buildEditableObjectSource, {
connectionId,
database,
schema,
name: request.name,
objectType: request.objectType,
databaseType,
signature: request.signature,
});
applyLoadedObjectSource(id, { connectionId, database, schema, catalog: tab.catalog, title: tab.title, request, initialEditing, databaseType, editableSource, raw, resolvedType });
} catch (e: any) {
// 就地显示错误 + Retry:用户此刻正看着这个 tab,比 toast 更可发现
const failed = tabs.value.find((candidate) => candidate.id === id);
if (failed?.sourceLoad) failed.sourceLoad.error = e?.message || String(e);
}
}
function applyLoadedObjectSource(
id: string,
loaded: {
connectionId: string;
database: string;
schema?: string;
catalog?: string;
title: string;
request: ObjectSourceRequestIdentity;
initialEditing?: boolean;
databaseType: DatabaseType;
editableSource: string;
raw: ObjectSource;
resolvedType: ObjectSourceKind;
},
) {
// 加载期间 tab 被关掉(用户放弃)或连接被断开:静默丢弃,不重建、不写库
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab?.sourceLoad) return;
const sourceIsEditable = !isViewOnlySourceWithoutEditablePayload(loaded.initialEditing, loaded.resolvedType) && loaded.raw.editable !== false && (!OBJECT_SOURCE_READ_ONLY_TYPES.includes(loaded.resolvedType) || (loaded.databaseType === "oceanbase-oracle" && loaded.resolvedType === "SEQUENCE"));
if (sourceIsEditable) {
const options: OpenObjectSourceTabOptions = {
connectionId: loaded.connectionId,
database: loaded.database,
title: loaded.title,
schema: loaded.schema,
catalog: loaded.catalog,
sql: loaded.editableSource,
objectSource: { schema: loaded.schema, name: loaded.request.name, objectType: loaded.resolvedType, signature: loaded.request.signature },
};
// 解析后的身份可能命中已存在的 tab(例:先按 FUNCTION 打开过,这次请求的是 PROCEDURE)。
// 有则交接给它并关掉 pending 占位,避免同一个对象出现两个 tab。
const existing = findMatchingObjectSourceTab(options, id);
if (existing) {
clearObjectSourceLoad(tab);
closeTab(id);
existing.sourceView = true;
switchTab(existing.id);
if (!isTabDirty(existing)) {
updateSql(existing.id, loaded.editableSource);
markTabClean(existing);
}
return;
}
updateSql(id, loaded.editableSource);
setObjectSource(id, options.objectSource);
} else {
updateSql(id, loaded.raw.source);
}
tab.sourceView = true;
markTabClean(tab);
clearObjectSourceLoad(tab);
}
/**
* 「先出 tab 再加载」(issue #9387):为 pending 的 DDL 新标签取回 DDL 并回填。
* 加载在 store 侧进行,不依赖该 tab 是否处于激活状态;失败就地写入
* `ddlLoad.error`,由编辑区渲染错误 + Retry,而不是弹 toast。
*/
async function loadDdlViewerTab(id: string) {
const tab = tabs.value.find((candidate) => candidate.id === id);
const request = tab?.ddlLoad;
const ddlViewer = tab?.ddlViewer;
if (!tab || !request || !ddlViewer || !tab.connectionId) return;
const { connectionId } = tab;
const { database } = tab;
const settingsStore = useSettingsStore();
try {
const connectionStore = useConnectionStore();
const databaseType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(connectionId));
const { ddl } = await loadObjectDdl(
{
connectionId,
database,
schema: ddlViewer.schema || database,
tableName: ddlViewer.tableName,
objectType: ddlViewer.objectType,
catalog: tab.catalog,
},
// 与弹框打开一致:默认优先使用缓存,开启「每次打开时刷新」才强制重查
{ force: settingsStore.editorSettings.refreshDdlOnOpen },
);
const displayed = await formatDdlForDisplay(
ddl,
{
dialect: ddlViewer.formatDialect ?? "generic",
databaseType,
database,
catalog: tab.catalog,
includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName,
quoteIdentifiers: settingsStore.editorSettings.generateSqlQuoteIdentifiers,
excludeDdlStorage: settingsStore.editorSettings.excludeDdlStorage,
},
settingsStore.editorSettings.sqlFormatter,
);
// 加载期间 tab 被关掉,或 pending 状态已被一次重试取代:静默丢弃这次结果
const current = tabs.value.find((candidate) => candidate.id === id);
if (current?.ddlLoad !== request) return;
updateSql(id, displayed);
markTabClean(current);
current.ddlLoad = undefined;
} catch (e: any) {
const failed = tabs.value.find((candidate) => candidate.id === id);
if (failed?.ddlLoad === request) failed.ddlLoad = { startedAt: Date.now(), error: e?.message || String(e) };
}
}
/** 就地重试失败的 DDL 加载;已完成加载的 DDL 标签走编辑区工具栏的刷新。 */
function retryDdlViewerTab(id: string) {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab?.ddlLoad) return;
tab.ddlLoad = { startedAt: Date.now() };
void loadDdlViewerTab(id);
}
function showExecutedQueryResults(connectionId: string, database: string, sql: string, queryResults: QueryResult[]) {
const id = createTab(connectionId, database, undefined, "query", undefined, sql);
const tab = tabs.value.find((item) => item.id === id);
if (!tab) return id;
const results = markQueryResultsRowsRaw(queryResults);
const firstDataResult = results.findIndex((result) => result.columns.length > 0);
const activeIndex = firstDataResult >= 0 ? firstDataResult : 0;
tab.lastExecutedSql = sql;
tab.resultBaseSql = sql;
tab.results = results.length > 1 ? results : undefined;
tab.activeResultIndex = results.length > 1 ? activeIndex : undefined;
tab.result = results[activeIndex];
tab.isExecuting = false;
tab.isCancelling = false;
tab.executionId = undefined;
tab.executingResultRunId = undefined;
tab.queryExecutionStartedAt = undefined;
// An externally-supplied result is a brand-new dataset, not the previous
// one: publish a fresh generation so the tab can capture a view snapshot.
publishResultGeneration(tab, "execute");
if (tab.result) touchResult(tab);
return id;
}
function refreshExternalSqlFileTitles() {
const externalTabs = tabs.value.filter((tab) => tab.mode === "query" && tab.externalSqlPath);
const titles = externalSqlFileDisplayTitles(externalTabs.map((tab) => tab.externalSqlPath!));
externalTabs.forEach((tab, index) => {
tab.title = titles[index];
tab.customTitle = true;
});
}
function openExternalSqlFile(connectionId: string, database: string, path: string, sql: string, version?: QueryTab["externalSqlFileVersion"], catalog?: string, schema?: string, reveal?: { line: number; column?: number }) {
const normalizedPath = normalizeExternalSqlPath(path);
const existing = tabs.value.find((tab) => tab.mode === "query" && tab.externalSqlPath && normalizeExternalSqlPath(tab.externalSqlPath) === normalizedPath);
if (existing) {
if (reveal) existing.editorRevealRequest = { id: ++contentRevealSeq, line: reveal.line, column: reveal.column };
switchTab(existing.id);
return existing.id;
}
// File-backed tabs are identified by their full path, not their basename.
// Bypassing createTab avoids overwriting another file with the same name.
const id = uuid();
const dbType = useConnectionStore().getConfig(connectionId)?.db_type;
const tab: QueryTab = {
id,
title: "",
customTitle: true,
connectionId,
database,
catalog,
// Restoring the schema keeps the reopened file on the namespace it was
// saved from. Without it the tab has no schema, so sidebar locate and the
// metadata paths fall back to the connection default (issue #7648).
schema,
sql,
originalSql: sql,
externalSqlPath: path,
externalSqlFileVersion: version,
editorRevealRequest: reveal ? { id: ++contentRevealSeq, line: reveal.line, column: reveal.column } : undefined,
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "query",
autoCommit: defaultAutoCommitForDbTypeWithSetting(dbType),
};
registerOpenTab(tab);
refreshExternalSqlFileTitles();
return id;
}
function openObjectBrowser(connectionId: string, database: string, schema?: string, catalog?: string, eventName?: string, eventReadOnly = false, initialObjectFilter?: "tables" | "events", eventCreateRequestId?: number) {
const title = catalog ? `${catalog}.${database} objects` : schema ? `${schema} objects` : `${database} objects`;
const existing = tabs.value.find((tab) => tab.mode === "objects" && tab.connectionId === connectionId && tab.database === database && (tab.objectBrowser?.catalog || "") === (catalog || "") && (tab.objectBrowser?.schema || "") === (schema || ""));
if (existing) {
if (eventCreateRequestId !== undefined) {
// 新建事件:显式 CREATE 请求优先,并清掉可能残留的"编辑已有事件"状态,
// 保证同一 tab 被复用时每次点击都能重新进入 CREATE 编辑器(请求号单调递增)。
existing.objectBrowser = {
...existing.objectBrowser,
eventName: undefined,
eventReadOnly: false,
eventOpenRequestId: undefined,
eventCreateRequestId,
initialObjectFilter: initialObjectFilter ?? "events",
};
} else if (eventName) {
existing.objectBrowser = {
...existing.objectBrowser,
eventName,
eventReadOnly,
eventCreateRequestId: undefined,
initialObjectFilter: initialObjectFilter ?? (eventName ? "events" : existing.objectBrowser?.initialObjectFilter),
eventOpenRequestId: (existing.objectBrowser?.eventOpenRequestId ?? 0) + 1,
};
}
switchTab(existing.id);
return existing.id;
}
const id = uuid();
const tab: QueryTab = {
id,
title,
connectionId,
database,
schema,
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "objects",
objectBrowser: {
catalog,
schema,
objectType: "tables",
eventName: eventCreateRequestId !== undefined ? undefined : eventName,
eventReadOnly: eventCreateRequestId !== undefined ? false : eventReadOnly,
initialObjectFilter: initialObjectFilter ?? (eventName || eventCreateRequestId !== undefined ? "events" : undefined),
eventOpenRequestId: eventName ? 1 : undefined,
eventCreateRequestId,
},
};
return registerOpenTab(tab);
}
function openDatabaseBrowser(connectionId: string) {
const existing = tabs.value.find((tab) => tab.mode === "databases" && tab.connectionId === connectionId);
if (existing) {
switchTab(existing.id);
return existing.id;
}
const id = uuid();
return registerOpenTab({
id,
title: "Databases",
connectionId,
database: "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "databases",
});
}
function openDriverProfileWorkspace(connectionId: string, database: string, title: string, mode: QueryTab["mode"], tabScope: DriverProfileWorkspaceScope = "database", workspaceBranch?: string) {
const existing = tabs.value.find((tab) => tab.mode === mode && tab.connectionId === connectionId && (tabScope === "connection" || tab.database === database));
if (existing) {
if (existing.database !== database) updateDatabase(existing.id, database);
existing.workspaceBranch = workspaceBranch;
switchTab(existing.id);
return existing.id;
}
const id = uuid();
return registerOpenTab({
id,
title,
connectionId,
database,
workspaceBranch,
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode,
});
}
function switchTab(tabId: string) {
const owner = groupForTab(tabId);
if (owner) {
activateTabInGroup(owner.id, tabId);
return;
}
activeTabId.value = tabId;
settingsStore.settingsPageActive = false;
if (typeof window !== "undefined") window.dispatchEvent(new Event(QUERY_SURFACE_ACTIVATION_EVENT));
}
function openUserAdmin(connectionId: string) {
const existing = tabs.value.find((tab) => tab.mode === "users" && tab.connectionId === connectionId);
if (existing) {
switchTab(existing.id);
return existing.id;
}
const conn = useConnectionStore().getConfig(connectionId);
const id = uuid();
const tab: QueryTab = {
id,
title: t("userAdmin.title"),
connectionId,
database: conn?.database || "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "users",
};
return registerOpenTab(tab);
}
function openProcessList(connectionId: string) {
const existing = tabs.value.find((tab) => tab.mode === "processlist" && tab.connectionId === connectionId);
if (existing) {
switchTab(existing.id);
return existing.id;
}
const conn = useConnectionStore().getConfig(connectionId);
const id = uuid();
const tab: QueryTab = {
id,
title: conn?.name ? `${conn.name} - ${t("processList.title")}` : t("processList.title"),
connectionId,
database: conn?.database || "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "processlist",
};
return registerOpenTab(tab);
}
function openSqlServerActivityTrace(connectionId: string) {
const existing = tabs.value.find((tab) => tab.mode === "sqlserver-trace" && tab.connectionId === connectionId);
if (existing) {
switchTab(existing.id);
return existing.id;
}
const conn = useConnectionStore().getConfig(connectionId);
const id = uuid();
const tab: QueryTab = {
id,
title: conn?.name ? `${conn.name} - ${t("sqlServerTrace.title")}` : t("sqlServerTrace.title"),
connectionId,
database: conn?.database || "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "sqlserver-trace",
};
return registerOpenTab(tab);
}
function openMysqlDashboard(connectionId: string) {
const existing = tabs.value.find((tab) => tab.mode === "mysql-dashboard" && tab.connectionId === connectionId);
if (existing) {
switchTab(existing.id);
return existing.id;
}
const conn = useConnectionStore().getConfig(connectionId);
const id = uuid();
const tab: QueryTab = {
id,
title: conn?.name ? `${conn.name} - ${t("serverDashboard.title")}` : t("serverDashboard.title"),
connectionId,
database: "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "mysql-dashboard",
};
return registerOpenTab(tab);
}
function openPostgresDashboard(connectionId: string) {
const existing = tabs.value.find((tab) => tab.mode === "postgres-dashboard" && tab.connectionId === connectionId);
if (existing) {
switchTab(existing.id);
return existing.id;
}
const conn = useConnectionStore().getConfig(connectionId);
const id = uuid();
const tab: QueryTab = {
id,
title: conn?.name ? `${conn.name} - ${t("serverDashboard.title")}` : t("serverDashboard.title"),
connectionId,
database: conn?.database || "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "postgres-dashboard",
};
return registerOpenTab(tab);
}
function openXuguDashboard(connectionId: string) {
const existing = tabs.value.find((tab) => tab.mode === "xugu-dashboard" && tab.connectionId === connectionId);
if (existing) {
switchTab(existing.id);
return existing.id;
}
const conn = useConnectionStore().getConfig(connectionId);
const id = uuid();
const tab: QueryTab = {
id,
title: conn?.name ? `${conn.name} - ${t("xuguServerDashboard.title")}` : t("xuguServerDashboard.title"),
connectionId,
database: conn?.database || "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "xugu-dashboard",
};
return registerOpenTab(tab);
}
function openNacosDashboard(connectionId: string) {
const existing = tabs.value.find((tab) => tab.mode === "nacos-dashboard" && tab.connectionId === connectionId);
if (existing) {
switchTab(existing.id);
return existing.id;
}
const conn = useConnectionStore().getConfig(connectionId);
const id = uuid();
const tab: QueryTab = {
id,
title: conn?.name ? `${conn.name} - ${t("serverDashboard.title")}` : t("serverDashboard.title"),
connectionId,
database: conn?.database || "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "nacos-dashboard",
};
return registerOpenTab(tab);
}
function openSolrAdmin(connectionId: string) {
const existing = tabs.value.find((tab) => tab.mode === "solr-admin" && tab.connectionId === connectionId);
if (existing) {
switchTab(existing.id);
return existing.id;
}
const conn = useConnectionStore().getConfig(connectionId);
const id = uuid();
const tab: QueryTab = {
id,
title: conn?.name ? `${conn.name} - ${t("solrAdmin.title")}` : t("solrAdmin.title"),
connectionId,
database: "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "solr-admin",
};
return registerOpenTab(tab);
}
function openDamengJobAdmin(connectionId: string) {
const existing = tabs.value.find((tab) => tab.mode === "dameng-jobs" && tab.connectionId === connectionId);
if (existing) {
switchTab(existing.id);
return existing.id;
}
const conn = useConnectionStore().getConfig(connectionId);
const id = uuid();
const tab: QueryTab = {
id,
title: t("damengJobAdmin.title"),
connectionId,
database: conn?.database || "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "dameng-jobs",
};
return registerOpenTab(tab);
}
function openDamengUsers(connectionId: string) {
const existing = tabs.value.find((tab) => tab.mode === "dameng-users" && tab.connectionId === connectionId);
if (existing) {
switchTab(existing.id);
return existing.id;
}
const conn = useConnectionStore().getConfig(connectionId);
const id = uuid();
const tab: QueryTab = {
id,
title: t("damengUserAdmin.title"),
connectionId,
database: conn?.database || "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "dameng-users",
};
return registerOpenTab(tab);
}
function openDamengRoles(connectionId: string) {
const existing = tabs.value.find((tab) => tab.mode === "dameng-roles" && tab.connectionId === connectionId);
if (existing) {
switchTab(existing.id);
return existing.id;
}
const conn = useConnectionStore().getConfig(connectionId);
const id = uuid();
const tab: QueryTab = {
id,
title: t("damengRoleAdmin.title"),
connectionId,
database: conn?.database || "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "dameng-roles",
};
return registerOpenTab(tab);
}
function openMongoBucket(connectionId: string, database: string, bucketName: string) {
const title = `${database}.${bucketName}`;
const existing = tabs.value.find((tab) => tab.mode === "mongo-bucket" && tab.connectionId === connectionId && tab.database === database && tab.mongoBucket?.bucketName === bucketName);
if (existing) {
switchTab(existing.id);
return existing.id;
}
const id = uuid();
const tab: QueryTab = {
id,
title,
connectionId,
database,
sql: bucketName,
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "mongo-bucket",
mongoBucket: {
bucketName,
},
};
return registerOpenTab(tab);
}
function openMongoGridFs(connectionId: string, database: string) {
const existing = tabs.value.find((tab) => tab.mode === "mongo-gridfs" && tab.connectionId === connectionId && tab.database === database);
if (existing) {
switchTab(existing.id);
return existing.id;
}
const id = uuid();
const tab: QueryTab = {
id,
title: "GridFS",
connectionId,
database,
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "mongo-gridfs",
};
return registerOpenTab(tab);
}
function openMqAdmin(connectionId: string, target?: { tenant?: string; initialTab?: QueryTab["mqInitialTab"] }) {
const existing = tabs.value.find((tab) => tab.mode === "mq" && tab.connectionId === connectionId);
if (existing) {
if (target?.tenant) existing.mqTenant = target.tenant;
if (target?.initialTab) existing.mqInitialTab = target.initialTab;
switchTab(existing.id);
return existing.id;
}
const conn = useConnectionStore().getConfig(connectionId);
const id = uuid();
const tab: QueryTab = {
id,
title: `${conn?.name || "Message Queue"} Admin`,
connectionId,
database: conn?.database || "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "mq",
mqTenant: target?.tenant,
mqInitialTab: target?.initialTab,
};
return registerOpenTab(tab);
}
function openNacosAdmin(connectionId: string, target?: { namespace?: string; namespaceName?: string; dataId?: string; group?: string; keyword?: string }) {
const namespace = target?.namespace ?? "";
const namespaceName = target?.namespaceName || (namespace ? namespace : "public");
const existing = tabs.value.find((tab) => tab.mode === "nacos" && tab.connectionId === connectionId && (tab.nacosNamespace || "") === namespace);
if (existing) {
existing.nacosNamespaceName = namespaceName;
if (target?.dataId) {
existing.nacosTargetDataId = target.dataId;
existing.nacosTargetGroup = target.group || "DEFAULT_GROUP";
existing.nacosTargetKeyword = target.keyword;
existing.nacosTargetRequestId = (existing.nacosTargetRequestId ?? 0) + 1;
}
if (!existing.customTitle) existing.title = `${useConnectionStore().getConfig(connectionId)?.name || "Nacos"}:${namespaceName}`;
switchTab(existing.id);
return existing.id;
}
const conn = useConnectionStore().getConfig(connectionId);
const id = uuid();
const tab: QueryTab = {
id,
title: `${conn?.name || "Nacos"}:${namespaceName}`,
connectionId,
database: conn?.database || "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "nacos",
nacosNamespace: namespace,
nacosNamespaceName: namespaceName,
nacosTargetDataId: target?.dataId,
nacosTargetGroup: target?.group,
nacosTargetKeyword: target?.keyword,
nacosTargetRequestId: target?.dataId ? 1 : undefined,
};
return registerOpenTab(tab);
}
function openMqttAdmin(connectionId: string, target?: { initialTopic?: string }) {
const existing = tabs.value.find((tab) => tab.mode === "mqtt" && tab.connectionId === connectionId);
if (existing) {
if (target?.initialTopic) existing.mqttInitialTopic = target.initialTopic;
switchTab(existing.id);
return existing.id;
}
const conn = useConnectionStore().getConfig(connectionId);
const id = uuid();
const tab: QueryTab = {
id,
title: "connection.mqttConsoleTitle",
connectionId,
database: conn?.database || "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "mqtt",
mqttInitialTopic: target?.initialTopic,
};
return registerOpenTab(tab);
}
function clearNacosNavigationTarget(connectionId: string, namespace: string, requestId?: number) {
const tab = tabs.value.find((candidate) => candidate.mode === "nacos" && candidate.connectionId === connectionId && (candidate.nacosNamespace || "") === namespace);
if (!tab || (requestId !== undefined && tab.nacosTargetRequestId !== requestId)) return;
tab.nacosTargetDataId = undefined;
tab.nacosTargetGroup = undefined;
tab.nacosTargetKeyword = undefined;
}
/**
* Effective connection binding of a plugin tab: bridge-created tabs carry
* the connection only inside context (tab-level connectionId is ""), so
* read both. New tabs normalize the context value up to the tab level at
* creation, but restored/legacy tabs still need the fallback.
*/
function pluginTabConnectionId(tab: QueryTab): string {
if (tab.connectionId) return tab.connectionId;
const contextConnectionId = tab.pluginWorkbench?.context?.connectionId;
return typeof contextConnectionId === "string" ? contextConnectionId : "";
}
function pluginTabPluginId(tab: QueryTab): string | undefined {
if (tab.mode === "plugin-workbench") return tab.pluginWorkbench?.pluginId;
if (tab.mode === "plugin-filesystem") return tab.pluginFilesystem?.pluginId;
return undefined;
}
function openPluginWorkbench(pluginId: string, contributionId: string, options: { commandId?: string; title?: string; connectionId?: string; database?: string; context?: Record<string, unknown>; forceNew?: boolean; refreshContextOnReuse?: boolean } = {}) {
const contextConnectionId = typeof options.context?.connectionId === "string" ? options.context.connectionId : "";
const connectionId = options.connectionId || contextConnectionId;
if (!options.forceNew) {
const existing = tabs.value.find((tab) => tab.mode === "plugin-workbench" && tab.pluginWorkbench?.pluginId === pluginId && tab.pluginWorkbench?.contributionId === contributionId && pluginTabConnectionId(tab) === connectionId);
if (existing) {
// Adopt legacy/bridge-created tabs without changing their live context.
// A shared workbench keeps an existing command's provenance on reuse.
if (existing.pluginWorkbench!.commandId == null && options.commandId) {
existing.pluginWorkbench!.commandId = options.commandId;
}
if (options.refreshContextOnReuse && options.context) {
existing.pluginWorkbench = {
...existing.pluginWorkbench!,
context: snapshotPluginWorkbenchContext(options.context),
};
}
// Reopening normally surfaces the existing tab as-is. Replacing the
// context here by default (openPluginConnection mints a fresh
// workbenchId per click) would deep-reload the plugin webview — a full
// flash plus losing the sidecar session binding on the old workbench
// id. Stateless surfaces such as result-view can explicitly opt into
// a context refresh without changing the tab identity.
// A tab created by an older build (or otherwise unregistered) may
// still be ownerless; land it in the workspace or the group-rendered
// tab strips can never show it.
if (!groupForTab(existing.id)) {
ensureTabInWorkspace(existing.id);
}
switchTab(existing.id);
return existing.id;
}
}
// Termius-style session numbering: the first same-plugin + same-connection
// tab keeps the bare connection name; each additional one gets " (n)"
// where n advances beyond the highest live suffix (second tab → (1)).
// Numbers are assigned at creation and never backfilled after a close —
// stable titles beat dense numbering, while live titles stay unique.
const siblingTabs = tabs.value.filter((tab) => tab.mode === "plugin-workbench" && tab.pluginWorkbench?.pluginId === pluginId && pluginTabConnectionId(tab) === connectionId);
const nextSessionNumber =
siblingTabs.reduce((highest, tab) => {
const suffix = / \((\d+)\)$/.exec(tab.title);
return suffix ? Math.max(highest, Number(suffix[1])) : highest;
}, 0) + 1;
const baseTitle = options.title || contributionId;
const id = uuid();
const tab: QueryTab = {
id,
title: siblingTabs.length >= 1 ? `${baseTitle} (${nextSessionNumber})` : baseTitle,
connectionId,
database: options.database || "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "plugin-workbench",
pluginWorkbench: {
commandId: options.commandId,
pluginId,
contributionId,
context: options.context ? snapshotPluginWorkbenchContext(options.context) : undefined,
},
};
// The split workspace renders strips from group membership — always go
// through registerOpenTab so the tab joins the focused group (raw push
// left it ownerless and invisible in every tab strip).
return registerOpenTab(tab);
}
// Connectionless plugin tabs inherit their title from the localized
// contribution label (sidebar/webview workbench opens and filesystem
// browse), so a locale switch leaves them showing the previous language.
// Re-resolve them on locale change: connection-bound tabs keep the
// connection name, explicit renames (customTitle) win, and a missing
// localized label leaves the current title untouched.
function localizePluginTabTitles(resolveTitle: (pluginId: string, contributionId: string, surface: "ui" | "filesystem") => string | undefined): void {
for (const tab of tabs.value) {
const target =
tab.mode === "plugin-workbench" && tab.pluginWorkbench
? { pluginId: tab.pluginWorkbench.pluginId, contributionId: tab.pluginWorkbench.contributionId, surface: "ui" as const }
: tab.mode === "plugin-filesystem" && tab.pluginFilesystem
? { pluginId: tab.pluginFilesystem.pluginId, contributionId: tab.pluginFilesystem.providerId, surface: "filesystem" as const }
: undefined;
if (!target || tab.customTitle || pluginTabConnectionId(tab)) continue;
const localizedTitle = resolveTitle(target.pluginId, target.contributionId, target.surface)?.trim();
if (!localizedTitle) continue;
const suffix = / \((\d+)\)$/.exec(tab.title)?.[0] || "";
tab.title = `${localizedTitle}${suffix}`;
}
}
function openPluginFilesystem(pluginId: string, providerId: string, options: { title?: string; connectionId?: string; rootUri?: string; currentUri?: string; forceNew?: boolean } = {}) {
if (!options.forceNew) {
const existing = tabs.value.find((tab) => tab.mode === "plugin-filesystem" && tab.pluginFilesystem?.pluginId === pluginId && tab.pluginFilesystem?.providerId === providerId && tab.connectionId === (options.connectionId || ""));
if (existing) {
if (options.currentUri) existing.pluginFilesystem = { ...existing.pluginFilesystem!, currentUri: options.currentUri };
if (!groupForTab(existing.id)) {
ensureTabInWorkspace(existing.id);
}
switchTab(existing.id);
return existing.id;
}
}
const id = uuid();
const tab: QueryTab = {
id,
title: options.title || providerId,
connectionId: options.connectionId || "",
database: "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "plugin-filesystem",
pluginFilesystem: {
pluginId,
providerId,
rootUri: options.rootUri,
currentUri: options.currentUri,
},
};
return registerOpenTab(tab);
}
async function openPluginConnection(connectionId: string) {
const connectionStore = useConnectionStore();
const connection = connectionStore.getConfig(connectionId);
if (!connection || connection.db_type !== "plugin") throw new Error("Plugin connection config not found");
const pluginId = connection.plugin_id;
const providerId = connection.plugin_connection_provider;
if (!pluginId || !providerId) throw new Error("Plugin connection binding is incomplete");
const registry = createFrontendPluginRegistry(await api.listPlugins(), currentLocale());
const provider = registry.listConnectionProviders().find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === providerId);
if (!provider) throw new Error(`Plugin connection provider '${pluginId}/${providerId}' is unavailable`);
const workbench = provider.contribution.workbench ? registry.findWorkbench(pluginId, provider.contribution.workbench) : undefined;
await connectionStore.ensureConnected(connectionId);
if (workbench) {
return openPluginWorkbench(pluginId, workbench.contribution.id, {
title: connection.name,
connectionId,
context: {
connectionId,
providerId,
connectionType: connection.plugin_connection_type,
workbenchId: uuid(),
connection: {
id: connection.id,
name: connection.name,
host: connection.host,
port: connection.port,
username: connection.username,
readOnly: false,
},
},
});
}
const filesystemProviderId = provider.contribution.filesystem_provider;
const filesystem = filesystemProviderId ? registry.listFilesystemProviders().find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === filesystemProviderId) : undefined;
if (!filesystem) throw new Error(`Plugin connection provider '${pluginId}/${providerId}' does not declare a workbench or filesystem provider`);
return openPluginFilesystem(pluginId, filesystem.contribution.id, {
title: `${connection.name} · SFTP`,
connectionId,
rootUri: filesystem.contribution.root_uri,
});
}
/**
* Boot-time tab restore only replays tab metadata: after a host restart the
* sidecar's in-memory connection registry starts empty (credentials are
* injected per connect via `connection/connect`), so a restored plugin
* workbench/filesystem tab fails its first session/open with "Connection is
* not active" until the user reopens the connection from the sidebar. Replay
* the connect lifecycle for every distinct restored plugin connection —
* fire-and-forget so an interactive password prompt or a slow sidecar spawn
* never blocks startup; per-connection failures surface in the tab's own UI.
*/
async function reconnectRestoredPluginTabs() {
const connectionStore = useConnectionStore();
const activeConnectionId = tabs.value.find((tab) => tab.id === activeTabId.value)?.connectionId;
const orderedIds: string[] = [];
for (const tab of tabs.value) {
if (tab.mode !== "plugin-workbench" && tab.mode !== "plugin-filesystem") continue;
if (!tab.connectionId || orderedIds.includes(tab.connectionId)) continue;
if (tab.connectionId === activeConnectionId) orderedIds.unshift(tab.connectionId);
else orderedIds.push(tab.connectionId);
}
for (const connectionId of orderedIds) {
if (connectionStore.getConfig(connectionId)?.db_type !== "plugin") continue;
try {
// activate:false keeps the boot restore from overriding the last
// active connection already chosen by restoreActiveConnectionContext().
await connectionStore.ensureConnected(connectionId, { activate: false });
} catch (error) {
console.warn("[DBX][plugin-tab-restore:reconnect]", connectionId, error);
}
}
}
function applyTableStructureInitialTab(tab: QueryTab, initialTab?: TableInfoTab, initialTarget?: TableStructureEditorTarget) {
if (!initialTab && !initialTarget?.name) return;
if (initialTab) tab.structureInitialTab = initialTab;
tab.structureInitialTarget = initialTarget?.name ? initialTarget : undefined;
tab.structureInitialTabRequestId = (tab.structureInitialTabRequestId ?? 0) + 1;
}
function openTableStructure(connectionId: string, database: string, schema?: string, tableName?: string, initialTab?: TableInfoTab, initialTarget?: TableStructureEditorTarget, catalog?: string, tableType?: "table" | "view") {
const resolvedTableName = tableName || "";
if (resolvedTableName) {
const existing = tabs.value.find((tab) => tab.mode === "structure" && tab.connectionId === connectionId && tab.database === database && (tab.catalog || "") === (catalog || "") && (tab.schema || "") === (schema || "") && (tab.structureTableName || "") === resolvedTableName);
if (existing) {
applyTableStructureInitialTab(existing, initialTab, initialTarget);
switchTab(existing.id);
return existing.id;
}
}
const title = resolvedTableName ? t("structureEditor.editTabTitle", { tableName: resolvedTableName }) : t("structureEditor.createTitle");
const id = uuid();
const tab: QueryTab = {
id,
title,
connectionId,
database,
schema,
catalog,
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "structure",
structureTableName: resolvedTableName,
structureTableType: tableType,
structureInitialTab: initialTab,
structureInitialTabRequestId: initialTab || initialTarget?.name ? 1 : undefined,
structureInitialTarget: initialTarget?.name ? initialTarget : undefined,
};
return registerOpenTab(tab);
}
function isTabDirty(tab: QueryTab): boolean {
if (tab.mode === "structure") {
// Legacy persisted structure drafts predate the dirty flag; treat them as dirty until the editor rehydrates them.
return !!tab.structureDraft && tab.structureDraft.dirty !== false;
}
if (tab.mode !== "query") return false;
if (!tab.externalSqlPath && !tab.sql.trim() && !(tab.savedSqlId && tab.originalSql !== undefined)) return false;
const original = tab.originalSql;
if (original === undefined) return !!tab.savedSqlId;
return tab.sql !== original;
}
const hasDirtyTabs = computed(() => tabs.value.some((tab) => isTabDirty(tab)));
const shouldConfirmUnsavedSqlClose = computed(() => useSettingsStore().editorSettings.confirmUnsavedSqlClose);
const keepUnsavedTabsDraftsOnAppClose = computed(() => useSettingsStore().editorSettings.appCloseUnsavedTabsMode === "keep-drafts");
const requiresAppCloseDraftPersist = computed(() => shouldConfirmUnsavedSqlClose.value && keepUnsavedTabsDraftsOnAppClose.value && tabs.value.some((tab) => tab.mode === "query" && isTabDirty(tab)));
function shouldConfirmTabOnAppClose(tab: QueryTab): boolean {
if (!shouldConfirmTabClose(tab)) return false;
return !keepUnsavedTabsDraftsOnAppClose.value || tab.mode !== "query";
}
const closeConfirmDirtyTabIds = computed(() => {
if (isConfirmingAppClose.value) return tabs.value.filter((tab) => shouldConfirmTabOnAppClose(tab)).map((tab) => tab.id);
if (pendingBatchCloseTabIds.value) {
return pendingBatchCloseTabIds.value
.map((id) => tabs.value.find((tab) => tab.id === id))
.filter((tab): tab is QueryTab => !!tab && shouldConfirmTabClose(tab))
.map((tab) => tab.id);
}
const pendingTab = pendingCloseTabId.value ? tabs.value.find((tab) => tab.id === pendingCloseTabId.value) : undefined;
return pendingTab && isTabDirty(pendingTab) ? [pendingTab.id] : [];
});
function showDirtyTabCloseConfirm(tab: QueryTab, context: CloseConfirmContext) {
pendingCloseTabId.value = tab.id;
closeConfirmContext.value = context;
// Move focus through the atomic activate so the confirming dialog, the
// shared result area, and the dirty tab's owner group stay coherent.
if (!activateTab(tab.id)) {
activeTabId.value = tab.id;
}
showCloseConfirm.value = true;
}
function markTabClean(tab: QueryTab | undefined) {
if (tab) tab.originalSql = tab.sql;
}
function applyExternalSqlFileSnapshot(id: string, sql: string, version: NonNullable<QueryTab["externalSqlFileVersion"]>) {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab?.externalSqlPath) return;
tab.sql = sql;
tab.originalSql = sql;
tab.externalSqlFileVersion = version;
tab.externalSqlIgnoredFileVersion = undefined;
tab.externalSqlFileMissing = undefined;
}
function markExternalSqlFileSaved(id: string, version: NonNullable<QueryTab["externalSqlFileVersion"]>) {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab?.externalSqlPath) return;
tab.originalSql = tab.sql;
tab.externalSqlFileVersion = version;
tab.externalSqlIgnoredFileVersion = undefined;
tab.externalSqlFileMissing = undefined;
}
function updateExternalSqlFileVersion(id: string, version: NonNullable<QueryTab["externalSqlFileVersion"]>) {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab?.externalSqlPath) return;
tab.externalSqlFileVersion = version;
tab.externalSqlIgnoredFileVersion = undefined;
tab.externalSqlFileMissing = undefined;
}
function ignoreExternalSqlFileVersion(id: string, version: NonNullable<QueryTab["externalSqlFileVersion"]>) {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab?.externalSqlPath) return;
tab.externalSqlIgnoredFileVersion = version;
tab.externalSqlFileMissing = undefined;
}
function acknowledgeExternalSqlFileMissing(id: string) {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab?.externalSqlPath) return;
tab.externalSqlFileMissing = true;
}
function relocateExternalSqlFilePath(previousPath: string, nextPath: string, version?: QueryTab["externalSqlFileVersion"]) {
const previous = normalizeExternalSqlPath(previousPath);
if (!previous) return;
for (const tab of tabs.value) {
if (tab.mode !== "query" || !tab.externalSqlPath || normalizeExternalSqlPath(tab.externalSqlPath) !== previous) continue;
tab.externalSqlPath = nextPath;
if (version) tab.externalSqlFileVersion = version;
tab.externalSqlIgnoredFileVersion = undefined;
tab.externalSqlFileMissing = undefined;
}
refreshExternalSqlFileTitles();
}
function markExternalSqlFileMissingForPath(path: string) {
const normalizedPath = normalizeExternalSqlPath(path);
if (!normalizedPath) return;
for (const tab of tabs.value) {
if (tab.mode === "query" && tab.externalSqlPath && normalizeExternalSqlPath(tab.externalSqlPath) === normalizedPath) {
tab.externalSqlFileMissing = true;
}
}
}
function persistSavedSqlEditorPosition(tab: QueryTab | undefined) {
if (!tab?.savedSqlId || tab.mode !== "query") return;
const pending = savedSqlEditorPositionTimers.get(tab.savedSqlId);
if (pending) {
clearTimeout(pending);
savedSqlEditorPositionTimers.delete(tab.savedSqlId);
}
saveSavedSqlEditorPosition(
createSavedSqlEditorPosition({
savedSqlId: tab.savedSqlId,
sql: tab.sql,
selection: tab.editorSelection,
viewport: tab.editorViewport,
}),
);
}
function flushEditorState(id: string): Promise<void> {
const tab = tabs.value.find((item) => item.id === id);
if (!tab) return Promise.resolve();
persistSavedSqlEditorPosition(tab);
return flushPendingPersist();
}
function queueSavedSqlEditorPositionPersist(tab: QueryTab | undefined) {
if (!tab?.savedSqlId || tab.mode !== "query") return;
const pending = savedSqlEditorPositionTimers.get(tab.savedSqlId);
if (pending) clearTimeout(pending);
const tabId = tab.id;
const savedSqlId = tab.savedSqlId;
const timer = setTimeout(() => {
savedSqlEditorPositionTimers.delete(savedSqlId);
persistSavedSqlEditorPosition(tabs.value.find((item) => item.id === tabId));
}, SAVED_SQL_EDITOR_POSITION_PERSIST_DELAY_MS);
savedSqlEditorPositionTimers.set(savedSqlId, timer);
}
function discardTabChanges(id: string) {
const tab = tabs.value.find((item) => item.id === id);
if (!tab) return false;
if (tab.mode === "structure") {
tab.structureDraft = undefined;
return true;
}
if (tab.mode !== "query") return false;
if (tab.originalSql !== undefined) {
tab.sql = tab.originalSql;
return true;
}
if (tab.savedSqlId) {
tab.sql = "";
return true;
}
tab.sql = "";
tab.originalSql = "";
return true;
}
function finishPendingBatchClose() {
const finalActiveTabId = pendingBatchCloseFinalActiveTabId.value;
const onComplete = pendingBatchCloseComplete;
pendingBatchCloseTabIds.value = null;
pendingBatchCloseFinalActiveTabId.value = undefined;
pendingBatchCloseComplete = null;
if (finalActiveTabId !== undefined) {
commitBatchCloseActiveTab(finalActiveTabId);
}
return onComplete;
}
/**
* Applies a batch close's chosen active tab while keeping the group
* invariants: the surviving tab becomes its owner group's active tab and the
* focused group. Falls back to the focused group when the choice no longer
* exists.
*/
function commitBatchCloseActiveTab(finalActiveTabId: string | null) {
if (finalActiveTabId && tabs.value.some((tab) => tab.id === finalActiveTabId)) {
if (!activateTab(finalActiveTabId)) {
syncActiveTabFromFocusedGroup();
}
return;
}
syncActiveTabFromFocusedGroup();
}
function continuePendingBatchClose() {
const pendingIds = pendingBatchCloseTabIds.value;
if (!pendingIds) return;
const remainingIds = pendingIds.filter((id) => tabs.value.some((tab) => tab.id === id));
pendingBatchCloseTabIds.value = remainingIds;
if (remainingIds.length === 0) {
finishPendingBatchClose()?.();
return;
}
const dirtyTab = remainingIds.map((id) => tabs.value.find((tab) => tab.id === id)).find((tab): tab is QueryTab => !!tab && shouldConfirmTabClose(tab));
if (dirtyTab) {
// Batch close must pause before dropping dirty tabs so the shared save/discard dialog protects every editable surface.
showDirtyTabCloseConfirm(dirtyTab, "batch");
return;
}
const onComplete = finishPendingBatchClose();
for (const id of remainingIds) closeTab(id, { force: true });
onComplete?.();
}
function beginBatchClose(ids: string[], finalActiveTabId?: string | null, onComplete?: () => void) {
const uniqueIds = [...new Set(ids)].filter((id) => tabs.value.some((tab) => tab.id === id));
if (uniqueIds.length === 0) {
onComplete?.();
return;
}
const existingIds = pendingBatchCloseTabIds.value;
if (existingIds) {
// Sidebar bulk disconnects settle independently, so later scopes must join the open dialog instead of replacing it.
const combinedIds = [...new Set([...existingIds, ...uniqueIds])];
const preferredFinalActiveTabId = finalActiveTabId !== undefined ? finalActiveTabId : pendingBatchCloseFinalActiveTabId.value;
pendingBatchCloseTabIds.value = combinedIds;
pendingBatchCloseFinalActiveTabId.value = preferredFinalActiveTabId && combinedIds.includes(preferredFinalActiveTabId) ? activeTabAfterClosing(combinedIds, preferredFinalActiveTabId) : preferredFinalActiveTabId;
if (onComplete) {
const previousComplete = pendingBatchCloseComplete;
pendingBatchCloseComplete = previousComplete
? () => {
try {
previousComplete();
} finally {
onComplete();
}
}
: onComplete;
}
} else {
pendingBatchCloseTabIds.value = uniqueIds;
pendingBatchCloseFinalActiveTabId.value = finalActiveTabId;
pendingBatchCloseComplete = onComplete ?? null;
}
continuePendingBatchClose();
}
function resumePendingBatchCloseAfter(id: string) {
const pendingIds = pendingBatchCloseTabIds.value;
if (!pendingIds?.includes(id)) return;
pendingBatchCloseTabIds.value = pendingIds.filter((pendingId) => pendingId !== id);
continuePendingBatchClose();
}
const pluginReleaseInFlight = new Set<string>();
/**
* Closing the last plugin tab only releases a connection explicitly owned by
* the plugin that closed it. Workbench/filesystem tabs can borrow a Host-owned
* connection, so a connectionId reference alone is never enough to disconnect.
* Fire-and-forget; concurrent releases for the same connection coalesce, and
* a reopen racing the release wins.
*/
function releasePluginConnectionsAfterClose(closedTabs: ReadonlyArray<QueryTab>) {
const closedPluginTabsByConnection = new Map<string, Set<string>>();
for (const tab of closedTabs) {
if (tab.mode !== "plugin-workbench" && tab.mode !== "plugin-filesystem") continue;
const connectionId = pluginTabConnectionId(tab);
const pluginId = pluginTabPluginId(tab);
if (!connectionId || !pluginId) continue;
const pluginIds = closedPluginTabsByConnection.get(connectionId) ?? new Set<string>();
pluginIds.add(pluginId);
closedPluginTabsByConnection.set(connectionId, pluginIds);
}
for (const [connectionId, closedPluginIds] of closedPluginTabsByConnection) {
if (pluginReleaseInFlight.has(connectionId)) continue;
pluginReleaseInFlight.add(connectionId);
void (async () => {
try {
// Yield one microtask so a same-tick reopen registers its
// replacement plugin tab before we tear the connection down.
await Promise.resolve();
if (tabs.value.some((tab) => (tab.mode === "plugin-workbench" || tab.mode === "plugin-filesystem") && pluginTabConnectionId(tab) === connectionId)) return;
const connectionStore = useConnectionStore();
const config = connectionStore.getConfig(connectionId);
// A connection reference is not ownership: only a plugin-backed
// connection closed by its owning plugin may be auto-released.
if (config?.db_type !== "plugin" || !config.plugin_id || !closedPluginIds.has(config.plugin_id)) return;
// A user-initiated disconnect that closed these tabs already owns
// the teardown — don't stack a second one on top of it.
if (connectionStore.hasDisconnectInFlight(connectionId)) return;
await connectionStore.disconnect(connectionId);
} catch (error) {
console.warn("[DBX][plugin-tab-close:disconnect]", connectionId, error);
} finally {
pluginReleaseInFlight.delete(connectionId);
}
})();
}
}
function closeTab(id: string, { force = false }: { force?: boolean } = {}) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab) return;
if (!force && shouldConfirmTabClose(tab)) {
showDirtyTabCloseConfirm(tab, "tab");
return;
}
const idx = tabs.value.findIndex((t) => t.id === id);
if (idx < 0) return;
persistSavedSqlEditorPosition(tabs.value[idx]);
if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id);
clearDataGridPendingSnapshotsForTab(id);
beginClosingDataGridViewSnapshotsForTab(id);
beginClosingBrowserState(id);
clearDataGridStructuredFilterStatesForTab(id);
clearDataGridSearchStatesForTab(id);
if (tabs.value[idx].txnSessionId) void rollbackTransaction(id);
if (tabs.value[idx].isExecuting) void cancelTabExecution(id);
if (tabs.value[idx].isExplaining) void cancelTabExplain(id);
void closeResultSession(tabs.value[idx]);
void closeClientConnectionSession(tabs.value[idx]);
clearResultRunSnapshots(tabs.value[idx]);
void deleteTabResultSnapshot(tabResultCacheKey(id));
releaseTabResultObjectPayloads(tabs.value[idx]);
clearResultRuns(tabs.value[idx]);
clearResultPayload(tabs.value[idx]);
const owner = groupForTab(id);
const ownerIndexInGroup = owner ? owner.tabIds.indexOf(id) : -1;
const wasOwnerActive = owner?.activeTabId === id;
const wasGlobalActive = activeTabId.value === id;
tabs.value.splice(idx, 1);
releasePluginConnectionsAfterClose([tab]);
if (tab.externalSqlPath) refreshExternalSqlFileTitles();
if (owner) {
owner.tabIds = owner.tabIds.filter((tabId) => tabId !== id);
if (wasOwnerActive) {
const groupHistory = activeTabHistory.value.filter((tabId) => tabId !== id && owner.tabIds.includes(tabId));
owner.activeTabId = groupHistory[groupHistory.length - 1] ?? owner.tabIds[Math.min(ownerIndexInGroup, owner.tabIds.length - 1)] ?? null;
}
if (owner.tabIds.length === 0 && groups.value.length > 1) {
const removedIndex = groups.value.findIndex((group) => group.id === owner.id);
groups.value.splice(removedIndex, 1);
if (focusedGroupId.value === owner.id) {
focusedGroupId.value = groups.value[0]?.id ?? "main";
}
repairGroupSizes();
}
}
if (wasGlobalActive) {
const nextGroup = focusedGroup();
activeTabId.value = nextGroup?.activeTabId ?? null;
}
if (force) resumePendingBatchCloseAfter(id);
}
function shouldConfirmTabClose(tab: QueryTab): boolean {
if (tab.mode === "structure") return isTabDirty(tab);
return shouldConfirmUnsavedSqlClose.value && isTabDirty(tab);
}
function forceClosePendingTab() {
const id = pendingCloseTabId.value;
const confirmingAppClose = isConfirmingAppClose.value;
pendingCloseTabId.value = null;
showCloseConfirm.value = false;
closeConfirmContext.value = "tab";
if (confirmingAppClose) {
if (id) discardTabChanges(id);
isConfirmingAppClose.value = false;
return;
}
if (id) closeTab(id, { force: true });
}
function forceCloseAllPendingTabs() {
const dirtyIds = closeConfirmDirtyTabIds.value;
const pendingId = pendingCloseTabId.value;
const batchIds = pendingBatchCloseTabIds.value?.filter((id) => tabs.value.some((tab) => tab.id === id)) ?? null;
const finalActiveTabId = pendingBatchCloseFinalActiveTabId.value;
const onBatchComplete = pendingBatchCloseComplete;
const confirmingAppClose = isConfirmingAppClose.value;
pendingCloseTabId.value = null;
showCloseConfirm.value = false;
pendingBatchCloseTabIds.value = null;
pendingBatchCloseFinalActiveTabId.value = undefined;
pendingBatchCloseComplete = null;
isConfirmingAppClose.value = false;
closeConfirmContext.value = "tab";
for (const id of dirtyIds) discardTabChanges(id);
if (confirmingAppClose) return;
const idsToClose = batchIds ?? (pendingId ? [pendingId] : []);
for (const id of idsToClose) closeTab(id, { force: true });
if (finalActiveTabId !== undefined) {
commitBatchCloseActiveTab(finalActiveTabId);
}
if (batchIds) onBatchComplete?.();
}
function cancelClosePendingTab() {
pendingCloseTabId.value = null;
showCloseConfirm.value = false;
pendingBatchCloseTabIds.value = null;
pendingBatchCloseFinalActiveTabId.value = undefined;
pendingBatchCloseComplete = null;
isConfirmingAppClose.value = false;
closeConfirmContext.value = "tab";
}
function saveAndClosePendingTab() {
const id = pendingCloseTabId.value;
pendingCloseTabId.value = null;
showCloseConfirm.value = false;
isConfirmingAppClose.value = false;
closeConfirmContext.value = "tab";
if (id) return id;
return null;
}
function suspendCloseConfirm() {
showCloseConfirm.value = false;
}
function resumeCloseConfirm() {
const dirtyId = closeConfirmDirtyTabIds.value[0];
const dirtyTab = dirtyId ? tabs.value.find((tab) => tab.id === dirtyId) : undefined;
if (!dirtyTab) return false;
pendingCloseTabId.value = dirtyTab.id;
activateTab(dirtyTab.id);
showCloseConfirm.value = true;
return true;
}
function completePendingCloseAfterSaveAll() {
const pendingId = pendingCloseTabId.value;
const batchIds = pendingBatchCloseTabIds.value?.filter((id) => tabs.value.some((tab) => tab.id === id)) ?? null;
const finalActiveTabId = pendingBatchCloseFinalActiveTabId.value;
const onBatchComplete = pendingBatchCloseComplete;
const confirmingAppClose = isConfirmingAppClose.value;
pendingCloseTabId.value = null;
showCloseConfirm.value = false;
pendingBatchCloseTabIds.value = null;
pendingBatchCloseFinalActiveTabId.value = undefined;
pendingBatchCloseComplete = null;
isConfirmingAppClose.value = false;
closeConfirmContext.value = "tab";
if (confirmingAppClose) return "app" as const;
const idsToClose = batchIds ?? (pendingId ? [pendingId] : []);
for (const id of idsToClose) closeTab(id, { force: true });
if (finalActiveTabId !== undefined) {
commitBatchCloseActiveTab(finalActiveTabId);
}
if (batchIds) onBatchComplete?.();
return "tabs" as const;
}
function closeOtherTabs(id: string) {
if (!tabs.value.some((tab) => tab.id === id)) return;
beginBatchClose(
tabs.value.filter((tab) => tab.id !== id).map((tab) => tab.id),
id,
);
}
function closeOtherTabsInGroup(groupId: string, id: string) {
const group = findGroup(groupId);
const target = tabs.value.find((tab) => tab.id === id);
if (!group || !target) return;
const ids = group.tabIds.filter((tabId) => {
const tab = tabs.value.find((item) => item.id === tabId);
return tab && tabId !== id && Boolean(tab.pinned) === Boolean(target.pinned);
});
beginBatchClose(ids, id);
}
function closeAllTabsInGroup(groupId: string, id: string) {
const group = findGroup(groupId);
const target = tabs.value.find((tab) => tab.id === id);
if (!group || !target) return;
const ids = group.tabIds.filter((tabId) => {
const tab = tabs.value.find((item) => item.id === tabId);
return tab && Boolean(tab.pinned) === Boolean(target.pinned);
});
if (ids.length === 0) return;
const finalActiveTabId = group.activeTabId && !ids.includes(group.activeTabId) ? group.activeTabId : id;
beginBatchClose(ids, finalActiveTabId);
}
function closeTabsByIds(ids: Iterable<string>, finalActiveTabId?: string | null, onComplete?: () => void) {
beginBatchClose([...ids], finalActiveTabId, onComplete);
}
function closeRightTabs(id: string, onComplete?: () => void) {
const target = tabs.value.find((tab) => tab.id === id);
if (!target) return;
const groupedTabs = tabs.value.filter((tab) => Boolean(tab.pinned) === Boolean(target.pinned));
const targetIndex = groupedTabs.findIndex((tab) => tab.id === id);
const ids = groupedTabs.slice(targetIndex + 1).map((tab) => tab.id);
if (ids.length === 0) {
onComplete?.();
return;
}
const finalActiveTabId = activeTabId.value && !ids.includes(activeTabId.value) ? activeTabId.value : id;
closeTabsByIds(ids, finalActiveTabId, onComplete);
}
function finalActiveTabAfterClosing(ids: string[]) {
const closingIds = new Set(ids);
const activeTab = activeTabId.value ? tabs.value.find((tab) => tab.id === activeTabId.value) : undefined;
if (activeTab && !closingIds.has(activeTab.id)) return activeTab.id;
return tabs.value.find((tab) => !closingIds.has(tab.id))?.id ?? null;
}
function activeTabAfterClosing(ids: string[], preferredActiveTabId = activeTabId.value) {
const closingIds = new Set(ids);
if (preferredActiveTabId && !closingIds.has(preferredActiveTabId) && tabs.value.some((tab) => tab.id === preferredActiveTabId)) {
return preferredActiveTabId;
}
const preferredIndex = preferredActiveTabId ? tabs.value.findIndex((tab) => tab.id === preferredActiveTabId) : -1;
const remainingTabs = tabs.value.filter((tab) => !closingIds.has(tab.id));
if (preferredIndex < 0) return remainingTabs[0]?.id ?? null;
return remainingTabs[Math.min(preferredIndex, remainingTabs.length - 1)]?.id ?? null;
}
function closeOtherRegularTabs(id: string) {
const tab = tabs.value.find((item) => item.id === id);
if (!tab || tab.pinned) return;
beginBatchClose(
tabs.value.filter((item) => !item.pinned && item.id !== id).map((item) => item.id),
id,
);
}
function closeRegularTabs() {
const ids = tabs.value.filter((tab) => !tab.pinned).map((tab) => tab.id);
beginBatchClose(ids, finalActiveTabAfterClosing(ids));
}
function closeOtherFixedTabs(id: string) {
const tab = tabs.value.find((item) => item.id === id);
if (!tab || !tab.pinned) return;
beginBatchClose(
tabs.value.filter((item) => item.pinned && item.id !== id).map((item) => item.id),
id,
);
}
function closeFixedTabs() {
const ids = tabs.value.filter((tab) => tab.pinned).map((tab) => tab.id);
beginBatchClose(ids, finalActiveTabAfterClosing(ids));
}
function closeAllTabs() {
beginBatchClose(
tabs.value.map((tab) => tab.id),
null,
);
}
function requestAppCloseConfirmation() {
const dirtyTab = tabs.value.find((tab) => shouldConfirmTabOnAppClose(tab));
if (!dirtyTab) return false;
isConfirmingAppClose.value = true;
showDirtyTabCloseConfirm(dirtyTab, "app");
return true;
}
function duplicateTab(id: string) {
const idx = tabs.value.findIndex((t) => t.id === id);
if (idx < 0) return;
const original = tabs.value[idx];
const newId = uuid();
const newTab: QueryTab = {
id: newId,
title: original.title,
customTitle: original.customTitle,
connectionId: original.connectionId,
database: original.database,
schema: original.schema,
catalog: original.catalog,
sql: original.sql,
originalSql: "",
savedSqlId: undefined,
externalSqlPath: undefined,
lastExecutedSql: undefined,
resultBaseSql: original.resultBaseSql,
resultSortedSql: undefined,
resultSortColumn: undefined,
resultSortColumnIndex: undefined,
resultSortDirection: undefined,
resultSortMode: undefined,
resultLocalSortOriginalRows: undefined,
resultLocalSortOriginalLargeValueCells: undefined,
resultLocalSortOriginalMongoDocuments: undefined,
resultLocalSortOriginalMongoCopyDocuments: undefined,
orderByInput: undefined,
resultPageSql: undefined,
resultPageLimit: undefined,
resultPageOffset: undefined,
resultCountSql: undefined,
resultTotalRowCount: undefined,
resultTotalRowCountLoading: undefined,
resultSessionId: undefined,
resultClientSessionId: undefined,
resultAccessedAt: undefined,
resultCacheKey: undefined,
resultCacheState: undefined,
pinned: false,
result: undefined,
results: undefined,
activeResultIndex: undefined,
explainPlan: undefined,
explainError: undefined,
explainSql: undefined,
lastExplainedSql: undefined,
isExecuting: false,
isCancelling: false,
queryExecutionStartedAt: undefined,
editorViewport: undefined,
editorSelection: undefined,
executionId: undefined,
executingResultRunId: undefined,
isExplaining: false,
explainExecutionId: undefined,
mode: original.mode,
mqTenant: original.mqTenant,
mqInitialTab: original.mqInitialTab,
nacosNamespace: original.nacosNamespace,
nacosNamespaceName: original.nacosNamespaceName,
structureTableName: original.structureTableName,
structureTableType: original.structureTableType,
structureDraft: original.structureDraft ? cloneTabDraft(original.structureDraft) : undefined,
objectBrowser: original.objectBrowser ? { ...original.objectBrowser } : undefined,
objectSource: original.objectSource ? { ...original.objectSource } : undefined,
sourceView: original.sourceView,
tableMeta: original.tableMeta ? { ...original.tableMeta, columns: [...original.tableMeta.columns], primaryKeys: [...original.tableMeta.primaryKeys] } : undefined,
queryAnalysis: original.queryAnalysis ? { ...original.queryAnalysis, sources: original.queryAnalysis.sources?.map((source) => ({ ...source })), columns: original.queryAnalysis.columns.map((c) => ({ ...c })) } : undefined,
querySourceColumns: original.querySourceColumns ? [...original.querySourceColumns] : undefined,
queryWriteTargets: original.queryWriteTargets?.map((target) => ({ ...target, sourceColumns: [...target.sourceColumns] })),
resultColumnComments: original.resultColumnComments ? [...original.resultColumnComments] : undefined,
queryDisplaySourceColumns: original.queryDisplaySourceColumns ? [...original.queryDisplaySourceColumns] : undefined,
queryEditabilityReason: original.queryEditabilityReason,
resultEvicted: undefined,
whereInput: original.whereInput,
previewSql: original.previewSql,
};
initializeResultAutoSave(newTab);
tabs.value.splice(idx + 1, 0, newTab);
const owner = groupForTab(id);
if (owner) {
const ownerIndex = owner.tabIds.indexOf(id);
owner.tabIds.splice(ownerIndex + 1, 0, newId);
owner.activeTabId = newId;
focusedGroupId.value = owner.id;
}
activeTabId.value = newId;
}
function closeTabsWhere(predicate: (tab: QueryTab) => boolean) {
const closingIds = new Set(tabs.value.filter((tab) => predicate(tab)).map((tab) => tab.id));
if (closingIds.size === 0) return;
const closingTabs = tabs.value.filter((tab) => closingIds.has(tab.id));
closingTabs.forEach((tab) => {
if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id);
clearDataGridPendingSnapshotsForTab(tab.id);
beginClosingDataGridViewSnapshotsForTab(tab.id);
beginClosingBrowserState(tab.id);
clearDataGridStructuredFilterStatesForTab(tab.id);
clearDataGridSearchStatesForTab(tab.id);
if (tab.txnSessionId) void rollbackTransaction(tab.id);
if (tab.isExecuting) void cancelTabExecution(tab.id);
if (tab.isExplaining) void cancelTabExplain(tab.id);
void closeResultSession(tab);
void closeClientConnectionSession(tab);
clearResultRunSnapshots(tab);
void deleteTabResultSnapshot(tabResultCacheKey(tab.id));
releaseTabResultObjectPayloads(tab);
clearResultRuns(tab);
clearResultPayload(tab);
});
const activeClosingIndex = tabs.value.findIndex((tab) => tab.id === activeTabId.value && closingIds.has(tab.id));
tabs.value = tabs.value.filter((tab) => !closingIds.has(tab.id));
releasePluginConnectionsAfterClose(closingTabs);
// Resolve the fallback against the POST-filter array: the survivor at the
// closing tab's index, not the closing tab itself.
const fallbackTabId = activeClosingIndex >= 0 ? tabs.value[Math.min(activeClosingIndex, tabs.value.length - 1)]?.id : undefined;
// Drop the closed ids from their owning groups, repair each group's
// active tab, and remove groups emptied by this close.
removeTabsFromGroups(closingIds);
if (fallbackTabId && tabs.value.some((tab) => tab.id === fallbackTabId)) {
activateTab(fallbackTabId);
} else {
syncActiveTabFromFocusedGroup();
}
}
/**
* Removes a set of tab ids from whichever groups own them, repairs each
* group's active tab (MRU first, then adjacent), and prunes groups emptied
* by the removal. Used by bulk close paths so group state stays consistent
* without a normalization pass.
*/
function removeTabsFromGroups(closingIds: Set<string>) {
if (closingIds.size === 0) return;
for (const group of groups.value) {
if (!group.tabIds.some((tabId) => closingIds.has(tabId))) continue;
const ownerIndexInGroup = group.activeTabId ? group.tabIds.indexOf(group.activeTabId) : -1;
const wasOwnerActive = group.activeTabId != null && closingIds.has(group.activeTabId);
group.tabIds = group.tabIds.filter((tabId) => !closingIds.has(tabId));
if (wasOwnerActive) {
const groupHistory = activeTabHistory.value.filter((tabId) => !closingIds.has(tabId) && group.tabIds.includes(tabId));
group.activeTabId = groupHistory[groupHistory.length - 1] ?? group.tabIds[Math.min(Math.max(ownerIndexInGroup, 0), group.tabIds.length - 1)] ?? null;
}
}
pruneEmptyGroups();
}
function closeScopedTabsWhere(predicate: (tab: QueryTab) => boolean, options: { force?: boolean } = {}) {
const ids = tabs.value.filter((tab) => predicate(tab)).map((tab) => tab.id);
if (options.force) {
closeTabsWhere(predicate);
return;
}
beginBatchClose(ids, activeTabAfterClosing(ids));
}
function closeConnectionTabs(connectionId: string, options?: { force?: boolean }) {
closeScopedTabsWhere((tab) => tab.connectionId === connectionId, options);
}
function closeDatabaseTabs(connectionId: string, database: string) {
closeScopedTabsWhere((tab) => tab.connectionId === connectionId && tab.database === database);
}
function tabMatchesDroppedTableObject(tab: QueryTab, target: DroppedTableObjectTarget): boolean {
if (tab.connectionId !== target.connectionId || tab.database !== target.database) return false;
const targetSchemas = droppedTableObjectSchemaCandidates(target);
if ((target.objectType ?? "TABLE") === "TABLE" && tab.mode === "hbase") {
return tab.sql === target.name;
}
if (tab.mode === "data") {
const tableMeta = tableMetaForDataTab(tab);
if (!tableMeta || tableMeta.tableName !== target.name) return false;
return targetSchemas.has(normalizeOptionalSchema(tableMeta.schema ?? tab.schema));
}
if ((target.objectType ?? "TABLE") === "TABLE" && tab.mode === "structure") {
if ((tab.structureTableName || "") !== target.name) return false;
return targetSchemas.has(normalizeOptionalSchema(tab.schema));
}
return false;
}
function tabMatchesTableDataRefreshTarget(tab: QueryTab, target: TableDataRefreshTarget): boolean {
if (tab.mode !== "data" || tab.connectionId !== target.connectionId || tab.database !== target.database) return false;
const tableMeta = tableMetaForDataTab(tab);
if (!tableMeta || tableMeta.tableName !== target.name) return false;
if ((tableMeta.catalog || "") !== (target.catalog || "")) return false;
const targetSchemas = droppedTableObjectSchemaCandidates(target);
return targetSchemas.has(normalizeOptionalSchema(tableMeta.schema ?? tab.schema));
}
function closeDroppedTableObjectTabs(target: DroppedTableObjectTarget) {
// A dropped table-like object makes existing data/structure tabs stale; close
// them immediately instead of letting the next refresh fail against a missing object.
closeTabsWhere((tab) => tabMatchesDroppedTableObject(tab, target));
}
async function refreshDataTabInternal(id: string, options?: { supersedeBusy?: boolean; propagateBuildError?: boolean }): Promise<boolean> {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab || tab.mode !== "data" || (tab.isExecuting && !options?.supersedeBusy)) return false;
const connStore = useConnectionStore();
const conn = connStore.getConfig(tab.connectionId);
const effectiveDbType = effectiveDatabaseTypeForConnection(conn);
const identifierQuote = connStore.connectionIdentifierQuote?.(tab.connectionId);
const refreshPreparationId = uuid();
// Reserve the tab synchronously before SQL construction yields so repeated
// refresh requests cannot build and execute duplicate queries.
setExecutingWithId(tab.id, refreshPreparationId);
try {
let tableMeta = tableMetaForDataTab(tab);
if (!tableMeta?.tableName) return false;
// 生命周期代次校验:disconnect / 关库 / 死池重连后的首次刷新必须先从
// 新连接源头重建结构,否则旧显式列列表会生成错误 SELECT(issue #6623 /
// PR #6640 review blocker 2)。reload 路径不读 tableMetaUpdatedAt 风干
// 判定、只读 tableMeta 本身,因此在这里显式强制重建。
const connectionGeneration = connStore.metadataGenerationFor(tab.connectionId, tab.database);
if (isDataTabMetadataLifecycleStale(tab, connectionGeneration)) {
const metadataGenerationAtStart = connectionGeneration;
const reloadedMetadata = await loadTableMetadata({
connectionId: tab.connectionId,
database: tableMeta.database ?? tab.database,
schema: tableMeta.schema,
tableName: tableMeta.tableName,
tableType: tableMeta.tableType,
catalog: tableMeta.catalog,
databaseType: effectiveDbType ?? conn?.db_type ?? "",
driverProfile: conn?.driver_profile,
force: true,
});
// 重建期间又跨越了一次连接生命周期边界 → 放弃本次刷新,避免旧结果
// 二次写回 tab(PR #6640 review blocker 1 的 tab-local 半边)
if (connStore.metadataGenerationFor(tab.connectionId, tab.database) !== metadataGenerationAtStart) return false;
const current = tabs.value.find((candidate) => candidate.id === id);
if (!current || current.executionId !== refreshPreparationId) return false;
setTableMeta(tab.id, tableMetadataToDataTabMeta(reloadedMetadata.metadata, { schema: tableMeta.schema }));
tableMeta = tableMetaForDataTab(tab) ?? tableMeta;
}
clearInvalidDataTabSortState(tab, tableMeta.columns);
const primaryKeys = tab.tableMeta ? tab.tableMeta.primaryKeys : tableMeta.primaryKeys;
const sortOrder = tab.resultSortColumn && tab.resultSortDirection ? `${quoteTableDataIdentifier(effectiveDbType, tab.resultSortColumn, identifierQuote)} ${tab.resultSortDirection.toUpperCase()}` : undefined;
const orderBy = tab.orderByInput?.trim() || sortOrder;
const limit = tab.resultPageLimit ?? tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize);
const offset = tab.resultPageOffset ?? 0;
const useDriverRowOffset = jdbcConnectionUsesDriverRowOffset(conn, effectiveDbType);
const sql = await buildTableSelectSql({
databaseType: effectiveDbType,
driverProfile: conn?.driver_profile,
identifierQuote,
database: tableMeta.database,
schema: tableMeta.schema,
tableName: tableMeta.tableName,
tableType: tableMeta.tableType,
catalog: tableMeta.catalog,
columns: tableMeta.columns.map((column) => column.name),
primaryKeys,
...tableDataLargeValuePreviewOptions(effectiveDbType, tableMeta.columns, primaryKeys, limit),
includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName,
includeRowId: shouldIncludeSyntheticRowId(effectiveDbType, primaryKeys, tableMeta.tableType),
whereInput: tab.whereInput,
injectDefaultTimeSeriesWhere: true,
orderBy,
limit,
offset,
...(useDriverRowOffset ? { useDriverRowOffset: true } : {}),
});
if (!sql.trim()) throw new Error("Failed to build table refresh SQL");
const current = tabs.value.find((candidate) => candidate.id === id);
if (!current || current.executionId !== refreshPreparationId) return false;
updateSql(tab.id, sql);
await executeTabSql(tab.id, sql, {
pagination: { limit, offset },
preserveResultDuringExecution: true,
publicationOrigin: "refresh",
});
return true;
} catch (error) {
const current = tabs.value.find((candidate) => candidate.id === id);
if (current?.executionId === refreshPreparationId) setErrorResult(id, error);
if (options?.propagateBuildError) throw error;
return false;
}
}
function refreshDataTab(id: string): Promise<boolean> {
return refreshDataTabInternal(id);
}
async function refreshDataTabsForTable(target: TableDataRefreshTarget): Promise<number> {
const matchingTabs = tabs.value.filter((tab) => tabMatchesTableDataRefreshTarget(tab, target));
if (matchingTabs.length === 0) return 0;
let refreshed = 0;
for (const tab of matchingTabs) {
if (await refreshDataTabInternal(tab.id, { supersedeBusy: true, propagateBuildError: true })) refreshed += 1;
}
return refreshed;
}
/** 释放单个页签的运行期状态:回滚事务、清空结果与执行态,但保留页签本身。 */
function releaseTabRuntimeState(tab: QueryTab) {
rollbackTabTransaction(tab, { resetAutoCommit: true });
clearDataGridPendingSnapshotsForTab(tab.id);
beginClosingDataGridViewSnapshotsForTab(tab.id);
beginClosingBrowserState(tab.id);
clearDataGridStructuredFilterStatesForTab(tab.id);
clearDataGridSearchStatesForTab(tab.id);
if (tab.isExecuting) void cancelTabExecution(tab.id);
if (tab.isExplaining) void cancelTabExplain(tab.id);
void closeResultSession(tab);
void closeClientConnectionSession(tab);
clearResultRunSnapshots(tab);
void deleteTabResultSnapshot(tabResultCacheKey(tab.id));
releaseTabResultObjectPayloads(tab);
clearResultRuns(tab);
clearResultPayload(tab);
}
/**
* 释放匹配的页签:关闭非 SQL 页签,并清空 SQL 页签的运行期状态(保留页签本身)。
*/
function releaseTabsWhere(predicate: (tab: QueryTab) => boolean) {
closeTabsWhere((tab) => predicate(tab) && tab.mode !== "query");
tabs.value.filter((tab) => predicate(tab)).forEach(releaseTabRuntimeState);
}
function releaseConnectionTabs(connectionId: string) {
releaseTabsWhere((tab) => tab.connectionId === connectionId);
}
/**
* 删除连接时的页签处理:连接配置已从磁盘移除、无法再重连,因此策略与断开连接分开。
* - `none`:关闭该连接的全部页签(默认)
* - `sql`:保留全部 SQL 页签(`mode === "query"`),其余关闭
* - `pinned-sql`:只保留固定的 SQL 页签,其余关闭
* - `all`:不关闭任何页签,保留 SQL 文本与当前结果(与断开设置的「不关闭相关页签」一致)
*
* 关闭一律走非 force 路径(`closeScopedTabsWhere` → `beginBatchClose`):干净页签立即关闭,
* 未保存的 SQL 草稿与表结构草稿都要经保存/放弃确认,删除连接不能静默丢改动。
*
* 该连接下的 SQL 页签都会记录原连接名(`detachedConnectionName`),便于新建同名连接后
* 重新绑定;包括因用户在确认框中选择取消而残留的页签。
*/
function detachConnectionTabsForDelete(connectionId: string, options: { keep?: DeletedConnectionTabKeepMode; connectionName?: string } = {}) {
const keepMode = options.keep ?? "none";
const keep = keepMode === "all" ? () => true : keepMode === "pinned-sql" ? (tab: QueryTab) => tab.mode === "query" && tab.pinned === true : keepMode === "sql" ? (tab: QueryTab) => tab.mode === "query" : () => false;
const connectionName = options.connectionName?.trim();
// 先给该连接下的 SQL 页签打上脱离标记。关闭流程可能因为「未保存」确认被用户取消,
// 取消后残留的页签同样需要能按连接名重绑到新建的同名连接。
if (connectionName) {
for (const tab of tabs.value) {
if (tab.connectionId !== connectionId || tab.mode !== "query") continue;
tab.detachedConnectionName = connectionName;
}
}
if (keepMode === "all") {
// 连接已消失,任何未结束的事务都不可能再提交,但仍保留页签与已加载的结果。
rollbackConnectionTransactions(connectionId);
return;
}
closeScopedTabsWhere((tab) => tab.connectionId === connectionId && !keep(tab));
tabs.value.filter((tab) => tab.connectionId === connectionId && keep(tab)).forEach(releaseTabRuntimeState);
}
/**
* 新建同名连接后,把因删除连接而保留下来的 SQL 页签重新绑定到新连接。
* 返回重新绑定的页签数量;同名页签已指向该连接时只清除脱离标记。
*
* 页签自身记录的库优先——它可能指向该连接下的另一个库;只有页签没有库时才回落到
* 新连接的默认库,避免重绑把页签的执行上下文改掉。
*/
function rebindDetachedTabs(connectionName: string, connectionId: string, database?: string): number {
const name = connectionName.trim();
if (!name) return 0;
const fallbackDatabase = database?.trim();
let rebound = 0;
for (const tab of tabs.value) {
if (tab.detachedConnectionName !== name) continue;
tab.detachedConnectionName = undefined;
if (tab.connectionId === connectionId) continue;
tab.connectionId = connectionId;
if (fallbackDatabase && !tab.database?.trim()) tab.database = fallbackDatabase;
rebound += 1;
}
return rebound;
}
function releaseDatabaseTabs(connectionId: string, database: string) {
releaseTabsWhere((tab) => tab.connectionId === connectionId && tab.database === database);
}
/**
* 连接生命周期边界(断开连接 / 关闭数据库连接 / 后端连接池失效重连)。
* 数据标签页的 tableMeta 为展示/编辑保留(不清除 UI、主键与编辑安全门控),
* 但清掉 freshness 戳:此后 openData 的 tab-local 暖缓存判定、激活路径的
* stale 判定、以及网格 reload 的 metadata age 判定全部按"冷缓存"处理,
* 即使位于 30s TTL 窗口内也会重新拉取结构(issue #6623)。
*/
function staleConnectionDataTabMetadata(connectionId: string, database?: string) {
for (const tab of tabs.value) {
if (tab.mode !== "data" || tab.connectionId !== connectionId) continue;
if (database != null && tab.database !== database) continue;
tab.tableMetaUpdatedAt = undefined;
}
}
function isDatabaseOpen(connectionId: string, database: string) {
return openDatabaseKeys.value.has(`${connectionId}\x00${database}`);
}
function rollbackTabsWhere(predicate: (tab: QueryTab) => boolean, options?: { resetAutoCommit?: boolean }) {
tabs.value.filter((tab) => predicate(tab)).forEach((tab) => rollbackTabTransaction(tab, options));
}
function rollbackConnectionTransactions(connectionId: string) {
rollbackTabsWhere((tab) => tab.connectionId === connectionId, { resetAutoCommit: true });
}
function rollbackDatabaseTransactions(connectionId: string, database: string) {
rollbackTabsWhere((tab) => tab.connectionId === connectionId && tab.database === database, { resetAutoCommit: true });
}
function updateSql(id: string, sql: string) {
const tab = tabs.value.find((t) => t.id === id);
if (tab) {
tab.sql = sql;
queueSavedSqlEditorPositionPersist(tab);
}
}
function updateDataGridLocalColumnFilters(id: string, filters: Record<string, string[]>) {
const tab = tabs.value.find((item) => item.id === id);
if (!tab?.result) return;
if (Object.keys(filters).length === 0) {
delete tab.result.local_column_filters;
} else {
tab.result.local_column_filters = Object.fromEntries(Object.entries(filters).map(([columnIndex, values]) => [columnIndex, [...values]]));
}
}
function updateDataGridHiddenColumnKeys(id: string, keys: string[]) {
const tab = tabs.value.find((item) => item.id === id);
if (!tab?.result) return;
if (keys.length === 0) {
delete tab.result.local_hidden_column_keys;
} else {
tab.result.local_hidden_column_keys = [...keys];
}
}
const manualTransactionTargetEpochs = new WeakMap<QueryTab, number>();
const pendingManualTransactionStarts = new Map<string, { epoch: number; promise: Promise<string> }>();
function manualTransactionTargetEpoch(tab: QueryTab): number {
return manualTransactionTargetEpochs.get(tab) ?? 0;
}
function invalidateManualTransactionTarget(tab: QueryTab) {
manualTransactionTargetEpochs.set(tab, manualTransactionTargetEpoch(tab) + 1);
}
async function ensureManualTransactionSession(id: string, database: string, schema?: string, catalog?: string): Promise<string> {
const tab = tabs.value.find((item) => item.id === id);
if (!tab || tab.mode !== "query" || tab.autoCommit !== false || !tab.connectionId) {
throw new Error("Manual transaction mode is no longer active for this query tab");
}
if (tab.txnSessionId) return tab.txnSessionId;
const epoch = manualTransactionTargetEpoch(tab);
const pending = pendingManualTransactionStarts.get(id);
if (pending?.epoch === epoch) return pending.promise;
const connectionId = tab.connectionId;
const originalDatabase = tab.database;
const originalCatalog = tab.catalog;
const originalSchema = tab.schema;
const start = api
.beginManualTransaction(connectionId, database, schema, catalog)
.then(async (sessionId) => {
if (tabs.value.find((item) => item.id === id) !== tab || tab.autoCommit !== false || manualTransactionTargetEpoch(tab) !== epoch || tab.connectionId !== connectionId || tab.database !== originalDatabase || tab.catalog !== originalCatalog || tab.schema !== originalSchema) {
await api.rollbackManualTransaction(sessionId);
throw new Error("Query tab changed while the manual transaction was starting");
}
tab.txnSessionId = sessionId;
return sessionId;
})
.finally(() => {
if (pendingManualTransactionStarts.get(id)?.promise === start) pendingManualTransactionStarts.delete(id);
});
pendingManualTransactionStarts.set(id, { epoch, promise: start });
return start;
}
function setAutoCommit(id: string, autoCommit: boolean) {
const tab = tabs.value.find((t) => t.id === id);
if (tab) {
if (tab.autoCommit !== autoCommit) invalidateManualTransactionTarget(tab);
const wasManual = tab.autoCommit === false;
tab.autoCommit = autoCommit;
if (autoCommit && wasManual) {
if (tab.txnSessionId) {
void rollbackTransaction(id);
} else {
clearManualTransactionSession(tab);
}
}
}
}
function markManualTransactionDirty(id: string) {
const tab = tabs.value.find((item) => item.id === id);
if (!tab?.txnSessionId) return;
const dbType = effectiveDatabaseTypeForConnection(useConnectionStore().getConfig(tab.connectionId));
if (usesProvenReadOnlyStickyTransactionState(dbType)) tab.txnPossiblyDirty = true;
}
/** Reset only the sticky proven-read-only 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 clearTxnPossiblyDirty(tab: { txnPossiblyDirty?: boolean }) {
if (tab.txnPossiblyDirty !== undefined) tab.txnPossiblyDirty = false;
}
/** Whether an expired manual transaction still owes the user the
* `toolbar.txnAutoRolledBack` notice.
*
* Sticky proven-read-only dialects (Oracle / OceanBase-Oracle / MySQL /
* PostgreSQL) track whether the session ever ran a statement that is not
* proven read-only: `txnPossiblyDirty` stays unset while every batch was
* proven read-only, so an idle expiry discarded no uncommitted work and the
* session can be rebuilt silently (#9831). Dialects without that tracking
* cannot tell a clean session from a dirty one, so they keep the notice. */
function manualTransactionRollbackNoticeRequired(tab: { txnPossiblyDirty?: boolean }, dbType?: string): boolean {
if (!usesProvenReadOnlyStickyTransactionState(dbType)) return true;
return tab.txnPossiblyDirty === true;
}
/** Auto-commit tabs mirror the backend's report of an open explicit
* transaction. The flag is dropped whenever the tab stops pointing at the
* connection that reported it (target switch, tab close), so a stale badge
* can never outlive the session it describes. */
function clearAutoCommitOpenTransaction(tab: { autoCommitOpenTransaction?: boolean; autoCommitSessionTxnRolledBack?: boolean; autoCommitSessionTxnRolledBackNotified?: boolean }) {
if (tab.autoCommitOpenTransaction !== undefined) tab.autoCommitOpenTransaction = false;
tab.autoCommitSessionTxnRolledBack = undefined;
tab.autoCommitSessionTxnRolledBackNotified = undefined;
}
/** 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.txnPossiblyDirty !== undefined) tab.txnPossiblyDirty = false;
}
function rollbackTabTransaction(tab: QueryTab, options?: { resetAutoCommit?: boolean; resetAutoCommitDbType?: string }) {
invalidateManualTransactionTarget(tab);
if (tab.txnSessionId) void rollbackTransaction(tab.id);
if (options?.resetAutoCommit) {
// Callers switching a tab to another connection pass the target db type
// explicitly: the tab still carries the previous connectionId at reset
// time, and unbound file/saved-SQL tabs have none at all (which would
// force auto-commit even when the default mode is manual, #8863).
const dbType = options.resetAutoCommitDbType ?? useConnectionStore().getConfig(tab.connectionId)?.db_type;
tab.autoCommit = defaultAutoCommitForDbTypeWithSetting(dbType);
}
clearTxnPossiblyDirty(tab);
tab.txnAutoRolledBack = false;
clearAutoCommitOpenTransaction(tab);
}
async function commitTransaction(id: string) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab) return;
if (!tab.txnSessionId) {
// Auto-commit tab (`Tx:A`) that keeps explicit user transactions: the
// transaction lives on the tab's own connection, so COMMIT is an ordinary
// statement on that connection.
if (tab.autoCommitOpenTransaction) await executeCurrentSql("COMMIT", { tabId: tab.id });
return;
}
try {
await api.commitManualTransaction(tab.txnSessionId);
} finally {
clearManualTransactionSession(tab);
}
}
async function rollbackTransaction(id: string) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab) return;
if (!tab.txnSessionId) {
if (tab.autoCommitOpenTransaction) await executeCurrentSql("ROLLBACK", { tabId: tab.id });
return;
}
const sessionId = tab.txnSessionId;
// Remove the old session before the backend responds: a target switch may
// start a new transaction while this rollback is still in flight.
clearManualTransactionSession(tab);
await api.rollbackManualTransaction(sessionId);
}
function updateEditorViewport(id: string, viewport: { scrollTop: number; scrollLeft: number }) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab) return;
if (tab.editorViewport?.scrollTop === viewport.scrollTop && tab.editorViewport?.scrollLeft === viewport.scrollLeft) return;
tab.editorViewport = viewport;
queueSavedSqlEditorPositionPersist(tab);
}
function updateTabUiState(id: string, patch: Partial<NonNullable<QueryTab["uiState"]>>) {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab) return;
tab.uiState = { ...tab.uiState, ...patch };
}
function updateTabPageUiState(id: string, mode: string, patch: Record<string, unknown>, owner?: QueryTab) {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab || tab.mode !== mode || (owner && owner !== tab)) return;
const nextPageState = sanitizeTabPageUiState({ ...(tab.uiState?.page?.[mode] ?? {}), ...patch });
if (!nextPageState) return;
const page = sanitizeTabPageUiState({ ...(tab.uiState?.page ?? {}), [mode]: nextPageState });
if (!page) return;
tab.uiState = { ...tab.uiState, page: page as NonNullable<QueryTab["uiState"]>["page"] };
}
function updateTabPageResult(id: string, mode: string, result: QueryResult | undefined, owner?: QueryTab) {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab || tab.mode !== mode || (owner && owner !== tab)) return;
if (!result) {
clearResultPayload(tab);
return;
}
tab.results = undefined;
tab.activeResultIndex = undefined;
assignDisplayedResult(tab, result);
touchResult(tab);
scheduleResultCacheTrim();
}
function updateEditorSelection(id: string, selection: { anchor: number; head: number }) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab) return;
tab.editorSelection = selection;
queueSavedSqlEditorPositionPersist(tab);
}
function updateObjectBrowserViewport(id: string, viewport: ObjectBrowserViewport) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab || tab.mode !== "objects") return;
const previous = tab.objectBrowser?.viewport;
if (previous?.scrollTop === viewport.scrollTop && previous.viewMode === viewport.viewMode) return;
tab.objectBrowser = { ...tab.objectBrowser, viewport };
}
function updateObjectBrowserSearch(id: string, query: string) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab || tab.mode !== "objects") return;
if (tab.objectBrowser?.searchQuery === query) return;
tab.objectBrowser = { ...tab.objectBrowser, searchQuery: query };
}
function updateObjectBrowserFilter(id: string, filter: ObjectBrowserFilter) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab || tab.mode !== "objects" || tab.objectBrowser?.filter === filter) return;
tab.objectBrowser = { ...tab.objectBrowser, filter };
}
function updateNacosConfigEditorViewport(connectionId: string, namespace: string, viewport: NacosConfigEditorViewport) {
if (!Number.isFinite(viewport.scrollTop) || !Number.isFinite(viewport.scrollLeft)) return;
const tab = tabs.value.find((candidate) => candidate.mode === "nacos" && candidate.connectionId === connectionId && (candidate.nacosNamespace || "") === namespace);
if (!tab) return;
const next = {
...viewport,
scrollTop: Math.max(0, Math.round(viewport.scrollTop)),
scrollLeft: Math.max(0, Math.round(viewport.scrollLeft)),
};
const previous = tab.nacosConfigEditorViewport;
if (previous?.namespace === next.namespace && previous.dataId === next.dataId && previous.group === next.group && previous.scrollTop === next.scrollTop && previous.scrollLeft === next.scrollLeft) {
return;
}
tab.nacosConfigEditorViewport = next;
}
function renameTab(id: string, title: string) {
const trimmed = title.trim();
if (!trimmed) return false;
const tab = tabs.value.find((t) => t.id === id);
if (!tab || tab.mode !== "query") return false;
tab.title = trimmed;
tab.customTitle = true;
return true;
}
function linkSavedSql(id: string, savedSqlId: string, title?: string) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab) return;
tab.savedSqlId = savedSqlId;
tab.externalSqlPath = undefined;
tab.externalSqlFileVersion = undefined;
tab.externalSqlIgnoredFileVersion = undefined;
tab.externalSqlFileMissing = undefined;
if (title) {
tab.title = title;
tab.customTitle = true;
}
}
function linkExternalSqlPath(id: string, path: string, title?: string, version?: QueryTab["externalSqlFileVersion"]) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab) return;
tab.externalSqlPath = path;
tab.externalSqlFileVersion = version;
tab.externalSqlIgnoredFileVersion = undefined;
tab.externalSqlFileMissing = undefined;
tab.savedSqlId = undefined;
if (title) {
tab.title = title;
tab.customTitle = true;
}
markTabClean(tab);
refreshExternalSqlFileTitles();
}
function currentSavedSqlExecutionTarget(): SavedSqlExecutionTarget | undefined {
const activeTab = tabs.value.find((tab) => tab.id === activeTabId.value);
const target = savedSqlExecutionTargetFromTab(activeTab);
if (!target || !useConnectionStore().getConfig(target.connectionId)) return undefined;
return target;
}
function applySavedSqlExecutionTarget(tab: QueryTab, target: SavedSqlExecutionTarget) {
const options = { persistSavedSqlTarget: false };
updateConnection(tab.id, target.connectionId, target.database, options);
if (tab.catalog !== target.catalog || tab.database !== target.database) {
if (tab.catalog !== undefined || target.catalog !== undefined) updateCatalog(tab.id, target.catalog, target.database, options);
else updateDatabase(tab.id, target.database, options);
}
updateSchema(tab.id, target.schema, options);
}
function openSavedSql(file: SavedSqlFile, options: OpenSavedSqlOptions = {}) {
const targetMode = options.targetMode ?? useSettingsStore().editorSettings.savedSqlOpenTargetMode;
const currentTarget = targetMode === "current" ? currentSavedSqlExecutionTarget() : undefined;
const target = resolveSavedSqlExecutionTarget(file, targetMode, currentTarget);
const existing = tabs.value.find((tab) => tab.savedSqlId === file.id);
if (existing) {
persistSavedSqlEditorPosition(existing);
if (!existing.sql && file.sql && existing.originalSql === undefined) {
existing.sql = file.sql;
existing.originalSql = file.sql;
const restored = restoreSavedSqlEditorPosition(file.id, file.sql);
existing.editorSelection = restored.selection;
existing.editorViewport = restored.viewport;
}
applySavedSqlExecutionTarget(existing, target);
switchTab(existing.id);
return existing.id;
}
const id = uuid();
const restoredPosition = restoreSavedSqlEditorPosition(file.id, file.sql);
const dbType = useConnectionStore().getConfig(target.connectionId)?.db_type;
const tab: QueryTab = {
id,
title: file.name,
customTitle: true,
connectionId: target.connectionId,
database: target.database,
schema: target.schema,
catalog: target.catalog,
sql: file.sql,
savedSqlId: file.id,
originalSql: file.sql,
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "query",
autoCommit: defaultAutoCommitForDbTypeWithSetting(dbType),
editorSelection: restoredPosition.selection,
editorViewport: restoredPosition.viewport,
};
return registerOpenTab(tab);
}
async function hydrateSavedSqlTabs() {
await initSavedSqlEditorPositions();
const savedSqlStore = useSavedSqlStore();
const linkedTabs = tabs.value.filter((tab) => tab.savedSqlId && tab.sql === "" && tab.originalSql === undefined);
for (const tab of linkedTabs) {
const file = await savedSqlStore.ensureFileContent(tab.savedSqlId!);
if (!file) continue;
tab.title = tab.customTitle ? tab.title : file.name;
tab.sql = file.sql;
tab.originalSql = file.sql;
const restored = restoreSavedSqlEditorPosition(file.id, file.sql);
tab.editorSelection = restored.selection;
tab.editorViewport = restored.viewport;
}
}
function togglePinnedTab(id: string) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab) return;
tab.pinned = !tab.pinned;
tabs.value = orderPinnedFirst(tabs.value, (item) => !!item.pinned);
const owner = groupForTab(id);
if (owner) {
const pinned = owner.tabIds.filter((tabId) => tabs.value.find((item) => item.id === tabId)?.pinned);
const regular = owner.tabIds.filter((tabId) => !tabs.value.find((item) => item.id === tabId)?.pinned);
owner.tabIds = [...pinned, ...regular];
}
}
function reorderTab(id: string, targetId: string, position: "before" | "after") {
const fromIdx = tabs.value.findIndex((t) => t.id === id);
const toIdx = tabs.value.findIndex((t) => t.id === targetId);
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return false;
const reordered = [...tabs.value];
const [tab] = reordered.splice(fromIdx, 1);
const newToIdx = reordered.findIndex((t) => t.id === targetId);
reordered.splice(newToIdx + (position === "after" ? 1 : 0), 0, tab);
const nextTabs = orderPinnedFirst(reordered, (item) => !!item.pinned);
if (nextTabs.every((item, index) => item.id === tabs.value[index]?.id)) return false;
tabs.value = nextTabs;
const owner = groupForTab(id);
if (owner) {
const order = new Map(tabs.value.map((tab, index) => [tab.id, index]));
owner.tabIds = [...owner.tabIds].sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0));
}
return true;
}
const savedSqlTargetRequests = new WeakMap<QueryTab, number>();
let savedSqlTargetPersistenceActive = true;
onScopeDispose(() => {
savedSqlTargetPersistenceActive = false;
});
function createExecutionTargetGuard(id: string): () => boolean {
const tab = tabs.value.find((candidate) => candidate.id === id);
const target = savedSqlExecutionTargetFromTab(tab);
if (!tab || !target) return () => false;
const revision = savedSqlTargetRequests.get(tab);
const savedSqlId = tab.savedSqlId;
return () =>
savedSqlTargetPersistenceActive && tabs.value.includes(tab) && savedSqlTargetRequests.get(tab) === revision && tab.savedSqlId === savedSqlId && tab.connectionId === target.connectionId && tab.database === target.database && tab.catalog === target.catalog && tab.schema === target.schema;
}
function persistSavedSqlExecutionTarget(tab: QueryTab, options: UpdateExecutionTargetOptions) {
const revision = (savedSqlTargetRequests.get(tab) ?? 0) + 1;
savedSqlTargetRequests.set(tab, revision);
if (options.persistSavedSqlTarget === false || tab.mode !== "query" || !tab.savedSqlId) return;
const savedSqlStore = useSavedSqlStore();
const savedSqlId = tab.savedSqlId;
void savedSqlStore
.updateFileExecutionTarget(savedSqlId, {
connectionId: tab.connectionId,
database: tab.database,
catalog: tab.catalog,
schema: tab.schema,
})
.catch((error) => {
console.warn("[DBX][saved-sql:target:error]", error);
// A failed older request must not undo a newer target selection.
if (!savedSqlTargetPersistenceActive || savedSqlTargetRequests.get(tab) !== revision || tab.savedSqlId !== savedSqlId || !tabs.value.includes(tab)) return;
const saved = savedSqlStore.getFile(savedSqlId);
if (saved) applySavedSqlExecutionTarget(tab, saved);
useToast().toast(i18n.global.t("savedSql.saveFailed", { message: savedSqlErrorMessage(error, i18n.global.t) }), 5000);
});
}
function updateDatabase(id: string, database: string, options: UpdateExecutionTargetOptions = {}) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab || tab.database === database) return;
rollbackTabTransaction(tab);
void closeResultSession(tab);
void closeClientConnectionSession(tab);
tab.database = database;
tab.schema = undefined;
tab.objectBrowser = undefined;
clearResultPayload(tab);
tab.lastExecutedSql = undefined;
tab.resultBaseSql = undefined;
tab.resultSortedSql = undefined;
clearExplain(tab);
tab.tableMeta = undefined;
persistSavedSqlExecutionTarget(tab, options);
}
function updateCatalog(id: string, catalog: string | undefined, database: string, options: UpdateExecutionTargetOptions = {}) {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab || (tab.catalog === catalog && tab.database === database)) return;
rollbackTabTransaction(tab);
void closeResultSession(tab);
void closeClientConnectionSession(tab);
tab.catalog = catalog;
tab.database = database;
tab.schema = undefined;
tab.objectBrowser = undefined;
clearResultPayload(tab);
tab.lastExecutedSql = undefined;
tab.resultBaseSql = undefined;
tab.resultSortedSql = undefined;
clearExplain(tab);
tab.tableMeta = undefined;
persistSavedSqlExecutionTarget(tab, options);
}
function updateSchema(id: string, schema: string | undefined, options: UpdateExecutionTargetOptions = {}) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab || tab.schema === schema) return;
rollbackTabTransaction(tab);
const clearsQuerySchema = tab.mode === "query" && tab.schema && !schema && supportsClearableQuerySchema(useConnectionStore().getConfig(tab.connectionId)?.db_type);
if (clearsQuerySchema) {
queueTabSessionReset(tab);
clearResultPayload(tab);
tab.lastExecutedSql = undefined;
tab.resultBaseSql = undefined;
tab.resultSortedSql = undefined;
clearExplain(tab);
}
tab.schema = schema;
if (tab.mode === "objects") tab.objectBrowser = { ...tab.objectBrowser, schema, filter: undefined, viewport: undefined };
persistSavedSqlExecutionTarget(tab, options);
}
function updateConnection(id: string, connectionId: string, database = "", options: UpdateExecutionTargetOptions = {}) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab || tab.connectionId === connectionId) return;
rollbackTabTransaction(tab, { resetAutoCommit: true, resetAutoCommitDbType: useConnectionStore().getConfig(connectionId)?.db_type });
void closeResultSession(tab);
void closeClientConnectionSession(tab);
tab.connectionId = connectionId;
tab.catalog = undefined;
tab.database = database;
tab.catalog = undefined;
tab.objectBrowser = undefined;
tab.schema = undefined;
clearResultPayload(tab);
tab.lastExecutedSql = undefined;
tab.resultBaseSql = undefined;
tab.resultSortedSql = undefined;
clearExplain(tab);
tab.tableMeta = undefined;
persistSavedSqlExecutionTarget(tab, options);
}
function clearInvalidDataTabSortState(tab: QueryTab, columns: NonNullable<QueryTab["tableMeta"]>["columns"]): boolean {
if (tab.mode !== "data") return false;
const hasColumn = (name: string) => columns.some((column) => column.name === name);
const structuredSortMissing = !!tab.resultSortColumn && !hasColumn(tab.resultSortColumn);
const simpleOrderMissing = simpleDataGridOrderByReferencesMissingColumn(
tab.orderByInput,
columns.map((column) => column.name),
);
if (!structuredSortMissing && !simpleOrderMissing) return false;
if (structuredSortMissing) {
tab.resultSortColumn = undefined;
tab.resultSortColumnIndex = undefined;
tab.resultSortDirection = undefined;
tab.resultSortMode = undefined;
tab.resultSortedSql = undefined;
tab.resultLocalSortOriginalRows = undefined;
tab.resultLocalSortOriginalLargeValueCells = undefined;
tab.resultLocalSortOriginalMongoDocuments = undefined;
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
}
if (simpleOrderMissing) tab.orderByInput = undefined;
return true;
}
function clearInvalidDataTabSort(id: string): boolean {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab?.tableMeta?.columns.length) return false;
return clearInvalidDataTabSortState(tab, tab.tableMeta.columns);
}
function setTableMeta(id: string, meta: NonNullable<QueryTab["tableMeta"]>, options: { rowIdentityPending?: boolean } = {}) {
const tab = tabs.value.find((t) => t.id === id);
if (tab) {
if (options.rowIdentityPending) tab.tableMetaPending = true;
tab.tableMeta = meta;
// 记录写入时的连接元数据代次:disconnect/关库/死池重连会使该代次递增,
// 代次失配视同冷缓存,即使位于 30s TTL 窗口内也会重建结构(issue #6623 /
// PR #6640)。同一代次内的多次写入与连接Store保持一致,无需额外入参。
tab.tableMetaGeneration = useConnectionStore().metadataGenerationFor(tab.connectionId, tab.database) ?? 0;
tab.tableMetaUpdatedAt = Date.now();
if (meta.columns.length > 0) clearInvalidDataTabSortState(tab, meta.columns);
// 只有真实元数据(columns 非空)落地才结束行标识等待;多处调用方会先写
// columns/primaryKeys 为空的占位身份(如 useNavigationTargets),不得
// 借此提前解除编辑门控。失败/中止路径不清除——标签页保持只读是安全
// 兜底,刷新或重开表会重新加载元数据恢复
if (meta.columns.length > 0 && !options.rowIdentityPending) tab.tableMetaPending = false;
}
}
function setObjectSource(id: string, objectSource: NonNullable<QueryTab["objectSource"]>) {
const tab = tabs.value.find((t) => t.id === id);
if (tab) tab.objectSource = objectSource;
}
function setExecuting(id: string, isExecuting: boolean) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab) return;
tab.isExecuting = isExecuting;
tab.executingResultRunId = isExecuting ? null : undefined;
tab.queryExecutionStartedAt = isExecuting ? Date.now() : undefined;
if (!isExecuting) {
tab.isCancelling = false;
tab.executionId = undefined;
}
}
function setExecutingWithId(id: string, executionId: string) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab) return;
tab.isExecuting = true;
tab.executingResultRunId = null;
tab.executionId = executionId;
tab.isCancelling = false;
tab.queryExecutionStartedAt = Date.now();
}
function clearExplain(tab: QueryTab) {
tab.explainPlan = undefined;
tab.explainTableResult = undefined;
tab.explainError = undefined;
tab.explainTableError = undefined;
tab.explainSql = undefined;
tab.explainTableSql = undefined;
tab.lastExplainedSql = undefined;
tab.isExplaining = false;
tab.explainExecutionId = undefined;
tab.explainClientSessionId = undefined;
}
function toErrorResult(e: any): NonNullable<QueryTab["result"]> {
// Single funnel for every query execution failure, so backend messages DBX
// knows about are shown in the active locale rather than as raw English.
const error = normalizeBackendError(e) ?? undefined;
const message = translateBackendError(i18n.global.t, e, e instanceof Error ? e.message : undefined);
return markQueryResultRowsRaw({
columns: ["Error"],
execution_error: true,
error,
rows: [[message]],
affected_rows: 0,
execution_time_ms: 0,
});
}
function setErrorResult(id: string, e: any) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab) return;
tab.result = toErrorResult(e);
tab.results = undefined;
tab.activeResultIndex = undefined;
// An error result is a replacement, not the previous dataset.
publishResultGeneration(tab, "execute");
tab.resultSessionId = undefined;
tab.resultClientSessionId = undefined;
tab.isExecuting = false;
tab.isCancelling = false;
tab.queryExecutionStartedAt = undefined;
tab.executionId = undefined;
tab.executingResultRunId = undefined;
touchResult(tab);
}
function clearAcknowledgedCancelIfStillRunning(id: string, executionId: string) {
setTimeout(() => {
const current = tabs.value.find((t) => t.id === id);
if (!current || current.executionId !== executionId || !current.isCancelling) {
pendingResultRunRestores.delete(executionId);
return;
}
finishBatchSqlExecution(current, executionId, true);
current.isExecuting = false;
current.isCancelling = false;
current.executionId = undefined;
current.executingResultRunId = undefined;
current.queryExecutionStartedAt = undefined;
if (!restorePendingResultRun(current, executionId)) {
current.result = toErrorResult(new Error("Query canceled"));
current.results = undefined;
current.activeResultIndex = undefined;
current.resultSessionId = undefined;
current.resultClientSessionId = undefined;
publishResultGeneration(current, "execute");
touchResult(current);
}
clearLiveBatchSqlExecution(current, executionId);
}, CANCEL_ACK_SETTLE_TIMEOUT_MS);
}
async function executeCurrentTab() {
assertUpdateAllowsInteraction();
const tab = tabs.value.find((t) => t.id === activeTabId.value);
if (!tab || !tab.sql.trim()) return;
await executeCurrentSql(tab.sql);
}
async function executeCurrentSql(sql: string, options?: { tabId?: string; skipRedisSafetyCheck?: boolean; sourceOffset?: number; openInNewResultTab?: boolean; onExecutionStarted?: () => void }) {
// Execution targets the requesting tab; only fall back to the global
// active tab when the caller did not (or could not) capture one.
const executionTabId = options?.tabId ?? activeTabId.value;
if (!executionTabId) return;
const tab = tabs.value.find((item) => item.id === executionTabId);
if (tab && pendingResultRunPreparations.has(tab)) return false;
const previousGridKey = tab ? resultGridInstanceKey(tab) : undefined;
if (tab?.mode === "query") {
tab.resultSortColumn = undefined;
tab.resultSortColumnIndex = undefined;
tab.resultSortDirection = undefined;
tab.resultSortMode = undefined;
tab.resultSortedSql = undefined;
}
const producedResult = await executeTabSql(executionTabId, sql, { resultBaseSql: sql, resultSortedSql: undefined, ...options });
if (producedResult !== false) {
const current = tabs.value.find((item) => item.id === executionTabId);
if (current?.result) {
current.resultGridRevision = uuid();
if (current.activeResultRunId) syncActiveResultRunFromDisplayed(current);
await nextTick();
if (previousGridKey && options?.openInNewResultTab !== true) {
clearDataGridPendingSnapshot(previousGridKey);
clearDataGridViewSnapshot(previousGridKey);
}
}
}
return producedResult;
}
type LoadedEditableSource = {
source: EditableQuerySource;
analysis: EditableQueryInfo;
tableMeta: NonNullable<QueryTab["tableMeta"]>;
};
type EditableSourceMetadataTarget = {
source: EditableQuerySource;
analysis: EditableQueryInfo;
request: TableMetadataRequest;
writeSchema?: string;
};
/**
* Resolve result columns (by projection ordinal) back to exactly one base
* column per source, then surface the resolved column comments and a
* result->source mapping. Reuses the same database-aware binder
* as the editability analysis, so `name AS username` (uniquely resolvable
* unqualified alias) maps back to its physical column and quoted mixed-case
* identifiers keep exact casing. Ambiguous or unresolved columns yield
* `undefined` (no comment) instead of first-source-wins on a shared name.
*/
function resolveResultColumnInfo(dbType: string, analysis: EditableQueryInfo, resultColumns: string[], loadedSources: LoadedEditableSource[]): { comments: Array<string | undefined>; mapping: Array<QueryResultSourceColumnRef | undefined> } {
const refs = resolveSourceColumnsByOrdinal(
dbType,
analysis,
loadedSources.map((loaded) => ({ source: loaded.source, columns: loaded.tableMeta.columns })),
resultColumns.length,
);
const comments: Array<string | undefined> = [];
const mapping: Array<QueryResultSourceColumnRef | undefined> = [];
for (const ref of refs) {
if (!ref) {
comments.push(undefined);
mapping.push(undefined);
continue;
}
const loaded = loadedSources.find((entry) => entry.source.key === ref.sourceKey);
const comment = loaded?.tableMeta.columns.find((column) => column.name === ref.sourceColumn)?.comment?.trim();
comments.push(comment || undefined);
mapping.push({
...ref,
database: loaded?.tableMeta.database,
schema: loaded?.tableMeta.schema,
tableName: loaded?.tableMeta.tableName,
});
}
return { comments, mapping };
}
function mysqlColumnIsGenerated(column: { extra: string | null }): boolean {
const extra = column.extra?.trim().toLowerCase() ?? "";
return extra.includes("virtual generated") || extra.includes("stored generated");
}
function groupedByExactlyOneSourcePrimaryKey(loaded: LoadedEditableSource, groupByRefs: Array<QueryResultSourceColumnRef | undefined>): boolean {
const primaryKeys = loaded.tableMeta.primaryKeys;
if (!primaryKeys.length || groupByRefs.length !== primaryKeys.length) return false;
const groupedColumns = groupByRefs.flatMap((ref) => (ref?.sourceKey === loaded.source.key ? [ref.sourceColumn] : []));
return groupedColumns.length === primaryKeys.length && new Set(groupedColumns).size === primaryKeys.length && primaryKeys.every((primaryKey) => groupedColumns.includes(primaryKey));
}
function canInsertIntoEditableQuerySource(tab: QueryTab, databaseType: DatabaseType | undefined, loaded: LoadedEditableSource, sourceColumns: readonly (string | undefined)[] | undefined): boolean {
if (!canInsertTableRows(databaseType) || !sourceColumns?.length || !sourceColumns.every(Boolean)) return false;
const knownTableType = knownEditableQuerySourceTableType(tab, loaded);
return !knownTableType?.toUpperCase().includes("VIEW");
}
function knownEditableQuerySourceTableType(tab: QueryTab, loaded: LoadedEditableSource): string | undefined {
return (
loaded.tableMeta.tableType ??
useConnectionStore()
.lookupLocalCompletionTables(tab.connectionId!, loaded.tableMeta.database ?? tab.database, loaded.tableMeta.tableName, 20, loaded.tableMeta.schema, loaded.tableMeta.catalog)
.find((table) => table.name.toLowerCase() === loaded.tableMeta.tableName.toLowerCase())?.type
);
}
interface EditableQueryExecutionPreparation {
sql: string;
metadataSql: string;
hiddenPrimaryKeys: HiddenPrimaryKeyProjection[];
oracleLobPreview: boolean;
}
function oracleCompletionTableType(tab: QueryTab, metadataDbType: string, database: string, schema: string, tableName: string, catalog?: string): string | undefined {
if (metadataDbType !== "oracle" && metadataDbType !== "oceanbase-oracle") return undefined;
const resolvedSchema = schema.trim();
if (!resolvedSchema) return undefined;
const normalizeIdentifier = (value: string | undefined) => value?.trim().toLowerCase() ?? "";
const targetName = normalizeIdentifier(tableName);
const targetSchema = normalizeIdentifier(resolvedSchema);
const targetCatalog = catalog?.trim() ? normalizeIdentifier(catalog) : undefined;
const matches = useConnectionStore()
.lookupLocalCompletionTables(tab.connectionId!, database, tableName, 20, resolvedSchema, catalog)
.filter((table) => normalizeIdentifier(table.name) === targetName && normalizeIdentifier(table.schema) === targetSchema && (!targetCatalog || normalizeIdentifier(table.catalog) === targetCatalog));
if (matches.length !== 1) return undefined;
const match = matches[0]!;
return match.tableType?.trim() || match.type?.toUpperCase();
}
function canUseQueryKeylessRowPredicate(databaseType: DatabaseType, loaded: LoadedEditableSource): boolean {
if (!canUseKeylessRowPredicate(databaseType, loaded.tableMeta.primaryKeys)) return false;
// An unknown Oracle object may be a view whose query shape rejects ROWID
// and whose rows cannot be mapped safely for writes. Keep the result
// read-only until the object tree or tab metadata confirms its type.
return databaseType !== "oracle" || !!loaded.tableMeta.tableType?.trim();
}
function applyQueryMetadataPatch(tab: QueryTab, patch: QueryMetadataPatch, databaseType?: DatabaseType, database?: string) {
tab.queryAnalysis = patch.queryAnalysis;
tab.querySourceColumns = patch.querySourceColumns;
tab.queryWriteTargets = patch.queryWriteTargets;
tab.queryEditabilityReason = patch.queryEditabilityReason;
tab.mongoEditTarget = undefined;
tab.tableMeta = patch.tableMeta;
tab.resultColumnComments = patch.resultColumnComments;
tab.queryDisplaySourceColumns = patch.queryDisplaySourceColumns;
if (patch.tableMeta?.tableName && tab.result?.sourceStatement && tab.result.sourceLabel) {
const nextLabel = canonicalizeQueryResultSourceLabel(tab.result.sourceLabel, tab.result.sourceStatement, patch.tableMeta, {
database: database ?? tab.database,
databaseType,
});
if (nextLabel) {
tab.result.sourceLabel = nextLabel;
const matching = tab.results?.find((result) => result === tab.result);
if (matching) matching.sourceLabel = nextLabel;
}
}
}
function resolveEditableSourceMetadataTarget(tab: QueryTab, analysis: EditableQueryInfo, source: EditableQuerySource, conn: ConnectionConfig | undefined, dbType: string, executionDatabase: string): EditableSourceMetadataTarget {
// Oracle-family metadata rules (schema-less resolution + uppercase folding)
// must follow the connection's effective database type. Callers built from
// the raw connection db_type pass "jdbc" for a JDBC Oracle connection,
// which would keep the service-name schema fallback and the query's
// lowercase table spelling, so the write targets a non-existent table.
const metadataDbType = effectiveDatabaseTypeForConnection(conn) || dbType;
// Metadata must resolve in the same namespace as the query execution. An
// empty query-tab database still executes in the connection's default DB,
// while database-tree dialects and SQL Server 3-part names may override it
// with a qualified source.
const qualifiedSourceDatabase = dbType === "sqlserver" ? source.catalog : connectionUsesDatabaseObjectTreeMode(conn) ? source.schema : undefined;
const metadataDatabase = qualifiedSourceDatabase || executionDatabase || conn?.database || tab.database;
// SQL Server does not apply the query tab's selected schema to an
// unqualified object reference. Resolve metadata through the login's
// default schema (with the driver's dbo fallback) so metadata and writes
// target the same object as the original SELECT.
const schema = source.schema || (dbType === "sqlserver" ? "" : tab.schema) || "";
// Oracle-family connection databases are service names, not schemas. When
// the query does not qualify a schema, let the driver resolve the current
// login user's schema instead of looking up metadata under the service name.
// Unqualified agent-backed PostgreSQL-family queries run in the
// connection's current search_path. Do not reinterpret the selected
// database as a schema; the agent reports the visible relation's actual
// namespace with the columns.
const resolveAgentSearchPathSchema = (dbType === "vastbase" || dbType === "kingbase") && !source.schema && !tab.schema;
// PostgreSQL-compatible unqualified names also resolve through the
// connection's search_path. Keep the metadata request unqualified when no
// schema was selected instead of assuming public (or the database name).
const useCurrentPostgresSchema = (dbType === "postgres" || dbType === "kwdb") && !source.schema && !tab.schema;
const resolvedSchema = (dbType === "sqlserver" && !source.schema) || (ORACLE_LIKE_METADATA_TYPES.has(metadataDbType) && !schema) || resolveAgentSearchPathSchema || useCurrentPostgresSchema ? "" : metadataSchemaForConnection(conn, metadataDatabase, schema || undefined);
const metadataSchema = normalizeUppercaseFoldedMetadataIdentifier(metadataDbType, resolvedSchema || undefined, source.schema ? source.schemaQuoted : false) || "";
const metadataTableName = normalizeUppercaseFoldedMetadataIdentifier(metadataDbType, source.tableName, source.tableNameQuoted)!;
// An unqualified source resolves in the tab's current external catalog on
// Doris-family federation: the statement executed there through the
// catalog execution context, so the column lookup must carry the same
// catalog or it searches the engine's internal catalog and finds no such
// database/table. Qualified sources keep the catalog parsed from the SQL.
const unqualifiedExecutionCatalog = !source.catalog && connectionIsDorisFamilyCatalogCapable(conn) ? tab.catalog : undefined;
const metadataCatalog = normalizeUppercaseFoldedMetadataIdentifier(metadataDbType, source.catalog ?? unqualifiedExecutionCatalog, source.catalogQuoted);
const metadataSource: EditableQuerySource = {
...source,
catalog: metadataCatalog,
schema: metadataSchema || undefined,
tableName: metadataTableName,
};
// Keep SQL Server writes unqualified unless the SELECT source explicitly
// named a schema, so SELECT and UPDATE resolve the same object.
const writeSchema = dbType === "sqlserver" && !source.schema ? undefined : metadataSchema || undefined;
const localTableType = oracleCompletionTableType(tab, metadataDbType, metadataDatabase, metadataSchema || conn?.default_schema || "", metadataTableName, metadataCatalog);
const knownTableType = localTableType ?? (tab.tableMeta?.tableName.toLowerCase() === metadataTableName.toLowerCase() && normalizeOptionalSchema(tab.tableMeta.schema) === normalizeOptionalSchema(metadataSchema) ? tab.tableMeta.tableType : undefined);
return {
source: metadataSource,
analysis: normalizeUppercaseFoldedQueryAnalysis(metadataDbType, cloneAnalysisForSource(analysis, metadataSource), metadataSchema || undefined, metadataTableName),
writeSchema,
request: {
connectionId: tab.connectionId!,
database: metadataDatabase,
schema: metadataSchema,
tableName: metadataTableName,
tableType: knownTableType,
databaseType: dbType,
driverProfile: conn?.driver_profile || conn?.db_type,
catalog: metadataCatalog,
},
};
}
function loadedEditableSourceFromMetadata(target: EditableSourceMetadataTarget, metadata: Awaited<ReturnType<typeof loadTableMetadata>>["metadata"]): LoadedEditableSource {
const usesReportedSchema = target.request.databaseType === "vastbase" || target.request.databaseType === "kingbase";
const writeSchema = usesReportedSchema && !target.writeSchema ? metadata.schema : target.writeSchema;
return {
source: target.source,
analysis: target.analysis,
tableMeta: {
catalog: target.request.catalog,
database: target.request.database,
schema: writeSchema,
tableName: target.request.tableName,
tableType: metadata.tableType,
columns: metadata.columns,
primaryKeys: metadata.primaryKeys,
},
};
}
function loadedEditableSourceFromColumns(target: EditableSourceMetadataTarget, loadedColumns: Awaited<ReturnType<typeof loadTableColumns>>): LoadedEditableSource {
return {
source: target.source,
analysis: target.analysis,
tableMeta: {
catalog: target.request.catalog,
database: target.request.database,
schema: target.writeSchema,
tableName: target.request.tableName,
tableType: loadedColumns.tableType,
columns: loadedColumns.columns,
// MySQL getColumns already marks declared primary-key columns. Keep the
// columns-only path free of index discovery while allowing grouped
// results to prove that one physical row is uniquely identifiable.
primaryKeys: target.request.databaseType === "mysql" && target.request.driverProfile === "mysql" ? loadedColumns.columns.filter((column) => column.is_primary_key).map((column) => column.name) : [],
},
};
}
async function loadEditableQuerySource(tab: QueryTab, analysis: EditableQueryInfo, source: EditableQuerySource, conn: ConnectionConfig | undefined, dbType: string, executionDatabase: string, traceId?: string, elapsed?: () => string): Promise<LoadedEditableSource> {
const target = resolveEditableSourceMetadataTarget(tab, analysis, source, conn, dbType, executionDatabase);
queryExecutionLog("info", "metadata:table:start", {
traceId,
schema: target.request.schema,
table: target.request.tableName,
alias: source.alias,
elapsed: elapsed?.(),
});
const loadedMetadata = await loadTableMetadata({
...target.request,
traceLogger: (event) => queryExecutionLog("debug", "metadata:table-trace", { sourceTraceId: traceId, ...event }),
});
const columns = loadedMetadata.metadata.columns;
const primaryKeys = loadedMetadata.metadata.primaryKeys;
queryExecutionLog("info", "metadata:table:done", {
traceId,
columnCount: columns.length,
primaryKeyCount: primaryKeys.length,
cacheStatus: loadedMetadata.cacheStatus,
ageMs: Math.round(loadedMetadata.ageMs),
elapsed: elapsed?.(),
});
return loadedEditableSourceFromMetadata(target, loadedMetadata.metadata);
}
function missingPrimaryKeysForSource(databaseType: DatabaseType, primaryKeys: string[], analysis: EditableQueryInfo, sourceKey: string): string[] {
if (analysis.selectStar) return [];
const selectedColumns = new Set(
analysis.columns.flatMap((column) => {
if (!column.sourceName || column.sourceKey !== sourceKey) return [];
if (databaseType === "oracle" && !column.sourceNameQuoted && column.sourceName.toUpperCase() === "ROWID") return [DBX_ROWID_COLUMN, column.sourceName];
return [column.sourceName];
}),
);
return primaryKeys.filter((primaryKey) => !selectedColumns.has(primaryKey));
}
function oracleRowIdIsSafeForQuery(tab: QueryTab, loaded: LoadedEditableSource): boolean {
const knownType = loaded.tableMeta.tableType?.trim().toUpperCase();
if (knownType) return knownType === "TABLE";
const connectionStore = useConnectionStore();
const normalizeIdentifier = (value: string | undefined) => value?.trim().toLowerCase() ?? "";
const targetName = normalizeIdentifier(loaded.tableMeta.tableName);
const resolvedSchema = loaded.tableMeta.schema?.trim() || tab.schema?.trim() || connectionStore.getConfig(tab.connectionId!)?.default_schema?.trim();
if (!resolvedSchema) return false;
const targetSchema = normalizeIdentifier(resolvedSchema);
const targetCatalog = loaded.tableMeta.catalog?.trim() ? normalizeIdentifier(loaded.tableMeta.catalog) : undefined;
const matches = connectionStore
.lookupLocalCompletionTables(tab.connectionId!, loaded.tableMeta.database ?? tab.database, loaded.tableMeta.tableName, 20, resolvedSchema, loaded.tableMeta.catalog)
.filter((table) => normalizeIdentifier(table.name) === targetName && normalizeIdentifier(table.schema) === targetSchema && (!targetCatalog || normalizeIdentifier(table.catalog) === targetCatalog));
return matches.length === 1 && matches[0]?.type === "table";
}
async function resolveOracleRowIdSafety(tab: QueryTab, loaded: LoadedEditableSource, databaseType: DatabaseType): Promise<boolean> {
if (oracleRowIdIsSafeForQuery(tab, loaded)) return true;
if (loaded.tableMeta.tableType?.trim()) return false;
// Never enumerate an Oracle schema on the query execution path: large
// schemas can make this optional editability check take minutes (#8462).
if (databaseType === "oracle") return false;
const connection = useConnectionStore().getConfig(tab.connectionId!);
const schema = loaded.tableMeta.schema?.trim() || tab.schema?.trim() || connection?.default_schema?.trim() || "";
const tables = await api.listTables(tab.connectionId!, loaded.tableMeta.database ?? tab.database, schema, loaded.tableMeta.tableName);
const exactMatches = tables.filter((table) => table.name === loaded.tableMeta.tableName);
if (exactMatches.length !== 1) return false;
loaded.tableMeta.tableType = exactMatches[0]!.table_type;
updateCachedTableMetadataType(
{
connectionId: tab.connectionId!,
database: loaded.tableMeta.database ?? tab.database,
schema: loaded.tableMeta.schema,
tableName: loaded.tableMeta.tableName,
databaseType,
driverProfile: connection?.driver_profile || connection?.db_type,
catalog: loaded.tableMeta.catalog,
},
loaded.tableMeta.tableType,
);
return oracleRowIdIsSafeForQuery(tab, loaded);
}
function primaryKeyIndex(indexes: IndexInfo[]): IndexInfo | undefined {
return indexes.find((index) => !index.filter && index.columns.length > 0 && index.is_primary);
}
function buildHiddenPrimaryKeyPreparation(tab: QueryTab, sql: string, databaseType: DatabaseType, loaded: LoadedEditableSource, primaryKeys: string[], declaredPrimaryKeys: string[], traceId: string, elapsed: () => string): EditableQueryExecutionPreparation {
const metadataAnalysis = expandStarProjectionColumnsForSource(bindColumnsForSource(databaseType, loaded.analysis, loaded.source, loaded.tableMeta.columns), loaded.source, loaded.tableMeta.columns);
const oracleLobPreview = databaseType === "oracle" && primaryKeys.length > 0 && oracleRowIdIsSafeForQuery(tab, loaded) && oracleColumnsAllowDeferredLobMarkers(loaded.tableMeta.columns) && oracleQueryProjectsDeferredLob(metadataAnalysis, loaded.source.key, loaded.tableMeta.columns);
const unchanged = { sql, metadataSql: sql, hiddenPrimaryKeys: [], oracleLobPreview };
const missingPrimaryKeys =
declaredPrimaryKeys.length === 0
? primaryKeys.filter((primaryKey) => !(databaseType === "oracle" && primaryKey === DBX_ROWID_COLUMN && metadataAnalysis.columns.some((column) => column.sourceKey === loaded.source.key && !column.sourceNameQuoted && column.sourceName?.toUpperCase() === "ROWID")))
: missingPrimaryKeysForSource(databaseType, primaryKeys, metadataAnalysis, loaded.source.key);
if (missingPrimaryKeys.length === 0) return unchanged;
const primaryKeySet = new Set(primaryKeys);
const hasWritableProjection = metadataAnalysis.selectStar ? loaded.tableMeta.columns.some((column) => !primaryKeySet.has(column.name)) : metadataAnalysis.columns.some((column) => column.sourceName && column.sourceKey === loaded.source.key && !primaryKeySet.has(column.sourceName));
if (!hasWritableProjection) return unchanged;
const rewritten = buildQueryWithHiddenPrimaryKeys({
sql,
databaseType,
primaryKeys: missingPrimaryKeys,
existingResultNames: metadataAnalysis.selectStar ? loaded.tableMeta.columns.map((column) => column.name) : metadataAnalysis.columns.map((column) => column.resultName),
sourceExpressions: missingPrimaryKeys.includes(DBX_ROWID_COLUMN) && (databaseType === "oracle" || databaseType === "xugu") ? { [DBX_ROWID_COLUMN]: databaseType === "oracle" ? "ROWIDTOCHAR(ROWID)" : "ROWID" } : undefined,
});
if (!rewritten) return unchanged;
queryExecutionLog("info", "hidden-primary-keys", {
traceId,
table: loaded.tableMeta.tableName,
keyCount: rewritten.projections.length,
elapsed: elapsed(),
});
return { sql: rewritten.sql, metadataSql: rewritten.sql, hiddenPrimaryKeys: rewritten.projections, oracleLobPreview };
}
async function prepareEditableQueryExecution(tab: QueryTab, sql: string, conn: ConnectionConfig | undefined, databaseType: DatabaseType | undefined, executionDatabase: string, traceId: string, elapsed: () => string): Promise<EditableQueryExecutionPreparation> {
const unchanged = { sql, metadataSql: sql, hiddenPrimaryKeys: [], oracleLobPreview: false };
if (!databaseType || !HIDDEN_QUERY_KEY_DATABASE_TYPES.has(databaseType) || !tab.connectionId) return unchanged;
try {
const editability = analyzeEditableQueryEditability(sql);
if (!editability.editable || !editability.analysis) return unchanged;
const analysis = editability.analysis;
const sources = editableQuerySources(analysis);
if (sources.length !== 1 || analysis.distinct) return unchanged;
const source = sources[0]!;
const wholeSourceProjected = projectsAllColumnsForSource(analysis, source.key);
const hasDirectSourceProjection = analysis.columns.some((column) => Boolean(column.sourceName) && (!column.sourceKey || column.sourceKey === source.key));
if (!wholeSourceProjected && !hasDirectSourceProjection) return unchanged;
// Whole-source projections already include declared primary keys. Only
// Oracle and Xugu need preflight metadata here to add their synthetic
// row key for a keyless base table.
if (databaseType !== "oracle" && databaseType !== "xugu" && wholeSourceProjected) return unchanged;
const target = resolveEditableSourceMetadataTarget(tab, analysis, source, conn, databaseType, executionDatabase);
const cached = getCachedTableMetadata(target.request);
let loaded = cached ? loadedEditableSourceFromMetadata(target, cached.metadata) : undefined;
if (!cached && (databaseType === "oracle" || databaseType === "xugu")) {
// Oracle column discovery can be slow. A star projection over a table
// with a declared primary key already returns the complete row identity,
// so SQL can start while the full metadata needed for editing loads.
const fullMetadataPromise = loadTableMetadata({
...target.request,
traceLogger: (event) => queryExecutionLog("debug", "metadata:table-trace", { sourceTraceId: traceId, ...event }),
});
void fullMetadataPromise.catch((error) => queryExecutionLog("warn", "metadata:table-prefetch:failed", { traceId, error, elapsed: elapsed() }));
const wholeSourceAutoCommit = projectsAllColumnsForSource(target.analysis, target.source.key) && tab.autoCommit !== false;
if (wholeSourceAutoCommit) {
const indexes = await waitForOracleQueryMetadataPreflight(loadTableIndexes(target.request));
if (indexes === ORACLE_QUERY_METADATA_PREFLIGHT_TIMEOUT) {
queryExecutionLog("info", "metadata:preflight:timeout", {
traceId,
table: target.request.tableName,
budgetMs: ORACLE_QUERY_METADATA_PREFLIGHT_BUDGET_MS,
elapsed: elapsed(),
});
return unchanged;
}
if (primaryKeyIndex(indexes)) return unchanged;
}
loaded = loadedEditableSourceFromMetadata(target, (await fullMetadataPromise).metadata);
}
loaded ??= await loadEditableQuerySource(tab, analysis, source, conn, databaseType, executionDatabase, traceId, elapsed);
if (loaded.tableMeta.columns.length === 0) return unchanged;
if (loaded.tableMeta.tableType?.toUpperCase().includes("VIEW")) return unchanged;
const columnPrimaryKeys = loaded.tableMeta.columns.filter((column) => column.is_primary_key).map((column) => column.name);
const primaryKeys = databaseType === "oracle" ? loaded.tableMeta.primaryKeys : editablePrimaryKeys(databaseType, loaded.tableMeta.columns, loaded.tableMeta.tableType);
const syntheticRowId = (databaseType === "oracle" || databaseType === "xugu") && usesSyntheticRowIdKey(databaseType, primaryKeys, loaded.tableMeta.tableType);
// Base tables without a natural identifier use the same ROWID identity
// as table-data tabs (Oracle and Xugu). Confirm the object is a base
// table because selecting ROWID from a view can fail with ORA-01445.
if (syntheticRowId && !(await resolveOracleRowIdSafety(tab, loaded, databaseType))) return unchanged;
const declaredPrimaryKeys = databaseType === "oracle" && !syntheticRowId ? primaryKeys : columnPrimaryKeys;
return buildHiddenPrimaryKeyPreparation(tab, sql, databaseType, loaded, primaryKeys, declaredPrimaryKeys, traceId, elapsed);
} catch (error) {
// Metadata enrichment is optional. Query execution must retain its prior
// behavior when metadata is unavailable or the SQL cannot be rewritten.
queryExecutionLog("warn", "hidden-primary-keys:skip", { traceId, error, elapsed: elapsed() });
return unchanged;
}
}
/**
* Resolve read-only query result columns by projection ordinal. All databases
* use the mapping for comments. MySQL aggregation may additionally edit
* direct columns from one uniquely identifiable base table.
*/
async function resolveQueryDisplayMetadata(
tab: QueryTab,
sql: string,
executionDatabase: string,
traceId: string | undefined,
connection: ConnectionConfig | undefined,
allowMysqlEditing = true,
readOnlyReason: "aggregation" | "complex-source" = "aggregation",
): Promise<QueryMetadataPatch | undefined> {
if (tab.mode !== "query" || !tab.connectionId || !tab.result || !tab.result.columns.length) return undefined;
const conn = connection ?? useConnectionStore().getConfig(tab.connectionId);
const dbType = conn?.db_type || "";
const analysis = analyzeSelectStructureForDisplay(sql);
if (!analysis) return undefined;
const sources = editableQuerySources(analysis);
if (!sources.length) return undefined;
try {
// Resolve every source's metadata target up front, then load *columns
// only* (display payload — no index discovery) under a connection-scoped
// bounded-concurrency limiter. The shared table-column cache/in-flight
// coordinator deduplicates identical tables across concurrent callers,
// so this path issues no indexes requests and never loads full editable
// metadata.
const loadedSources: LoadedEditableSource[] = [];
const targets = sources.map((source) => resolveEditableSourceMetadataTarget(tab, analysis, source, conn, dbType, executionDatabase));
const limiterScope = `${GROUPED_DISPLAY_LIMITER_SCOPE_PREFIX}${tab.connectionId}`;
await Promise.all(
targets.map((target) =>
groupedDisplayMetadataLimiter.run(limiterScope, "query-column-comments", async () => {
const loadedColumns = await loadTableColumns({
...target.request,
traceLogger: (event) => queryExecutionLog("debug", "metadata:table-columns-trace", { sourceTraceId: traceId, ...event }),
});
loadedSources.push(loadedEditableSourceFromColumns(target, loadedColumns));
}),
),
);
const displayInfo = resolveResultColumnInfo(dbType, analysis, tab.result.columns, loadedSources);
const readOnlyPatch: QueryMetadataPatch = {
queryAnalysis: undefined,
querySourceColumns: undefined,
queryEditabilityReason: readOnlyReason,
tableMeta: undefined,
resultColumnComments: displayInfo.comments,
queryDisplaySourceColumns: displayInfo.mapping,
};
if (!allowMysqlEditing || dbType !== "mysql" || (conn?.driver_profile || conn?.db_type) !== "mysql") return readOnlyPatch;
// Mutation safety boundary: the FROM root must remain on the preserved
// side of the join tree, and GROUP BY must resolve to exactly that table's
// declared primary key. This makes every editable result row identify one
// physical root row even when joined rows are collapsed by aggregation.
if (analysis.distinct || analysis.hasHavingClause || analysis.hasWindowClause || analysis.hasRightJoinClause || !analysis.groupByColumns?.length) return readOnlyPatch;
const groupByRefs = resolveSourceColumnsByOrdinal(
dbType,
{ ...analysis, selectStar: false, columns: analysis.groupByColumns },
loadedSources.map((loaded) => ({ source: loaded.source, columns: loaded.tableMeta.columns })),
analysis.groupByColumns.length,
);
const candidates = loadedSources
.map((loaded) => {
const sourceColumns = displayInfo.mapping.map((ref) => {
if (ref?.sourceKey !== loaded.source.key) return undefined;
const column = loaded.tableMeta.columns.find((candidate) => candidate.name === ref.sourceColumn);
return column && !mysqlColumnIsGenerated(column) ? ref.sourceColumn : undefined;
});
const primaryKeySet = new Set(loaded.tableMeta.primaryKeys);
const hasCompletePrimaryKey = loaded.tableMeta.primaryKeys.length > 0 && loaded.tableMeta.primaryKeys.every((primaryKey) => sourceColumns.includes(primaryKey));
const editableSourceColumnCount = sourceColumns.filter((column) => column && !primaryKeySet.has(column)).length;
const hasExactPrimaryKeyGrouping = groupedByExactlyOneSourcePrimaryKey(loaded, groupByRefs);
return { ...loaded, sourceColumns, isRootSource: loaded.source.key === sources[0]!.key, hasCompletePrimaryKey, hasExactPrimaryKeyGrouping, editableSourceColumnCount };
})
.filter((loaded) => loaded.isRootSource && loaded.hasCompletePrimaryKey && loaded.hasExactPrimaryKeyGrouping && loaded.editableSourceColumnCount > 0 && !knownEditableQuerySourceTableType(tab, loaded)?.toUpperCase().includes("VIEW"));
// More than one writable source is ambiguous. Refuse the entire result
// instead of guessing which table an edit should mutate.
if (candidates.length !== 1) return readOnlyPatch;
const target = candidates[0]!;
return {
queryAnalysis: {
...target.analysis,
editableSourceKey: target.source.key,
allowInsert: false,
allowInsertDelete: false,
multiSource: sources.length > 1,
},
querySourceColumns: target.sourceColumns,
queryEditabilityReason: undefined,
tableMeta: target.tableMeta,
resultColumnComments: displayInfo.comments,
queryDisplaySourceColumns: displayInfo.mapping,
};
} catch (err) {
// Display enrichment stays silent for the user, but a swallowed lookup
// failure must stay diagnosable: record it with the trace id and the
// execution database/catalog so namespace mismatches can be traced.
queryExecutionLog("warn", "metadata:display-columns:failed", { traceId, database: executionDatabase, catalog: tab.catalog, error: err });
return undefined;
}
}
async function buildQueryMetadataPatch(tab: QueryTab, sql: string, executionDatabase: string, traceId?: string, elapsed?: () => string, hiddenPrimaryKeys: HiddenPrimaryKeyProjection[] = [], connection?: ConnectionConfig): Promise<QueryMetadataPatch | undefined> {
if (tab.mode !== "query") return;
if (!tab.result || !tab.result.columns.length) {
return {
queryAnalysis: undefined,
querySourceColumns: undefined,
queryEditabilityReason: undefined,
tableMeta: undefined,
};
}
queryExecutionLog("info", "metadata:editability:start", { traceId, elapsed: elapsed?.() });
const editability = await api.analyzeEditableQueryEditability(sql);
queryExecutionLog("info", "metadata:editability:done", {
traceId,
editable: editability.editable,
reason: editability.editable ? undefined : editability.reason,
elapsed: elapsed?.(),
});
if (!editability.editable) {
let displayPatch: QueryMetadataPatch | undefined;
if (editability.reason === "aggregation") {
displayPatch = await resolveQueryDisplayMetadata(tab, sql, executionDatabase, traceId, connection);
} else if (editability.reason === "complex-source") {
displayPatch = await resolveQueryDisplayMetadata(tab, sql, executionDatabase, traceId, connection, false, "complex-source");
}
if (displayPatch) return displayPatch;
return {
queryAnalysis: undefined,
querySourceColumns: undefined,
queryEditabilityReason: editability.reason,
tableMeta: undefined,
};
}
const analysis = editability.analysis;
if (!tab.connectionId) {
return {
queryAnalysis: undefined,
querySourceColumns: undefined,
queryEditabilityReason: "metadata-unavailable",
tableMeta: undefined,
};
}
const conn = connection ?? useConnectionStore().getConfig(tab.connectionId);
const dbType = conn?.db_type || "";
const sources = editableQuerySources(analysis);
const loadedSources: LoadedEditableSource[] = [];
try {
for (const source of sources) {
loadedSources.push(await loadEditableQuerySource(tab, analysis, source, conn, dbType, executionDatabase, traceId, elapsed));
}
const allSourceColumns = loadedSources.map((source) => ({ source: source.source, columns: source.tableMeta.columns }));
// Match DBeaver's safety model: a joined result is writable only when one
// source table has a complete row identifier and at least one writable column.
// A keyless source has no row identifier: allPrimaryKeysPresent is vacuously
// true for an empty key set, so joined results must exclude such sources.
const candidates = loadedSources
.map((loaded) => {
const metadataAnalysis = expandStarProjectionColumnsForSource(bindColumnsForSource(dbType, loaded.analysis, loaded.source, loaded.tableMeta.columns, allSourceColumns), loaded.source, loaded.tableMeta.columns);
const primaryKeys = loaded.tableMeta.primaryKeys;
const sourceColumns = sourceColumnsForResult(metadataAnalysis, tab.result!.columns, loaded.source.key, dbType as DatabaseType, primaryKeys);
const primaryKeysPresent = primaryKeysPresentForSource(dbType, primaryKeys, tab.result!.columns, metadataAnalysis, loaded.source.key, loaded.tableMeta.columns);
const keylessAllowed = sources.length === 1 && canUseQueryKeylessRowPredicate(dbType as DatabaseType, loaded);
const primaryKeySet = new Set(primaryKeys);
const editableSourceColumnCount = (sourceColumns ?? []).filter((column) => column && !primaryKeySet.has(column)).length;
return {
...loaded,
analysis: metadataAnalysis,
sourceColumns,
primaryKeysPresent,
keylessAllowed,
editableSourceColumnCount,
};
})
.filter((loaded) => ((loaded.primaryKeysPresent && loaded.tableMeta.primaryKeys.length > 0) || loaded.keylessAllowed) && !!loaded.sourceColumns && loaded.editableSourceColumnCount > 0);
if (loadedSources.length === 1) {
const loaded = loadedSources[0]!;
const metadataAnalysis = expandStarProjectionColumnsForSource(bindColumnsForSource(dbType, loaded.analysis, loaded.source, loaded.tableMeta.columns, allSourceColumns), loaded.source, loaded.tableMeta.columns);
const syntheticRowIdProjection = hiddenPrimaryKeys.find((projection) => projection.sourceName.toUpperCase() === DBX_ROWID_COLUMN);
const primaryKeys = loaded.tableMeta.primaryKeys.length === 0 && syntheticRowIdProjection ? [DBX_ROWID_COLUMN] : loaded.tableMeta.primaryKeys;
const displaySourceInfo = resolveResultColumnInfo(dbType, analysis, tab.result.columns, loadedSources);
const sourceColumns = sourceColumnsForResult(metadataAnalysis, tab.result.columns, loaded.source.key, dbType as DatabaseType, primaryKeys);
if (sourceColumns && syntheticRowIdProjection) {
const resultIndex = tab.result.columns.findIndex((column) => column.toLowerCase() === syntheticRowIdProjection.alias.toLowerCase());
if (resultIndex >= 0) sourceColumns[resultIndex] = DBX_ROWID_COLUMN;
}
if (primaryKeys.length === 0 && !canUseQueryKeylessRowPredicate(dbType as DatabaseType, loaded)) {
return {
queryAnalysis: undefined,
querySourceColumns: undefined,
queryEditabilityReason: "no-primary-key",
tableMeta: loaded.tableMeta,
resultColumnComments: displaySourceInfo.comments,
queryDisplaySourceColumns: displaySourceInfo.mapping,
};
}
const primaryKeysPresent = syntheticRowIdProjection ? sourceColumns?.some((column) => column?.toUpperCase() === DBX_ROWID_COLUMN) === true : primaryKeysPresentForSource(dbType, primaryKeys, tab.result.columns, metadataAnalysis, loaded.source.key, loaded.tableMeta.columns);
if (!primaryKeysPresent) {
return {
queryAnalysis: undefined,
querySourceColumns: undefined,
queryEditabilityReason: "primary-key-not-returned",
tableMeta: loaded.tableMeta,
queryDisplaySourceColumns: displaySourceInfo.mapping,
};
}
if (!allEditableColumnsWriteable(metadataAnalysis, tab.result.columns, undefined, dbType)) {
return {
queryAnalysis: undefined,
querySourceColumns: undefined,
queryEditabilityReason: "aliased-columns",
tableMeta: loaded.tableMeta,
queryDisplaySourceColumns: displaySourceInfo.mapping,
};
}
return {
queryAnalysis: metadataAnalysis.distinct && canInsertIntoEditableQuerySource(tab, dbType as DatabaseType, loaded, sourceColumns) ? { ...metadataAnalysis, allowInsert: true } : metadataAnalysis,
querySourceColumns: sourceColumns,
queryEditabilityReason: undefined,
tableMeta: primaryKeys === loaded.tableMeta.primaryKeys ? loaded.tableMeta : { ...loaded.tableMeta, primaryKeys },
resultColumnComments: primaryKeys.length === 0 ? displaySourceInfo.comments : undefined,
queryDisplaySourceColumns: displaySourceInfo.mapping,
};
}
// Multi-source results cannot carry a single tableMeta, but every source
// table's metadata is already loaded. Surface per-ordinal column comments
// and a display-only result->source mapping so the data grid can still
// show comments for joined results (fixes #2129 / #6352).
const multiSourceInfo = loadedSources.length > 1 ? resolveResultColumnInfo(dbType, analysis, tab.result.columns, loadedSources) : undefined;
if (candidates.length === 0) {
return {
queryAnalysis: undefined,
querySourceColumns: undefined,
queryEditabilityReason: loadedSources.some((loaded) => loaded.tableMeta.primaryKeys.length > 0) ? "primary-key-not-returned" : "no-primary-key",
tableMeta: undefined,
resultColumnComments: multiSourceInfo?.comments,
queryDisplaySourceColumns: multiSourceInfo?.mapping,
};
}
if (candidates.length > 1) {
const target = candidates[0]!;
return {
queryAnalysis: { ...target.analysis, multiSource: true, allowInsert: false, allowDelete: false, allowInsertDelete: false },
querySourceColumns: tab.result.columns.map((_, index) => {
const owners = candidates.filter((candidate) => candidate.sourceColumns?.[index] !== undefined);
return owners.length === 1 ? owners[0]!.sourceColumns![index] : undefined;
}),
queryWriteTargets: candidates.map((candidate) => ({ tableMeta: candidate.tableMeta, sourceColumns: candidate.sourceColumns! })),
queryEditabilityReason: undefined,
tableMeta: target.tableMeta,
resultColumnComments: multiSourceInfo?.comments,
queryDisplaySourceColumns: multiSourceInfo?.mapping,
};
}
const target = candidates[0]!;
const queryAnalysis = {
...target.analysis,
...(target.analysis.distinct && canInsertIntoEditableQuerySource(tab, dbType as DatabaseType, target, target.sourceColumns) ? { allowInsert: true } : {}),
// A DISTINCT result is still one-to-one with the selected source row
// when that source's complete primary key is present in the projection.
// The candidate filter above guarantees that identity before enabling
// the primary-key based delete SQL.
allowDelete: true,
allowInsertDelete: false,
multiSource: true,
};
return {
queryAnalysis,
querySourceColumns: target.sourceColumns,
queryEditabilityReason: undefined,
tableMeta: target.tableMeta,
resultColumnComments: multiSourceInfo?.comments,
queryDisplaySourceColumns: multiSourceInfo?.mapping,
};
} catch (err) {
// Metadata stays optional for the result grid, but the failure was
// previously only a bare console error with no trace context. Record the
// execution database/catalog so a lost catalog shows up in the debug log.
queryExecutionLog("warn", "metadata:columns:failed", { traceId, database: executionDatabase, catalog: tab.catalog, error: err });
return {
queryAnalysis: undefined,
querySourceColumns: undefined,
queryEditabilityReason: "metadata-unavailable",
tableMeta: undefined,
};
}
}
function analyzeQueryMetadataInBackground(tabId: string, sql: string, result: QueryResult, executionDatabase: string, traceId: string, elapsed: () => string, databaseType: DatabaseType | undefined, hiddenPrimaryKeys: HiddenPrimaryKeyProjection[] = [], connection?: ConnectionConfig) {
void (async () => {
const tab = tabs.value.find((t) => t.id === tabId);
if (!tab || tab.result !== result) return;
queryExecutionLog("info", "metadata:start", { traceId, elapsed: elapsed() });
// Metadata requests outlive the displayed result when another query starts
// or a retained run is selected. Analyze this result, not the live tab.
const patch = await buildQueryMetadataPatch({ ...tab, result }, sql, executionDatabase, traceId, elapsed, hiddenPrimaryKeys, connection);
if (patch?.queryAnalysis && hasHiddenPhysicalRowKey(databaseType, hiddenPrimaryKeys)) {
patch.queryAnalysis = { ...patch.queryAnalysis, allowInsert: false };
}
const current = tabs.value.find((t) => t.id === tabId);
if (patch && current?.result === result) {
applyQueryMetadataPatch(current, patch, databaseType, executionDatabase);
syncActiveResultRunFromDisplayed(current);
queryExecutionLog("info", "metadata:done", { traceId, elapsed: elapsed() });
} else {
queryExecutionLog("warn", "metadata:stale", { traceId, elapsed: elapsed() });
}
})();
}
function setQueryTotalRowCountIfCurrent(tabId: string, executionId: string, result: QueryResult, totalRowCount: number | undefined) {
const current = tabs.value.find((t) => t.id === tabId);
if (!current || (current.mode !== "query" && current.mode !== "data")) return;
if (current.executionId !== executionId && current.result !== result) return;
current.resultTotalRowCount = totalRowCount;
current.resultTotalRowCountLoading = false;
syncActiveResultRunFromDisplayed(current);
}
type TotalRowCountSqlTarget = { sql: string; schema?: string };
function countQueryTotalRowsInBackground(options: {
tabId: string;
connectionId: string;
database: string;
schema?: string;
catalog?: string;
countSql?: string;
countSqlTarget?: () => Promise<TotalRowCountSqlTarget | undefined>;
result: QueryResult;
pageLimit?: number;
pageOffset?: number;
useAgentResultSession?: boolean;
executionId: string;
traceId: string;
elapsed: () => string;
timeoutSecs: number;
txnSessionId?: string;
}) {
const resultRowCount = options.result.rows.length;
if (resultRowCount <= 0) {
setQueryTotalRowCountIfCurrent(options.tabId, options.executionId, options.result, undefined);
return;
}
const exactIncompletePageTotal = exactTotalFromIncompletePage(options.result, options.pageLimit, options.pageOffset, options.useAgentResultSession);
if (typeof exactIncompletePageTotal === "number") {
setQueryTotalRowCountIfCurrent(options.tabId, options.executionId, options.result, exactIncompletePageTotal);
return;
}
// A full page was returned, so more rows may exist and determining the true
// total requires a potentially expensive COUNT(*) over the user's query.
// Only run it automatically when the user opted in; otherwise leave the
// total unknown and let them trigger it on demand from the result grid
// (matches DBeaver's default of not counting large result sets).
if (!useSettingsStore().editorSettings.autoCalculateTotalRows) {
setQueryTotalRowCountIfCurrent(options.tabId, options.executionId, options.result, undefined);
return;
}
const countExecutionId = `${options.executionId}:count`;
void (async () => {
let isolatedClientSessionId: string | undefined;
try {
const countTarget = options.countSql ? { sql: options.countSql, schema: options.schema } : await options.countSqlTarget?.();
if (!countTarget?.sql) {
setQueryTotalRowCountIfCurrent(options.tabId, options.executionId, options.result, undefined);
return;
}
queryExecutionLog("info", "count:start", { traceId: options.traceId, elapsed: options.elapsed() });
let countResult: QueryResult | undefined;
if (options.txnSessionId) {
const txnResults = await api.executeInManualTransaction(options.txnSessionId, countTarget.sql, options.database, countTarget.schema, 1);
countResult = txnResults.find((result) => result.columns.length > 0) ?? txnResults[txnResults.length - 1];
} else {
const connection = useConnectionStore().getConfig(options.connectionId);
const databaseType = effectiveDatabaseTypeForConnection(connection);
const usesQuerySession = sqlServerCountUsesLocalTempTable(databaseType, countTarget.sql);
const clientSessionId = usesQuerySession ? tabClientSessionId({ id: options.tabId }) : tabClientSessionId({ id: options.tabId }, "count");
if (!usesQuerySession) isolatedClientSessionId = clientSessionId;
countResult = await api.executeQuery(options.connectionId, options.database, countTarget.sql, countTarget.schema, countExecutionId, {
clientSessionId,
catalog: options.catalog,
timeoutSecs: options.timeoutSecs,
});
}
const total = Number(countResult?.rows?.[0]?.[0] ?? 0);
if (!Number.isFinite(total) || total < 0) {
setQueryTotalRowCountIfCurrent(options.tabId, options.executionId, options.result, undefined);
return;
}
// COUNT describes all matching rows; the configured result limit only
// constrains how many of them pagination may load and retain.
setQueryTotalRowCountIfCurrent(options.tabId, options.executionId, options.result, total);
queryExecutionLog("info", "count:done", {
traceId: options.traceId,
total,
elapsed: options.elapsed(),
});
} catch (error) {
setQueryTotalRowCountIfCurrent(options.tabId, options.executionId, options.result, undefined);
queryExecutionLog("warn", "count:error", {
traceId: options.traceId,
elapsed: options.elapsed(),
error,
});
} finally {
if (isolatedClientSessionId) {
void closeClientSessionId(options.connectionId, options.database, isolatedClientSessionId, options.catalog, { tabId: options.tabId });
}
}
})();
}
async function countTabResultRows(id: string): Promise<number | undefined> {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab?.resultCountSql) return undefined;
const target = queryResultExecutionLocation(tab);
const connection = useConnectionStore().getConfig(target.connectionId);
const databaseType = effectiveDatabaseTypeForConnection(connection);
let countResult: QueryResult | undefined;
let isolatedClientSessionId: string | undefined;
try {
if (tab.autoCommit === false && tab.txnSessionId) {
const txnResults = await api.executeInManualTransaction(tab.txnSessionId, tab.resultCountSql, target.database, target.schema, 1);
countResult = txnResults.find((result) => result.columns.length > 0) ?? txnResults[txnResults.length - 1];
} else {
const usesQuerySession = sqlServerCountUsesLocalTempTable(databaseType, tab.resultCountSql);
const clientSessionId = usesQuerySession ? tabClientSessionId(tab) : tabClientSessionId(tab, "count");
if (!usesQuerySession) isolatedClientSessionId = clientSessionId;
countResult = await api.executeQuery(target.connectionId, target.database, tab.resultCountSql, target.schema, undefined, {
clientSessionId,
catalog: target.catalog,
maxRows: 1,
timeoutSecs: queryTimeoutSecsForConnection(connection, useSettingsStore().editorSettings.globalQueryTimeoutSecs),
});
}
const total = Number(countResult?.rows?.[0]?.[0]);
return Number.isFinite(total) && total >= 0 ? total : undefined;
} finally {
if (isolatedClientSessionId) {
void closeClientSessionId(target.connectionId, target.database, isolatedClientSessionId, target.catalog, { tabId: tab.id });
}
}
}
async function executeTabSql(
id: string,
sql: string,
options?: {
resultBaseSql?: string;
resultSortedSql?: string | undefined;
querySort?: {
resultColumns: string[];
columnIndex: number;
column: string;
direction: "asc" | "desc";
};
pagination?: { limit: number; offset: number; sessionId?: string; clientSessionId?: string };
appendResult?: { maxRows: number };
/** Logical-result publication origin for the view-snapshot cache. */
publicationOrigin?: ResultPublicationOrigin;
mongoSafety?: MongoAggregateSafetyOptions;
preserveResultDuringExecution?: boolean;
preserveTotalRowCountDuringExecution?: boolean;
preserveActiveResultIndex?: boolean;
replaceActiveResultInGroup?: boolean;
retainDisplayedResult?: boolean;
skipRedisSafetyCheck?: boolean;
sourceOffset?: number;
sourceTraceId?: string;
skipEnsureConnected?: boolean;
openInNewResultTab?: boolean;
targetContext?: SqlExecutionTargetContext;
executionTarget?: MultiDbExecutionTarget;
onExecutionStarted?: () => void;
batchResume?: BatchSqlResumeOptions;
},
) {
assertUpdateAllowsInteraction();
const tab = findExecutionTab(id);
if (!tab || !sql.trim()) return;
if (pendingResultRunPreparations.has(tab)) return false;
const openInNewResultTab = tab.mode === "query" && options?.openInNewResultTab === true;
// Auto-saved results need two independent decisions: keep the currently
// displayed run visible while the request is pending, then capture the new
// response as another run. Previously `resultAutoSave` only made the latter
// decision after clearing the displayed payload, which caused the result
// toolbar and grid to briefly disappear before the next Run was added.
const captureAutoSavedResultRun = tab.mode === "query" && tab.resultAutoSave === true && (!!tab.activeResultRunId || !!tab.result);
let captureResultRun = openInNewResultTab || captureAutoSavedResultRun;
let resultRunToRestore: string | undefined;
let reuseResultRun = false;
if (!captureResultRun && tab.mode === "query" && !tab.resultAutoSave && tab.activeResultRunId) {
const activeRun = tab.resultRuns?.find((run) => run.id === tab.activeResultRunId);
if (activeRun?.pinned) {
const reusableRun = tab.resultRuns?.find((run) => !run.pinned);
if (reusableRun) {
resultRunToRestore = reusableRun.id;
reuseResultRun = true;
} else {
captureResultRun = true;
}
}
}
if (captureResultRun && tab.activeResultRunId && !tab.result) resultRunToRestore = tab.activeResultRunId;
if (resultRunToRestore) {
// Reserve before disk I/O so another click cannot queue a second SQL.
const preparationId = uuid();
pendingResultRunPreparations.set(tab, preparationId);
tab.isExecuting = true;
tab.isCancelling = false;
tab.executionId = preparationId;
tab.executingResultRunId = null;
tab.queryExecutionStartedAt = Date.now();
const isCurrent = () => findExecutionTab(id) === tab && tab.executionId === preparationId && pendingResultRunPreparations.get(tab) === preparationId;
let prepared = false;
try {
const restored = await setActiveResultRun(id, resultRunToRestore, { isCurrent });
if (!isCurrent()) return false;
if (reuseResultRun) captureResultRun = !restored;
prepared = true;
} catch (error) {
if (!isCurrent()) return false;
throw error;
} finally {
if (pendingResultRunPreparations.get(tab) === preparationId) {
pendingResultRunPreparations.delete(tab);
if (!prepared && tab.executionId === preparationId) {
tab.isExecuting = false;
tab.isCancelling = false;
tab.executionId = undefined;
tab.executingResultRunId = undefined;
tab.queryExecutionStartedAt = undefined;
}
}
}
}
const executionId = uuid();
const executionEditorFingerprint = tab.mode === "query" ? sqlTextFingerprint(tab.sql) : undefined;
const traceId = executionId.slice(0, 8);
const startedAt = performance.now();
const executionTargetEpoch = manualTransactionTargetEpoch(tab);
const elapsed = () => `${Math.round(performance.now() - startedAt)}ms`;
const batchResume = options?.batchResume;
const continueOnBatchError = batchResume?.continueOnError ?? settingsStore.editorSettings.continueOnErrorOnBatch;
const preserveResultDuringExecution = batchResume !== undefined || options?.preserveResultDuringExecution === true || captureAutoSavedResultRun || (tab.mode === "query" && !!tab.activeResultRunId && !tab.resultAutoSave && !captureResultRun);
const updateActiveResultRun = !!tab.activeResultRunId && preserveResultDuringExecution;
tab.isExecuting = true;
tab.executingResultRunId = !captureResultRun && updateActiveResultRun ? tab.activeResultRunId : null;
options?.onExecutionStarted?.();
tab.isCancelling = false;
if (!tab.queryExecutionStartedAt) {
tab.queryExecutionStartedAt = Date.now();
}
tab.executionId = executionId;
const tableDataNativeSelectionBlockOwner = tab.mode === "data" ? {} : undefined;
if (tableDataNativeSelectionBlockOwner) beginDataGridNativeSelectionBlock(tableDataNativeSelectionBlockOwner);
const previousDisplayedSql = tab.resultBaseSql ?? tab.lastExecutedSql ?? tab.sql;
tab.lastExecutedSql = sql;
tab.resultLocalSortOriginalRows = undefined;
tab.resultLocalSortOriginalLargeValueCells = undefined;
tab.resultLocalSortOriginalMongoDocuments = undefined;
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
if (captureResultRun && tab.result && !tab.activeResultRunId) {
captureDisplayedResultRun(tab, previousDisplayedSql);
}
if (captureResultRun && tab.activeResultRunId) {
pendingResultRunRestores.set(executionId, tab.activeResultRunId);
}
if (batchResume) {
tab.batchSqlExecution = prepareBatchSqlRecovery(batchResume.batch, executionId, batchResume.startStatementIndex);
liveBatchSqlExecutions.set(tab, tab.batchSqlExecution);
} else {
tab.batchSqlExecution = undefined;
liveBatchSqlExecutions.delete(tab);
}
if (!updateActiveResultRun) {
tab.activeResultRunId = undefined;
}
if (!options?.preserveTotalRowCountDuringExecution) {
tab.resultTotalRowCount = undefined;
}
tab.resultTotalRowCountLoading = false;
const previousResultSessionClose = closeResultSession(tab, options?.pagination?.sessionId);
if (!preserveResultDuringExecution || !tab.result) {
clearResultPayload(tab, { preserveCacheSnapshot: captureResultRun && pendingResultRunRestores.has(executionId) });
}
queryExecutionLog("info", "start", {
traceId,
tabId: id,
mode: tab.mode,
sourceTraceId: options?.sourceTraceId,
sqlLength: sql.length,
});
const queryBaseSql = options?.resultBaseSql ?? sql;
let sqlToExecute = sql;
let resultSortedSql = options?.resultSortedSql;
let queryMetadataSql = queryBaseSql;
let hiddenPrimaryKeys: HiddenPrimaryKeyProjection[] = [];
let useOracleLobPreview = false;
let pageSql: string | undefined;
let requestedPageLimit: number | undefined;
let pageLimit: number | undefined;
let pageOffset: number | undefined;
let countSql: string | undefined;
let exactQueryRowBound: number | undefined;
let useAgentResultSession = false;
let paginationRowNumberColumn: string | undefined;
let executionDispatched = false;
let clientRequestStartedAt: number | undefined;
let producedResult = false;
const resumedExecutionTarget = batchResume?.batch.executionTarget;
const executionConnectionId = resumedExecutionTarget?.connectionId ?? options?.executionTarget?.connectionId ?? tab.connectionId;
// Captured for the catch below: a single-statement failure aborts the whole
// execute-multi command (the core only returns per-statement error results
// for batches), so the synthesized error result must be annotated here for
// the row/column locate flow. These locals live inside the try block.
let errorLocateContext: { databaseType: DatabaseType | undefined; parameterOptions: SqlParameterOptions | undefined; sourceOffset: number | undefined; executedSql: string | undefined } | undefined;
try {
await waitForTabSessionReset(id);
const connStore = useConnectionStore();
const executionTarget = resumedExecutionTarget ?? options?.executionTarget;
const usesExternalExecutionTarget = !!executionTarget;
let conn = connStore.getConfig(executionConnectionId);
const parsedMongoCommands = conn?.db_type === "mongodb" ? splitMongoCommandRanges(sql) : undefined;
let mongoCommands = parsedMongoCommands ?? [];
const mongoNeedsConnection = mongoCommands.some(({ command }) => command.kind !== "use");
if (options?.skipEnsureConnected) {
queryExecutionLog("info", "ensure-connected:skip", { traceId, elapsed: elapsed(), reason: "caller" });
} else if (conn?.db_type === "mongodb" && mongoCommands.length > 0 && !mongoNeedsConnection) {
queryExecutionLog("info", "ensure-connected:skip", { traceId, elapsed: elapsed(), reason: "mongo-use-only" });
} else {
queryExecutionLog("info", "ensure-connected:start", { traceId, elapsed: elapsed() });
await connStore.ensureConnected(executionConnectionId);
queryExecutionLog("info", "ensure-connected:done", { traceId, elapsed: elapsed() });
}
conn = connStore.getConfig(executionConnectionId);
if (parsedMongoCommands === undefined && conn?.db_type === "mongodb") {
mongoCommands = splitMongoCommandRanges(sql);
}
const effectiveDbType = effectiveDatabaseTypeForConnection(conn);
if (effectiveDbType === "mysql") {
sqlToExecute = stripMysqlClientDisplayCommand(sqlToExecute);
}
if (tab.autoCommit === false && !supportsTransaction(conn?.db_type)) {
tab.autoCommit = true;
}
const targetContext = options?.targetContext;
if (targetContext?.scope === "namespace") {
throw new Error("Namespace execution targets require a registered execution adapter.");
}
const databaseTargetContext = targetContext?.scope === "catalog" || targetContext?.scope === "database" ? targetContext : undefined;
const executionCatalog = resumedExecutionTarget ? resumedExecutionTarget.catalog : targetContext ? (targetContext.scope === "catalog" ? targetContext.catalog : undefined) : (executionTarget?.catalog ?? (tab.mode === "data" ? tab.tableMeta?.catalog : tab.catalog));
const contextDatabase = databaseTargetContext?.database;
const targetDatabase = resumedExecutionTarget ? resumedExecutionTarget.database : targetContext?.scope === "connection" ? "" : (contextDatabase ?? executionTarget?.database ?? tab.database);
if (effectiveDbType === "opengauss") {
await connStore.ensureDatabaseCompatibilityMode(executionConnectionId, targetDatabase || conn?.database);
}
const targetSchema = resumedExecutionTarget ? resumedExecutionTarget.schema : targetContext?.scope === "connection" ? undefined : (databaseTargetContext?.schema ?? executionTarget?.schema ?? tab.schema);
const executionDatabase = targetDatabase;
const sqlStatementParameterOptions = sqlStatementParameterOptionsForCompatibility(effectiveDbType, effectiveDbType === "opengauss" ? connStore.databaseCompatibilityMode(executionConnectionId, targetDatabase || conn?.database) : undefined);
const useAgentCursor = usesAgentCursorForQuery(conn?.db_type, conn?.driver_profile);
const queryTimeoutSecs = queryTimeoutSecsForConnection(conn, settingsStore.editorSettings.globalQueryTimeoutSecs);
if (!batchResume) {
const statementExecution =
tab.mode === "query"
? createBatchSqlExecution(
executionId,
tab.sql,
sql,
effectiveDbType,
options?.sourceOffset,
{
connectionId: executionConnectionId,
catalog: executionCatalog,
database: targetDatabase,
schema: targetSchema,
},
sqlStatementParameterOptions,
)
: undefined;
tab.batchSqlExecution = statementExecution && (tab.autoCommit !== false || statementExecution.total === 1) ? statementExecution : undefined;
if (tab.batchSqlExecution) liveBatchSqlExecutions.set(tab, tab.batchSqlExecution);
}
queryExecutionLog("info", "previous-session-close:start", { traceId, elapsed: elapsed() });
await previousResultSessionClose;
queryExecutionLog("info", "previous-session-close:done", { traceId, elapsed: elapsed() });
// Redis command execution — split multi-line input into individual commands
if (conn?.db_type === "redis") {
await connStore.ensureConnected(executionConnectionId);
let currentDb = Number(executionDatabase) || Number(tab.database) || 0;
// One command per line, skipping blank and comment lines — the same split that
// gives each result its source range, so the two can never drift apart.
const commandRanges = executableStatementRanges(sql, "redis");
const commands = commandRanges.map((range) => range.sql);
if (commands.length === 0) return false;
if (commands.length === 1 && isRedisMonitorCommand(commands[0])) {
const monitor = startRedisMonitor(
() => api.redisPubSubConnect(executionConnectionId, true),
(rows) => {
const current = findExecutionTab(id);
if (current?.executionId !== executionId) return;
current.result = markQueryResultRowsRaw(annotateQueryResultSource({ columns: ["MONITOR"], rows: rows.map((message) => [message]), affected_rows: 0, execution_time_ms: performance.now() - startedAt }, "MONITOR"));
current.results = undefined;
current.activeResultIndex = undefined;
current.queryEditabilityReason = undefined;
current.queryWriteTargets = undefined;
current.tableMeta = undefined;
current.resultBaseSql = "MONITOR";
current.redisMonitorActive = true;
current.queryAnalysis = undefined;
current.querySourceColumns = undefined;
current.resultColumnComments = undefined;
current.queryDisplaySourceColumns = undefined;
current.mongoEditTarget = undefined;
if (!producedResult) {
publishResultGeneration(current, "execute");
syncDisplayedResultRun(current, "MONITOR", captureResultRun);
}
producedResult = true;
touchResult(current);
},
);
redisMonitors.set(executionId, monitor.stop);
try {
await monitor.done;
} finally {
redisMonitors.delete(executionId);
const current = findExecutionTab(id);
if (current?.executionId === executionId) current.redisMonitorActive = false;
}
return producedResult;
}
queryExecutionLog("info", "redis:start", { traceId, db: currentDb, commandCount: commands.length, sqlLength: sql.length });
const allResults: QueryResult[] = [];
const skipSafety = options?.skipRedisSafetyCheck;
let hadMutatingCommand = false;
for (const [commandIndex, command] of commands.entries()) {
const commandTiming = createQueryRequestTiming(commandIndex === 0 ? startedAt : performance.now());
const commandRange = commandRanges[commandIndex];
const sourceRange = commandRange && options?.sourceOffset !== undefined ? { from: options.sourceOffset + commandRange.from, to: options.sourceOffset + commandRange.to } : undefined;
try {
const result = await commandTiming.run(api.redisExecuteCommand, executionConnectionId, currentDb, command, skipSafety);
allResults.push(commandTiming.finish(markQueryResultRowsRaw(annotateQueryResultSource(redisCommandResultToQueryResult(result.value, performance.now() - startedAt, command), command, undefined, undefined, sourceRange))));
// Track db switches from SELECT N so later commands in the same batch run on the right db.
currentDb = nextRedisCommandDb(currentDb, command, result.value);
// Write commands (SET/DEL/...) mutate the key set — drop the cached key-name completion
// for the db this command ran on so the next autocomplete fetch reflects the new keys.
if (isRedisMutatingCommand(command)) {
hadMutatingCommand = true;
connStore.invalidateCompletionCache(executionConnectionId, String(currentDb));
}
} catch (e: any) {
allResults.push(annotateQueryResultSource(toErrorResult(e), command, undefined, undefined, sourceRange));
}
}
queryExecutionLog("info", "redis:done", { traceId, commandCount: commands.length, elapsed: elapsed() });
const current = findExecutionTab(id);
if (current?.executionId === executionId) {
if (captureResultRun && current.isCancelling && restorePendingResultRun(current, executionId)) return false;
if (allResults.length > 1) {
const activeResultIndex = allResults.findIndex((result) => !isQueryExecutionErrorResult(result));
const resultIndex = preservedResultIndex(allResults, current.activeResultIndex, options?.preserveActiveResultIndex) ?? (activeResultIndex >= 0 ? activeResultIndex : 0);
current.results = allResults;
current.activeResultIndex = resultIndex;
current.result = allResults[resultIndex];
} else {
current.results = undefined;
current.activeResultIndex = undefined;
current.result = allResults[0];
}
// Redis command batches always replace the visible result.
publishResultGeneration(current, "execute");
producedResult = current.result !== undefined;
touchResult(current);
current.queryAnalysis = undefined;
current.querySourceColumns = undefined;
current.queryWriteTargets = undefined;
current.resultColumnComments = undefined;
current.queryDisplaySourceColumns = undefined;
current.queryEditabilityReason = undefined;
current.mongoEditTarget = undefined;
current.tableMeta = undefined;
current.resultBaseSql = options?.resultBaseSql ?? sql;
current.resultSortedSql = options?.resultSortedSql;
syncDisplayedResultRun(current, options?.resultBaseSql ?? sql, captureResultRun);
// Reflect db switches from SELECT N in the tab so the toolbar dropdown, tab title and
// sidebar stay in sync with the command's effective db.
if (!usesExternalExecutionTarget && current.database !== String(currentDb)) {
current.database = String(currentDb);
}
}
// Refresh the sidebar db key counts (INFO keyspace) when at least one command in
// this batch mutated the key set, so `dbN (count)` stays accurate without a manual
// refresh. Fire-and-forget: never block result display.
if (hadMutatingCommand) {
void connStore.refreshRedisDbKeyCounts(executionConnectionId);
}
return producedResult;
}
if (conn?.db_type === "mongodb" && mongoCommands.length === 0 && sql.trim()) {
// Avoid falling through to the SQL executor, which only returns the generic
// "Use MongoDB-specific commands" rejection and hides parse/syntax details.
throw new Error(describeMongoCommandParseFailure(sql));
}
if (mongoCommands.length > 0) {
queryExecutionLog("info", "mongo:start", { traceId, commandCount: mongoCommands.length, sqlLength: sql.length });
const allResults: QueryResult[] = [];
const mongoResultMaxRows = effectiveQueryResultMaxRows(settingsStore.editorSettings.queryResultMaxRowsEnabled, settingsStore.editorSettings.queryResultMaxRows);
// Track the effective db as we walk the batch so later commands observe
// earlier `use ...` statements in the same editor selection.
let currentDatabase = executionTarget?.database ?? tab.database;
let mongoEditTarget: QueryTab["mongoEditTarget"] | undefined;
let mongoFindPageState: { pageLimit: number; pageOffset: number; total: number; totalIsExact: boolean } | undefined;
for (const [commandIndex, parsedCommand] of mongoCommands.entries()) {
let mongoCommand = parsedCommand.command;
// db.getSiblingDB("x").<command>: target that database for this command only.
const sessionDatabase = currentDatabase;
const targetsSiblingDatabase = mongoCommand.kind === "inDatabase";
if (mongoCommand.kind === "inDatabase") {
currentDatabase = mongoCommand.database;
mongoCommand = mongoCommand.command;
}
const sourceStatement = parsedCommand.text;
const sourceRange = options?.sourceOffset === undefined ? undefined : { from: options.sourceOffset + parsedCommand.from, to: options.sourceOffset + parsedCommand.to };
const commandStartedAt = performance.now();
const commandTiming = createQueryRequestTiming(commandIndex === 0 ? startedAt : commandStartedAt);
const annotateMongoResult = (result: QueryResult): QueryResult => {
const annotated = annotateQueryResultSource(result, sourceStatement, undefined, undefined, sourceRange);
if ("collection" in mongoCommand) {
annotated.sourceQualifier = currentDatabase || undefined;
annotated.sourceName = mongoCommand.collection;
annotated.sourceLabel = currentDatabase ? `${currentDatabase}.${mongoCommand.collection}` : mongoCommand.collection;
}
return commandTiming.finish(annotated);
};
try {
// The frontend parser remains responsible for editor ranges, while
// dbx-core is authoritative for command semantics at execution time.
mongoCommand = await api.mongoParseShellCommand(sourceStatement);
// The authoritative parse keeps the `db.getSiblingDB("x").` wrapper;
// re-apply the same one-command database override before dispatch.
if (mongoCommand.kind === "inDatabase") {
currentDatabase = mongoCommand.database;
mongoCommand = mongoCommand.command;
}
switch (mongoCommand.kind) {
case "find": {
queryExecutionLog("info", "mongo-find:start", { traceId, collection: mongoCommand.collection, database: currentDatabase });
// Pagination planning parses `db.<collection>.find(...)` directly;
// strip a `db.getSiblingDB("x")` wrapper so the plan reflects the
// wrapped command's own skip/limit chain.
const siblingPrefix = splitSiblingDbPrefix(sourceStatement);
const paginationSource = siblingPrefix ? `db${siblingPrefix.rest}` : sourceStatement;
const pagePlan = planMongoFindPagination(paginationSource, mongoCommand, options?.pagination?.offset ?? 0, normalizeResultPageSize(options?.pagination?.limit ?? settingsStore.editorSettings.pageSize));
if (!pagePlan) throw new Error(describeMongoCommandParseFailure(sourceStatement));
// A stale request can point past an explicit .limit() bound. Keep
// the backend call bounded so limit(0) cannot become unbounded.
const result = await commandTiming.run(
api.mongoFindDocuments,
executionConnectionId,
currentDatabase,
mongoCommand.collection,
pagePlan.requestSkip,
Math.max(1, pagePlan.requestLimit),
mongoCommand.filter,
mongoCommand.projection,
mongoCommand.sort,
mongoCommand.collation,
executionId,
);
const documents = pagePlan.requestLimit === 0 ? [] : result.documents;
const extendedDocuments = pagePlan.requestLimit === 0 ? [] : result.extended_documents;
const totalIsExact = result.total_is_exact !== false;
const reportedTotal = mongoFindLogicalTotal(result.total, pagePlan);
const loadedLowerBound = pagePlan.pageOffset + documents.length;
const total = totalIsExact ? reportedTotal : Math.max(reportedTotal, loadedLowerBound);
const hasMore = totalIsExact ? loadedLowerBound < total : pagePlan.requestLimit > 0 && documents.length >= pagePlan.requestLimit && (pagePlan.logicalLimit === undefined || loadedLowerBound < pagePlan.logicalLimit);
const queryResult = markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(documents, performance.now() - commandStartedAt, total, extendedDocuments, totalIsExact)));
queryResult.truncated = hasMore;
queryResult.has_more = hasMore;
allResults.push(queryResult);
if (mongoCommands.length === 1) {
mongoFindPageState = { pageLimit: pagePlan.pageLimit, pageOffset: pagePlan.pageOffset, total, totalIsExact };
}
mongoEditTarget = mongoCommands.length === 1 && !mongoCommand.projection && queryResult.columns.includes("_id") ? { collection: mongoCommand.collection, idColumn: "_id" } : undefined;
queryExecutionLog("info", "mongo-find:done", {
traceId,
collection: mongoCommand.collection,
database: currentDatabase,
rowCount: result.documents.length,
total: result.total,
elapsed: elapsed(),
});
break;
}
case "findOne": {
queryExecutionLog("info", "mongo-find-one:start", { traceId, collection: mongoCommand.collection, database: currentDatabase });
const result = await commandTiming.run(api.mongoFindOne, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.projection, mongoCommand.options, executionId);
const queryResult = markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents, result.total_is_exact !== false)));
allResults.push(queryResult);
mongoEditTarget = mongoCommands.length === 1 && !mongoCommand.projection && queryResult.columns.includes("_id") ? { collection: mongoCommand.collection, idColumn: "_id" } : undefined;
queryExecutionLog("info", "mongo-find-one:done", {
traceId,
collection: mongoCommand.collection,
database: currentDatabase,
rowCount: result.documents.length,
elapsed: elapsed(),
});
break;
}
case "version": {
queryExecutionLog("info", "mongo-version:start", { traceId, database: currentDatabase });
const version = await commandTiming.run(api.mongoServerVersion, executionConnectionId, currentDatabase, executionId);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoVersionToQueryResult(version, performance.now() - commandStartedAt))));
mongoEditTarget = undefined;
queryExecutionLog("info", "mongo-version:done", {
traceId,
database: currentDatabase,
version,
elapsed: elapsed(),
});
break;
}
case "showDatabases": {
queryExecutionLog("info", "mongo-show-databases:start", { traceId });
const result = await commandTiming.run(api.mongoRunCommand, executionConnectionId, "admin", '{"listDatabases":1}', executionId);
const queryResult = mongoDatabasesToQueryResult(result.documents, performance.now() - commandStartedAt, agentProtocolQueryResultMaxRows(mongoResultMaxRows));
allResults.push(markQueryResultRowsRaw(annotateMongoResult(queryResult)));
mongoEditTarget = undefined;
queryExecutionLog("info", "mongo-show-databases:done", {
traceId,
databaseCount: queryResult.affected_rows,
elapsed: elapsed(),
});
break;
}
case "countDocuments": {
queryExecutionLog("info", "mongo-count:start", { traceId, collection: mongoCommand.collection, database: currentDatabase });
const total = await commandTiming.run(api.mongoCountDocuments, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.mode, executionId);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoCountToQueryResult(total, performance.now() - commandStartedAt))));
mongoEditTarget = undefined;
queryExecutionLog("info", "mongo-count:done", {
traceId,
collection: mongoCommand.collection,
database: currentDatabase,
total,
elapsed: elapsed(),
});
break;
}
case "findExplain": {
queryExecutionLog("info", "mongo-explain:start", { traceId, collection: mongoCommand.collection, database: currentDatabase });
const plan = await commandTiming.run(
api.mongoExplainFind,
executionConnectionId,
currentDatabase,
mongoCommand.collection,
{
skip: mongoCommand.skip,
limit: mongoCommand.limit,
filter: mongoCommand.filter,
projection: mongoCommand.projection,
sort: mongoCommand.sort,
collation: mongoCommand.collation,
verbosity: mongoCommand.verbosity,
},
executionId,
);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult([plan], performance.now() - commandStartedAt, 1))));
mongoEditTarget = undefined;
queryExecutionLog("info", "mongo-explain:done", { traceId, collection: mongoCommand.collection, database: currentDatabase, elapsed: elapsed() });
break;
}
case "aggregate": {
if (options?.mongoSafety) {
const safety = evaluateMongoAggregateSafety(mongoCommand, options.mongoSafety);
if (!safety.allowed) throw new Error(safety.reason);
}
queryExecutionLog("info", "mongo-aggregate:start", { traceId, collection: mongoCommand.collection, database: currentDatabase });
const aggregateMaxRows = normalizeResultPageSize(pageLimit ?? options?.pagination?.limit ?? settingsStore.editorSettings.pageSize);
const result = await commandTiming.run(api.mongoAggregateDocuments, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.pipeline, aggregateMaxRows, mongoCommand.options, executionId);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents, result.total_is_exact !== false))));
mongoEditTarget = undefined;
queryExecutionLog("info", "mongo-aggregate:done", {
traceId,
collection: mongoCommand.collection,
database: currentDatabase,
rowCount: result.documents.length,
total: result.total,
elapsed: elapsed(),
});
break;
}
case "distinct": {
queryExecutionLog("info", "mongo-distinct:start", { traceId, collection: mongoCommand.collection, database: currentDatabase, field: mongoCommand.field });
const result = await commandTiming.run(api.mongoDistinct, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.field, mongoCommand.filter, executionId);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDistinctToQueryResult(mongoCommand.field, result.documents, performance.now() - commandStartedAt))));
mongoEditTarget = undefined;
queryExecutionLog("info", "mongo-distinct:done", {
traceId,
collection: mongoCommand.collection,
database: currentDatabase,
field: mongoCommand.field,
valueCount: result.documents.length,
elapsed: elapsed(),
});
break;
}
case "getIndexes": {
queryExecutionLog("info", "mongo-indexes:start", { traceId, collection: mongoCommand.collection, database: currentDatabase });
const indexes = await commandTiming.run(api.listIndexes, executionConnectionId, currentDatabase, "", mongoCommand.collection);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoIndexesToQueryResult(indexes, performance.now() - commandStartedAt))));
mongoEditTarget = undefined;
queryExecutionLog("info", "mongo-indexes:done", {
traceId,
collection: mongoCommand.collection,
database: currentDatabase,
indexCount: indexes.length,
elapsed: elapsed(),
});
break;
}
case "collectionStats": {
queryExecutionLog("info", "mongo-collection-stats:start", {
traceId,
collection: mongoCommand.collection,
metric: mongoCommand.metric,
database: currentDatabase,
});
const stats = await commandTiming.run(api.mongoCollectionStats, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.scale, executionId);
// SAFETY: The backend returns collection statistics as a JSON object; the API type is broader than the converter's record-shaped input.
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoCollectionStatsToQueryResult(mongoCommand.metric, stats as unknown as Record<string, unknown>, performance.now() - commandStartedAt))));
mongoEditTarget = undefined;
queryExecutionLog("info", "mongo-collection-stats:done", {
traceId,
collection: mongoCommand.collection,
metric: mongoCommand.metric,
database: currentDatabase,
elapsed: elapsed(),
});
break;
}
case "findOneAndUpdate":
case "findOneAndReplace":
case "findOneAndDelete": {
if (options?.mongoSafety) {
const safety = evaluateMongoWriteSafety(mongoCommand, options.mongoSafety);
if (!safety.allowed) throw new Error(safety.reason);
}
queryExecutionLog("info", "mongo-find-and-modify:start", {
traceId,
kind: mongoCommand.kind,
collection: mongoCommand.collection,
database: currentDatabase,
});
const result =
mongoCommand.kind === "findOneAndUpdate"
? await commandTiming.run(api.mongoFindOneAndUpdate, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.update, mongoCommand.options)
: mongoCommand.kind === "findOneAndReplace"
? await commandTiming.run(api.mongoFindOneAndReplace, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.replacement, mongoCommand.options)
: await commandTiming.run(api.mongoFindOneAndDelete, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.options);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents, result.total_is_exact !== false))));
mongoEditTarget = undefined;
queryExecutionLog("info", "mongo-find-and-modify:done", {
traceId,
kind: mongoCommand.kind,
collection: mongoCommand.collection,
database: currentDatabase,
rowCount: result.documents.length,
elapsed: elapsed(),
});
break;
}
case "runCommand": {
if (options?.mongoSafety) {
const safety = evaluateMongoWriteSafety(mongoCommand, options.mongoSafety);
if (!safety.allowed) throw new Error(safety.reason);
}
queryExecutionLog("info", "mongo-run-command:start", {
traceId,
database: currentDatabase,
});
const result = await commandTiming.run(api.mongoRunCommand, executionConnectionId, currentDatabase, mongoCommand.commandJson, executionId);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents, result.total_is_exact !== false))));
mongoEditTarget = undefined;
queryExecutionLog("info", "mongo-run-command:done", {
traceId,
database: currentDatabase,
rowCount: result.documents.length,
elapsed: elapsed(),
});
break;
}
case "insert":
case "update":
case "replace":
case "bulkWrite":
case "delete":
case "createIndex":
case "createUser":
case "dropIndex":
case "dropIndexes":
case "renameCollection":
case "dropCollection": {
if (options?.mongoSafety) {
const safety = evaluateMongoWriteSafety(mongoCommand, options.mongoSafety);
if (!safety.allowed) throw new Error(safety.reason);
}
queryExecutionLog("info", "mongo-write:start", {
traceId,
database: currentDatabase,
kind: mongoCommand.kind,
...(mongoCommand.kind === "createUser" ? {} : { collection: mongoCommand.collection }),
});
mongoEditTarget = undefined;
if (mongoCommand.kind === "insert") {
const result = await commandTiming.run(api.mongoInsertDocuments, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.docsJson);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoWriteToQueryResult(result.affected_rows, performance.now() - commandStartedAt))));
} else if (mongoCommand.kind === "update") {
const result = await commandTiming.run(api.mongoUpdateDocuments, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.update, mongoCommand.many, mongoCommand.options);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoWriteToQueryResult(result.affected_rows, performance.now() - commandStartedAt))));
} else if (mongoCommand.kind === "bulkWrite") {
const result = await commandTiming.run(api.mongoBulkWrite, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.operations, mongoCommand.options);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoBulkWriteToQueryResult(result, performance.now() - commandStartedAt))));
} else if (mongoCommand.kind === "replace") {
const result = await commandTiming.run(api.mongoReplaceDocument, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.replacement, mongoCommand.options);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoWriteToQueryResult(result.affected_rows, performance.now() - commandStartedAt))));
} else if (mongoCommand.kind === "createIndex") {
const result = await commandTiming.run(api.mongoCreateIndex, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.keys, mongoCommand.options);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoCreateIndexToQueryResult(result.name, performance.now() - commandStartedAt))));
} else if (mongoCommand.kind === "createUser") {
const result = await commandTiming.run(api.mongoCreateUser, executionConnectionId, currentDatabase, mongoCommand.userJson, mongoCommand.writeConcernJson);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoWriteToQueryResult(result.affected_rows, performance.now() - commandStartedAt))));
} else if (mongoCommand.kind === "dropIndex" || mongoCommand.kind === "dropIndexes") {
try {
const result = await commandTiming.run(api.mongoDropIndexes, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.kind === "dropIndex" ? mongoCommand.index : mongoCommand.indexes, mongoCommand.kind === "dropIndex");
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDroppedIndexesToQueryResult(result.dropped_names, performance.now() - commandStartedAt, result.failures))));
} finally {
await refreshLoadedMongoIndexesAfterMutation(executionConnectionId, currentDatabase, mongoCommand.collection, traceId);
}
} else if (mongoCommand.kind === "renameCollection") {
await commandTiming.run(api.mongoRenameCollection, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.newName);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoScalarToQueryResult("renamed", `${mongoCommand.collection} -> ${mongoCommand.newName}`, performance.now() - commandStartedAt))));
} else if (mongoCommand.kind === "dropCollection") {
await commandTiming.run(api.mongoDropCollection, executionConnectionId, currentDatabase, mongoCommand.collection);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoWriteToQueryResult(1, performance.now() - commandStartedAt))));
} else {
const result = await commandTiming.run(api.mongoDeleteDocuments, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.many);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoWriteToQueryResult(result.affected_rows, performance.now() - commandStartedAt))));
}
queryExecutionLog("info", "mongo-write:done", {
traceId,
database: currentDatabase,
kind: mongoCommand.kind,
...(mongoCommand.kind === "createUser" ? {} : { collection: mongoCommand.collection }),
elapsed: elapsed(),
});
break;
}
case "use": {
currentDatabase = mongoCommand.database;
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoUseToQueryResult(currentDatabase, performance.now() - commandStartedAt))));
mongoEditTarget = undefined;
queryExecutionLog("info", "mongo-use:done", {
traceId,
database: currentDatabase,
elapsed: elapsed(),
});
break;
}
}
} catch (error: any) {
// Surface per-command failures inline and continue collecting results
// for the rest of the batch, matching the grouped-result UX.
allResults.push(annotateMongoResult(toErrorResult(error)));
mongoEditTarget = undefined;
} finally {
// A sibling-database command does not change the session database the way `use` does.
if (targetsSiblingDatabase) currentDatabase = sessionDatabase;
}
}
queryExecutionLog("info", "mongo:done", {
traceId,
database: currentDatabase,
commandCount: mongoCommands.length,
elapsed: elapsed(),
});
const current = findExecutionTab(id);
if (current?.executionId === executionId) {
reconcileBatchSqlResults(current, executionId, allResults);
if (captureResultRun && current.isCancelling && restorePendingResultRun(current, executionId)) return false;
const activeGroupIndex = current.activeResultIndex;
const activeGroupResults = current.results;
const findPageState = mongoFindPageState;
const shouldAppendResult = !!findPageState && !!options?.appendResult && !!current.result && allResults.length === 1;
const shouldReplaceActiveResultInGroup = options?.replaceActiveResultInGroup === true && allResults.length === 1 && Array.isArray(activeGroupResults) && typeof activeGroupIndex === "number" && activeGroupIndex >= 0 && activeGroupIndex < activeGroupResults.length;
if (shouldAppendResult) {
if (findPageState!.pageOffset !== current.result!.rows.length) {
throw new Error("Ignoring a stale MongoDB result segment whose offset no longer matches the loaded rows");
}
const appendedResult = appendQueryResultSegment(current.result!, allResults[0]!, options!.appendResult!.maxRows);
if (Array.isArray(activeGroupResults) && typeof activeGroupIndex === "number" && activeGroupIndex >= 0 && activeGroupIndex < activeGroupResults.length) {
current.results = activeGroupResults.slice();
current.results[activeGroupIndex] = appendedResult;
}
current.result = appendedResult;
} else if (shouldReplaceActiveResultInGroup) {
current.results = activeGroupResults.slice();
current.results[activeGroupIndex] = allResults[0];
current.result = allResults[0];
} else if (allResults.length > 1) {
// Open grouped output on the first non-error result when possible so
// mixed success/error batches land on the most useful table first.
const activeResultIndex = allResults.findIndex((result) => !isQueryExecutionErrorResult(result));
const resultIndex = preservedResultIndex(allResults, current.activeResultIndex, options?.preserveActiveResultIndex) ?? (activeResultIndex >= 0 ? activeResultIndex : 0);
current.results = allResults;
current.activeResultIndex = resultIndex;
current.result = allResults[resultIndex];
} else {
current.results = undefined;
current.activeResultIndex = undefined;
current.result = allResults[0];
}
publishResultGeneration(current, shouldAppendResult ? "append" : "execute");
producedResult = current.result !== undefined;
touchResult(current);
current.queryAnalysis = undefined;
current.querySourceColumns = undefined;
current.queryWriteTargets = undefined;
current.resultColumnComments = undefined;
current.queryDisplaySourceColumns = undefined;
current.queryEditabilityReason = undefined;
current.mongoEditTarget = mongoCommands.length === 1 ? mongoEditTarget : undefined;
current.tableMeta = undefined;
current.resultBaseSql = shouldReplaceActiveResultInGroup ? (current.resultBaseSql ?? options?.resultBaseSql ?? sql) : (options?.resultBaseSql ?? sql);
current.resultSortedSql = options?.resultSortedSql;
current.resultPageSql = undefined;
current.resultPageLimit = mongoFindPageState?.pageLimit;
current.resultPageOffset = shouldAppendResult ? (current.resultPageOffset ?? 0) : mongoFindPageState?.pageOffset;
current.resultCountSql = undefined;
current.resultSessionId = undefined;
current.resultClientSessionId = undefined;
current.resultTotalRowCount = mongoFindPageState?.totalIsExact ? mongoFindPageState.total : undefined;
current.resultTotalRowCountLoading = false;
syncDisplayedResultRun(current, current.resultBaseSql ?? options?.resultBaseSql ?? sql, captureResultRun);
if (!usesExternalExecutionTarget && current.database !== currentDatabase) current.database = currentDatabase;
}
return producedResult;
}
const elasticsearchRequests = elasticsearchRestRequestRanges(sqlToExecute, effectiveDbType);
if (elasticsearchRequests.length > 0) {
console.info("[DBX][executeTabSql:elasticsearch-rest-batch:start]", {
traceId,
requestCount: elasticsearchRequests.length,
sql,
});
const allResults: QueryResult[] = [];
const continueOnError = continueOnBatchError;
for (const [requestIndex, request] of elasticsearchRequests.entries()) {
const commandTiming = createQueryRequestTiming(requestIndex === 0 ? startedAt : performance.now());
const current = findExecutionTab(id);
if (current?.executionId !== executionId) break;
const sourceRange = options?.sourceOffset === undefined ? undefined : { from: options.sourceOffset + request.from, to: options.sourceOffset + request.to };
try {
const result = await commandTiming.run(api.executeQuery, executionConnectionId, executionDatabase, request.sql, undefined, executionId, {
timeoutSecs: queryTimeoutSecs,
});
allResults.push(commandTiming.finish(markQueryResultRowsRaw(annotateQueryResultSource(result, request.sql, targetDatabase || conn?.database, effectiveDbType, sourceRange))));
if (elasticsearchHttpErrorStatus(result) !== undefined && !continueOnError) break;
} catch (error) {
const latest = findExecutionTab(id);
if (latest?.executionId !== executionId) break;
allResults.push(annotateQueryResultSource(toErrorResult(error), request.sql, targetDatabase || conn?.database, effectiveDbType, sourceRange));
if (!continueOnError) break;
}
}
console.info("[DBX][executeTabSql:elasticsearch-rest-batch:done]", {
traceId,
requestCount: elasticsearchRequests.length,
resultCount: allResults.length,
elapsed: elapsed(),
});
const current = findExecutionTab(id);
if (current?.executionId === executionId && captureResultRun && current.isCancelling && restorePendingResultRun(current, executionId)) return false;
if (current?.executionId === executionId && allResults.length > 0) {
clearResultNavigationState(current);
const errorResultIndex = allResults.findIndex((result) => isQueryExecutionErrorResult(result) || elasticsearchHttpErrorStatus(result) !== undefined);
const resultIndex = errorResultIndex >= 0 ? errorResultIndex : 0;
current.results = allResults.length > 1 ? allResults : undefined;
current.activeResultIndex = allResults.length > 1 ? resultIndex : undefined;
current.result = allResults[resultIndex];
// Elasticsearch batch requests replace the visible result.
publishResultGeneration(current, "execute");
producedResult = current.result !== undefined;
touchResult(current);
current.queryAnalysis = undefined;
current.querySourceColumns = undefined;
current.queryWriteTargets = undefined;
current.resultColumnComments = undefined;
current.queryDisplaySourceColumns = undefined;
current.queryEditabilityReason = undefined;
current.mongoEditTarget = undefined;
current.tableMeta = undefined;
current.resultBaseSql = options?.resultBaseSql ?? sql;
current.resultSortedSql = undefined;
syncDisplayedResultRun(current, current.resultBaseSql, captureResultRun);
}
return producedResult;
}
const queryResultMaxRows = tab.mode === "query" ? effectiveQueryResultMaxRows(settingsStore.editorSettings.queryResultMaxRowsEnabled, settingsStore.editorSettings.queryResultMaxRows) : undefined;
if (tab.mode === "query") {
const prepared = await prepareEditableQueryExecution(tab, sqlToExecute, conn, effectiveDbType, executionDatabase, traceId, elapsed);
sqlToExecute = prepared.sql;
// Database sorting executes a generated wrapper around the user's query.
// Keep editability metadata anchored to the original query so the wrapper
// does not turn an otherwise editable result into a complex read-only one.
queryMetadataSql = options?.resultSortedSql && !options?.querySort ? queryBaseSql : prepared.metadataSql;
hiddenPrimaryKeys = prepared.hiddenPrimaryKeys;
useOracleLobPreview = prepared.oracleLobPreview;
if (options?.querySort) {
const sorted = await api.buildSortedQuerySql({
originalSql: sqlToExecute,
databaseType: effectiveDbType,
resultColumns: [...options.querySort.resultColumns, ...hiddenPrimaryKeys.map((projection) => projection.alias)],
columnIndex: options.querySort.columnIndex,
column: options.querySort.column,
direction: options.querySort.direction,
});
if (!sorted.ok || !sorted.sql) throw new Error("Unable to build sorted query SQL");
sqlToExecute = sorted.sql;
resultSortedSql = sorted.sql;
}
const requestedPagination = options?.pagination ?? { limit: settingsStore.editorSettings.pageSize, offset: 0 };
requestedPageLimit = requestedPagination.limit;
const pagination = limitQueryPagination(requestedPagination, queryResultMaxRows);
const sqlBeforePagination = sqlToExecute;
const sqlServerUseScript = effectiveDbType === "sqlserver" && sqlToExecute === queryBaseSql ? sqlServerLeadingUseScript(sqlToExecute) : undefined;
const plan = await api.prepareQueryPaginationExecutionPlan({
sql: sqlServerUseScript?.querySql ?? sqlToExecute,
queryBaseSql: sqlServerUseScript?.querySql ?? queryBaseSql,
databaseType: effectiveDbType,
pagination,
useAgentCursor,
firstPageUsesActualSql: hiddenPrimaryKeys.length > 0,
});
const canPaginateSqlServerUseScript = !!sqlServerUseScript && !!plan.pageSql && typeof plan.pageLimit === "number" && typeof plan.pageOffset === "number";
if (sqlServerUseScript && !canPaginateSqlServerUseScript) {
sqlToExecute = sqlBeforePagination;
} else {
sqlToExecute = sqlServerUseScript ? replaceSqlServerLeadingUseQuery(sqlBeforePagination, sqlServerUseScript, plan.sqlToExecute) : plan.sqlToExecute;
pageSql = sqlServerUseScript && plan.pageSql ? replaceSqlServerLeadingUseQuery(sqlBeforePagination, sqlServerUseScript, plan.pageSql) : plan.pageSql;
pageLimit = plan.pageLimit;
pageOffset = plan.pageOffset;
countSql = sqlServerUseScript && plan.countSql ? replaceSqlServerLeadingUseQuery(queryBaseSql, sqlServerUseScript, plan.countSql) : plan.countSql;
exactQueryRowBound = plan.exactQueryRowBound;
useAgentResultSession = plan.useAgentResultSession;
paginationRowNumberColumn = plan.paginationRowNumberColumn;
}
const hasBoundedPagination = typeof pageLimit === "number" && typeof pageOffset === "number";
if (options?.appendResult && !hasBoundedPagination && !useAgentResultSession) {
const current = findExecutionTab(id);
if (current?.executionId === executionId && current.result) {
current.result.has_more = false;
const activeResultIndex = current.activeResultIndex;
if (Array.isArray(current.results) && typeof activeResultIndex === "number" && activeResultIndex >= 0 && activeResultIndex < current.results.length) {
current.results[activeResultIndex]!.has_more = false;
}
touchResult(current);
syncDisplayedResultRun(current, queryBaseSql, captureResultRun);
}
queryExecutionLog("info", "append-result:pagination-unsupported", { traceId, elapsed: elapsed() });
return false;
}
} else if (tab.mode === "data") {
const requestedPagination = {
limit: options?.pagination?.limit ?? tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize),
offset: options?.pagination?.offset ?? 0,
};
requestedPageLimit = requestedPagination.limit;
const pagination = limitQueryPagination(requestedPagination, queryResultMaxRows);
pageLimit = pagination.limit;
pageOffset = pagination.offset;
useAgentResultSession = conn?.db_type === "sqlserver" && conn?.driver_profile?.trim().toLowerCase() === "sqlserver-legacy";
}
errorLocateContext = {
databaseType: effectiveDbType,
parameterOptions: sqlStatementParameterOptions,
sourceOffset: options?.sourceOffset,
executedSql: sqlToExecute,
};
const executionSchema = connectionQueryExecutionSchema(conn, targetDatabase, targetSchema, tab.mode === "data");
// Jumping to a non-zero offset without a live cursor session: the plan
// could not rewrite the SQL for these engines, so a plain execution
// would return the first page again (#8993).
const isOffsetJumpPage = typeof pageOffset === "number" && pageOffset > 0 && !options?.pagination?.sessionId;
const frontendTimeoutSecs = frontendQueryTimeoutSecsForSql(sqlToExecute, effectiveDbType, queryTimeoutSecs, sqlStatementParameterOptions);
const sourceLabelDatabase = targetDatabase || conn?.database;
const executionClientSessionId = options?.pagination?.clientSessionId ?? (tab.mode === "query" || tab.mode === "data" ? tabClientSessionId(tab) : undefined);
const currentBeforeDispatch = findExecutionTab(id);
if (currentBeforeDispatch?.executionId !== executionId || currentBeforeDispatch.isCancelling || manualTransactionTargetEpoch(currentBeforeDispatch) !== executionTargetEpoch) {
queryExecutionLog("info", "dispatch:skipped-cancelled", { traceId, elapsed: elapsed() });
return false;
}
const executeWithoutManualTransaction = (): Promise<QueryResult[]> => {
queryExecutionLog("info", "execute-multi:start", { traceId, elapsed: elapsed() });
// Query and data tabs use a tab-scoped pool so repeated executions keep
// connection-local state and avoid MySQL pool resets on every refresh.
const dataTabMeta = tab.mode === "data" ? tableMetaForDataTab(tab) : undefined;
const useTableDataPreview = canUseTableDataLargeValuePreview(effectiveDbType, dataTabMeta?.columns ?? [], dataTabMeta?.primaryKeys ?? []);
const useJdbcDriverRowOffset = tab.mode === "data" && jdbcConnectionUsesDriverRowOffset(conn, effectiveDbType);
const executionOptions = {
...(typeof pageLimit === "number"
? useAgentResultSession
? {
// Agent cursors apply maxRows cumulatively across fetched pages.
maxRows: agentProtocolQueryResultMaxRows(queryResultMaxRows),
fetchSize: pageLimit,
pageSize: pageLimit,
resultSessionId: options?.pagination?.sessionId,
}
: { maxRows: pageLimit, fetchSize: pageLimit }
: { maxRows: agentProtocolQueryResultMaxRows(queryResultMaxRows) }),
...(useJdbcDriverRowOffset && typeof pageOffset === "number" && pageOffset > 0 ? { rowOffset: pageOffset } : {}),
...(executionClientSessionId ? { clientSessionId: executionClientSessionId } : {}),
...(tab.mode === "data" && (effectiveDbType === "mysql" || effectiveDbType === "postgres")
? {
maxResultBytes: TABLE_DATA_RESULT_MAX_BYTES,
resultKeyColumns: dataTabMeta?.primaryKeys ?? [],
tableDataPreview: useTableDataPreview,
}
: {}),
...(useOracleLobPreview ? { tableDataPreview: true } : {}),
timeoutSecs: queryTimeoutSecs,
catalog: executionCatalog,
continueOnError: continueOnBatchError,
// MySQL-family connections only use this; other drivers ignore it.
...(settingsStore.editorSettings.keepExplicitTransactionInAutoCommit ? { preserveExplicitTransaction: true } : {}),
};
queryExecutionLog("info", "execute-multi:invoke", {
traceId,
elapsed: elapsed(),
executionSchema,
optionKeys: Object.keys(executionOptions),
clientSession: Boolean(executionClientSessionId),
});
executionDispatched = true;
if (tab.mode === "query") clientRequestStartedAt = performance.now();
if (useAgentResultSession && tab.mode === "query" && typeof pageOffset === "number" && pageOffset > 0 && !options?.pagination?.sessionId && !(tab.batchSqlExecution && tab.batchSqlExecution.total > 1)) {
return (async () => {
let sessionId: string | undefined;
let skipped = 0;
let pageCount = 0;
let executionMs = 0;
let completeTimings = true;
const timings: Record<string, number> = {};
while (true) {
const pageResults = await api.executeMulti(executionConnectionId, executionDatabase, sqlToExecute, executionSchema, executionId, {
...executionOptions,
resultSessionId: sessionId,
});
const page = pageResults[0];
if (!page) return pageResults;
pageCount += 1;
executionMs += page.execution_time_ms;
if (!page.query_timings_ms) completeTimings = false;
else for (const [key, value] of Object.entries(page.query_timings_ms)) timings[key] = (timings[key] ?? 0) + value;
// Offset jumps consume several cursor pages before publication.
// Keep their timings as well, without retaining skipped rows.
const timingSummary = { query_timings_ms: completeTimings ? { ...timings } : undefined, execution_time_ms: executionMs, timing_page_count: pageCount };
if (skipped + page.rows.length > pageOffset) {
const start = pageOffset - skipped;
const limit = typeof pageLimit === "number" ? pageLimit : page.rows.length - start;
return [{ ...page, ...timingSummary, rows: page.rows.slice(start, start + limit) }];
}
skipped += page.rows.length;
if (!page.has_more || !page.session_id) {
if (page.has_more) {
// The cursor session ended before the requested offset;
// returning the short page would show the wrong rows.
throw new Error("Result session ended before the requested page offset");
}
return [{ ...page, ...timingSummary, rows: [] }];
}
sessionId = page.session_id;
}
})();
}
return tab.batchSqlExecution && tab.batchSqlExecution.total > 1
? api.executeMultiWithProgress(
executionConnectionId,
executionDatabase,
sqlToExecute,
(progress) => {
const current = findExecutionTab(id);
if (current?.executionId === executionId) {
applyBatchSqlProgress(current, progress, continueOnBatchError, batchResume?.startStatementIndex ?? 0);
}
},
executionSchema,
{ ...executionOptions, executionId },
)
: api.executeMulti(executionConnectionId, executionDatabase, sqlToExecute, executionSchema, executionId, executionOptions);
};
let executionPromise: Promise<QueryResult[]>;
if (tab.autoCommit === false) {
let useLegacyReadFallback = false;
if (!tab.txnSessionId) {
queryExecutionLog("info", "begin-manual-txn:start", { traceId, elapsed: elapsed() });
try {
tab.txnSessionId = await ensureManualTransactionSession(id, executionDatabase, executionSchema, executionCatalog);
queryExecutionLog("info", "begin-manual-txn:done", { traceId, txnSessionId: tab.txnSessionId, elapsed: elapsed() });
} catch (error) {
const risk = classifySqlRisk(sqlToExecute, { dialect: effectiveDbType }).risk;
if (!isUnsupportedManualTransactionMethod(error) || risk !== "read") throw error;
tab.autoCommit = true;
clearManualTransactionSession(tab);
useLegacyReadFallback = true;
queryExecutionLog("warn", "begin-manual-txn:legacy-read-fallback", {
traceId,
elapsed: elapsed(),
databaseType: effectiveDbType,
});
}
}
if (useLegacyReadFallback) {
executionPromise = executeWithoutManualTransaction();
} else {
queryExecutionLog("info", "execute-in-txn:invoke", { traceId, txnSessionId: tab.txnSessionId, elapsed: elapsed() });
executionDispatched = true;
if (tab.mode === "query") clientRequestStartedAt = performance.now();
// Only an initial manual execution classifies the user SQL (sticky
// proven-read-only dialects). A later cursor-page fetch must neither
// set nor clear the sticky bit.
const isInitialStickyClassification = usesProvenReadOnlyStickyTransactionState(effectiveDbType) && !options?.pagination?.sessionId;
const classificationSql = isInitialStickyClassification ? queryBaseSql : undefined;
let manualTransactionRecoveryAttempted = false;
executionPromise = (async () => {
const txnSessionId = tab.txnSessionId;
if (!txnSessionId) throw new Error("Manual transaction session was not initialized");
// Offset jumps inside a manual transaction keep the legacy
// single-shot call: the session-consume loop only runs in the
// auto-commit path, and opening a cursor session here would
// strand it after returning the first page.
const executeInTransaction = (sessionId: string) =>
useAgentResultSession && !isOffsetJumpPage
? 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;
if (tab.executionId !== executionId || tab.autoCommit !== false || manualTransactionTargetEpoch(tab) !== executionTargetEpoch) throw error;
manualTransactionRecoveryAttempted = true;
// A session that only ever ran proven read-only statements lost
// nothing to the idle rollback, so it restarts without the notice.
const rollbackNoticeRequired = manualTransactionRollbackNoticeRequired(tab, effectiveDbType);
// The expired session was discarded by the backend; the replacement
// session starts fresh, so the old sticky state resets with it.
clearTxnPossiblyDirty(tab);
tab.txnSessionId = undefined;
tab.txnAutoRolledBack = rollbackNoticeRequired;
queryExecutionLog("info", "manual-txn:expired-recover", { traceId, elapsed: elapsed() });
const refreshedSessionId = await ensureManualTransactionSession(id, executionDatabase, executionSchema, executionCatalog);
if (tab.executionId !== executionId || tab.autoCommit !== false || manualTransactionTargetEpoch(tab) !== executionTargetEpoch) {
if (tab.txnSessionId === refreshedSessionId) clearManualTransactionSession(tab);
await api.rollbackManualTransaction(refreshedSessionId);
throw new Error("Query tab changed while the manual transaction was restarting");
}
tab.txnSessionId = refreshedSessionId;
queryExecutionLog("info", "manual-txn:restarted", { traceId, txnSessionId: refreshedSessionId, elapsed: elapsed() });
return executeInTransaction(refreshedSessionId);
}
})();
}
} else {
executionPromise = executeWithoutManualTransaction();
}
const responseResults = await withFrontendQueryTimeout(executionPromise, frontendTimeoutSecs, t("editor.queryTimeoutError", { seconds: frontendTimeoutSecs }), () => {
void api.cancelQuery(executionId).catch((error) => queryExecutionLog("warn", "frontend-timeout:cancel-failed", { traceId, error }));
});
if (findExecutionTab(id) !== tab || tab.executionId !== executionId || manualTransactionTargetEpoch(tab) !== executionTargetEpoch) return false;
// A single result has an unambiguous request boundary. This includes fetch and
// transport, but excludes SQL preparation and the grid's later render work.
if (clientRequestStartedAt !== undefined && responseResults.length === 1 && !responseResults[0]?.execution_error) {
responseResults[0]!.client_request_wait_ms = Math.max(0, performance.now() - clientRequestStartedAt);
responseResults[0]!.client_prepare_ms = Math.max(0, clientRequestStartedAt - startedAt);
responseResults[0]!.timing_page_count ??= 1;
}
const resultProcessingStartedAt = performance.now();
const annotatedResults = annotateQueryResultSources(markQueryResultsRowsRaw(responseResults), queryBaseSql, sourceLabelDatabase, effectiveDbType, options?.sourceOffset, sqlStatementParameterOptions, sqlToExecute, options?.sourceOffset === undefined ? undefined : tab.sql);
const results = offsetBatchQueryResultIndexes(annotatedResults.results, batchResume?.startStatementIndex ?? 0);
if (paginationRowNumberColumn && results.length === 1) {
results[0] = markQueryResultsRowsRaw([stripPaginationRowNumber(results[0]!, paginationRowNumberColumn)])[0]!;
}
reconcileBatchSqlResults(tab, executionId, results);
// Sticky proven-read-only aggregation (Oracle/OceanBase-Oracle/MySQL/PG).
// 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 && usesProvenReadOnlyStickyTransactionState(effectiveDbType) && !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.txnPossiblyDirty = true;
}
}
const successfulOracleSchemaChanges = usesOracleStickyTransactionState(effectiveDbType) ? 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.useDatabase : undefined;
// MySQL 家族(含 Doris/StarRocks)的 `USE db` 同样会切走会话的当前库,标签库名
// 要跟着走,否则工具栏、标签标题和侧栏仍指向旧库(#9941)。SQL Server 走上面的
// 分支,它有额外的事务与 reset 语义。
const mysqlUseDatabase = switchesDatabaseWithUseStatement(effectiveDbType) ? annotatedResults.useDatabase : undefined;
if (hiddenPrimaryKeys.length > 0 && results.length === 1) {
const hiddenIndexes = hiddenResultColumnIndexes(results[0]!.columns, hiddenPrimaryKeys);
if (hiddenIndexes.length > 0) results[0]!.hidden_column_indexes = hiddenIndexes;
if (hiddenIndexes.length !== hiddenPrimaryKeys.length) queryMetadataSql = queryBaseSql;
} else if (hiddenPrimaryKeys.length > 0) {
queryMetadataSql = queryBaseSql;
}
queryExecutionLog("info", "execute-multi:done", {
traceId,
resultCount: results.length,
rowCounts: results.map((result) => result.rows.length),
columnCounts: results.map((result) => result.columns.length),
elapsed: elapsed(),
});
let resolvedSapHanaSchema: string | undefined;
if (successfulSapHanaSchemaChanges > 0 && findExecutionTab(id)?.executionId === executionId) {
try {
const schemaResult = await api.executeQuery(executionConnectionId, executionDatabase, "SELECT CURRENT_SCHEMA FROM DUMMY", undefined, executionId, {
clientSessionId: tabClientSessionId(tab),
timeoutSecs: queryTimeoutSecs,
});
resolvedSapHanaSchema = sapHanaCurrentSchemaFromResult(schemaResult);
} catch (error) {
console.warn("[DBX] Failed to resolve SAP HANA CURRENT_SCHEMA", error);
}
}
if (tab.autoCommit !== false) applyAutoCommitTransactionReport(tab, results);
const current = findExecutionTab(id);
if (current?.executionId === executionId && manualTransactionTargetEpoch(current) === executionTargetEpoch) {
if (captureResultRun && current.isCancelling && restorePendingResultRun(current, executionId)) return false;
if (successfulOracleSchemaChanges > 0) {
current.completionContextVersion = (current.completionContextVersion ?? 0) + successfulOracleSchemaChanges;
}
if (resolvedSapHanaSchema && !usesExternalExecutionTarget) {
current.schema = resolvedSapHanaSchema;
current.completionContextVersion = (current.completionContextVersion ?? 0) + successfulSapHanaSchemaChanges;
}
if (sqlServerUseDatabase && !usesExternalExecutionTarget && current.database !== sqlServerUseDatabase) {
rollbackTabTransaction(current);
void closeClientConnectionSession(current);
current.database = sqlServerUseDatabase;
current.schema = undefined;
}
if (mysqlUseDatabase && !usesExternalExecutionTarget && current.database !== mysqlUseDatabase) {
// 切库后旧库的池(池按「连接 + 库」分桶)不再被这个标签复用,旧会话却已经在
// server 端停在新库上;不关掉它,用户切回旧库时会被重新用上,出现「标签写着 A、
// 实际在 B」的错配。标签上挂着的显式事务在切库后同样不可达,一并收掉(与 SQL
// Server 分支一致)。
rollbackTabTransaction(current);
void closeClientConnectionSession(current);
current.database = mysqlUseDatabase;
current.schema = undefined;
}
if (clientRequestStartedAt !== undefined && results.length === 1 && !results[0]?.execution_error) {
results[0]!.client_result_ms = Math.max(0, performance.now() - resultProcessingStartedAt);
}
const activeGroupIndex = current.activeResultIndex;
const activeGroupResults = current.results;
const shouldAppendResult = !!options?.appendResult && !!current.result;
const shouldReplaceActiveResultInGroup = options?.replaceActiveResultInGroup === true && results.length === 1 && Array.isArray(activeGroupResults) && typeof activeGroupIndex === "number" && activeGroupIndex >= 0 && activeGroupIndex < activeGroupResults.length;
const retainedPaginationResult = options?.retainDisplayedResult === true && results.length === 1 && !isQueryExecutionErrorResult(results[0]!) ? results[0] : undefined;
if (retainedPaginationResult) {
// Cursor-only fetches advance the backend session without publishing
// intermediate rows or page offsets to the visible result grid.
current.resultSessionId = retainedPaginationResult.session_id ?? undefined;
current.resultClientSessionId = current.resultSessionId ? executionClientSessionId : undefined;
producedResult = true;
return producedResult;
}
if (batchResume) {
const mergedResults = mergeBatchQueryResults(batchResume.previousResults, results);
const preferredResult = results.find((result) => isQueryExecutionErrorResult(result)) ?? results[results.length - 1] ?? mergedResults[mergedResults.length - 1];
const resultIndex = preferredResult ? mergedResults.indexOf(preferredResult) : 0;
current.results = mergedResults.length > 1 ? mergedResults : undefined;
current.activeResultIndex = mergedResults.length > 1 ? Math.max(0, resultIndex) : undefined;
current.result = mergedResults[Math.max(0, resultIndex)];
} else if (shouldAppendResult) {
if (results.length !== 1) throw new Error("Expected one result while loading the next segment");
if (options.pagination?.offset !== current.result!.rows.length) {
throw new Error("Ignoring a stale result segment whose offset no longer matches the loaded rows");
}
const appendedResult = appendQueryResultSegment(current.result!, results[0]!, options.appendResult!.maxRows);
if (Array.isArray(activeGroupResults) && typeof activeGroupIndex === "number" && activeGroupIndex >= 0 && activeGroupIndex < activeGroupResults.length) {
current.results = activeGroupResults.slice();
current.results[activeGroupIndex] = appendedResult;
}
current.result = appendedResult;
} else if (shouldReplaceActiveResultInGroup) {
current.results = activeGroupResults.slice();
current.results[activeGroupIndex] = results[0];
current.result = results[0];
} else if (results.length > 1) {
const errorResultIndex = results.findIndex((result) => isQueryExecutionErrorResult(result));
const activeResultIndex = results.findIndex((result) => result.columns.length > 0);
const resultIndex = errorResultIndex >= 0 ? errorResultIndex : (preservedResultIndex(results, current.activeResultIndex, options?.preserveActiveResultIndex) ?? (activeResultIndex >= 0 ? activeResultIndex : 0));
current.results = results;
current.activeResultIndex = resultIndex;
current.result = results[resultIndex];
} else {
current.results = undefined;
current.activeResultIndex = undefined;
current.result = results[0];
}
// Logical-result identity for the view-snapshot cache. An append extends
// the same dataset; every other branch above replaces it.
publishResultGeneration(current, shouldAppendResult ? "append" : (options?.publicationOrigin ?? "execute"));
producedResult = current.result !== undefined;
current.resultBaseSql = batchResume ? batchResume.batch.submittedSql : shouldReplaceActiveResultInGroup ? (current.resultBaseSql ?? queryBaseSql) : queryBaseSql;
current.resultEditorFingerprint = batchResume ? batchResume.batch.editorFingerprint : shouldReplaceActiveResultInGroup ? (current.resultEditorFingerprint ?? executionEditorFingerprint) : executionEditorFingerprint;
current.resultSortedSql = resultSortedSql;
// Appended rows form one logical result starting at the original page.
// Keep the base page state so later table refresh/cache recovery does
// not re-execute only the most recently fetched tail segment.
current.resultPageSql = shouldAppendResult ? (current.resultPageSql ?? pageSql) : pageSql;
const displayPageLimit = typeof pageLimit === "number" ? (requestedPageLimit ?? pageLimit) : undefined;
current.resultPageLimit = shouldAppendResult ? (current.resultPageLimit ?? displayPageLimit) : displayPageLimit;
current.resultPageOffset = shouldAppendResult ? (current.resultPageOffset ?? 0) : pageOffset;
current.resultCountSql = countSql;
current.resultSessionId = current.result?.session_id ?? undefined;
current.resultClientSessionId = current.resultSessionId ? executionClientSessionId : undefined;
if (!options?.preserveTotalRowCountDuringExecution) {
current.resultTotalRowCount = undefined;
}
const resultRowCount = current.result?.rows.length ?? 0;
const resultLimitReached = !!current.result && queryResultLimitReached(pageOffset, resultRowCount, queryResultMaxRows);
if (resultLimitReached && current.result) {
current.result.has_more = false;
current.result.truncated = true;
current.resultTotalRowCount = queryResultMaxRows;
}
const paginationPageResult = shouldAppendResult ? results[0] : current.result;
const exactIncompletePageTotal = paginationPageResult ? exactTotalFromIncompletePage(paginationPageResult, pageLimit, pageOffset, useAgentResultSession) : undefined;
const totalKnownFromIncompletePage = typeof exactIncompletePageTotal === "number";
let totalRowCountResolved = false;
if (current.mode === "query" && current.result && !isQueryExecutionErrorResult(current.result) && typeof exactQueryRowBound === "number") {
const boundedTotal = capQueryResultTotal(exactQueryRowBound, queryResultMaxRows);
current.resultTotalRowCount = Math.min(boundedTotal, exactIncompletePageTotal ?? boundedTotal);
current.resultTotalRowCountLoading = false;
totalRowCountResolved = true;
}
const dataCountTarget =
current.mode === "data"
? (() => {
const tableMeta = tableMetaForDataTab(current);
if (!tableMeta?.tableName) return undefined;
return {
databaseType: effectiveDbType,
identifierQuote: useConnectionStore().connectionIdentifierQuote?.(current.connectionId),
catalog: tableMeta.catalog,
database: tableMeta.database,
schema: tableMeta.schema,
tableName: tableMeta.tableName,
whereInput: current.whereInput?.trim() || undefined,
countHint: effectiveDbType === "gaussdb" ? gaussdbCountQueryDopHint(useConnectionStore().getConfig(current.connectionId)) : undefined,
};
})()
: undefined;
const canAutoCalculateTotalRows =
!options?.appendResult &&
!!current.result &&
resultRowCount > 0 &&
!resultLimitReached &&
!totalKnownFromIncompletePage &&
!totalRowCountResolved &&
settingsStore.editorSettings.autoCalculateTotalRows &&
((current.mode === "query" && !!countSql) || (current.mode === "data" && !!dataCountTarget));
current.resultTotalRowCountLoading = canAutoCalculateTotalRows;
// Server-side pagination without a countSql: the backend (currently
// the Elasticsearch driver) already reports the true match total via
// affected_rows. Use it directly so the result-grid can compute the
// page count without issuing a separate COUNT query.
if (!totalRowCountResolved && current.result && current.result.total_is_exact !== false && current.mode === "query" && typeof pageLimit === "number" && !countSql && typeof current.result.affected_rows === "number" && current.result.affected_rows > current.result.rows.length) {
current.resultTotalRowCount = current.result.affected_rows;
current.resultTotalRowCountLoading = false;
totalRowCountResolved = true;
}
touchResult(current);
syncDisplayedResultRun(current, queryBaseSql, captureResultRun);
if (!options?.appendResult && !resultLimitReached && !totalRowCountResolved && (current.mode === "query" || current.mode === "data") && current.result) {
countQueryTotalRowsInBackground({
tabId: id,
connectionId: executionConnectionId,
database: executionDatabase,
schema: executionTarget?.schema ?? current.schema,
catalog: executionCatalog,
countSql,
countSqlTarget: dataCountTarget
? async () => ({
sql: await api.buildDataGridCountSql(dataCountTarget),
schema: undefined,
})
: undefined,
result: current.result,
pageLimit,
pageOffset,
useAgentResultSession,
executionId,
traceId,
elapsed,
timeoutSecs: queryTimeoutSecs,
txnSessionId: current.autoCommit === false ? current.txnSessionId : undefined,
});
}
queryExecutionLog("info", "result:assigned", {
traceId,
activeResultIndex: current.activeResultIndex,
rowCount: current.result?.rows.length ?? 0,
columnCount: current.result?.columns.length ?? 0,
backendMs: current.result?.execution_time_ms,
elapsed: elapsed(),
});
if (current.mode === "query" && current.result) {
analyzeQueryMetadataInBackground(id, displayedQueryMetadataSql(current, queryMetadataSql), current.result, executionDatabase, traceId, elapsed, effectiveDbType, hiddenPrimaryKeys, conn);
}
} else {
queryExecutionLog("warn", "stale-result", {
traceId,
currentExecutionId: current?.executionId,
elapsed: elapsed(),
});
}
} catch (e: any) {
queryExecutionLog("error", "error", { traceId, elapsed: elapsed(), error: e });
// Sync connection state if the error indicates a lost connection
useConnectionStore().recordConnectionLostError(executionConnectionId ?? tab.connectionId, e);
if (findExecutionTab(id) !== tab || tab.executionId !== executionId || manualTransactionTargetEpoch(tab) !== executionTargetEpoch) return false;
// Handle manual transaction auto-rollback (idle timeout only for the banner;
// other statement failures still clear the session without the 5-minute notice).
if (tab.autoCommit === false) {
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. Same rule as the
// restart path: a session proven read-only reports no lost work.
const rollbackNoticeRequired = manualTransactionRollbackNoticeRequired(tab, effectiveDatabaseTypeForConnection(useConnectionStore().getConfig(tab.connectionId)));
clearTxnPossiblyDirty(tab);
tab.txnSessionId = undefined;
tab.txnAutoRolledBack = rollbackNoticeRequired;
} 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.
clearTxnPossiblyDirty(tab);
tab.txnSessionId = undefined;
tab.txnAutoRolledBack = false;
} else if (tab.txnSessionId && executionDispatched && !options?.pagination?.sessionId && usesProvenReadOnlyStickyTransactionState(effectiveDatabaseTypeForConnection(useConnectionStore().getConfig(tab.connectionId)))) {
// 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.txnPossiblyDirty = true;
}
}
const current = findExecutionTab(id);
if (current?.executionId === executionId) {
failBatchSqlExecution(current, executionId, e, current.isCancelling === true);
const restoredRetainedResult = captureResultRun && (current.isCancelling || !executionDispatched) && restorePendingResultRun(current, executionId);
if (restoredRetainedResult) {
queryExecutionLog("info", "retained-result:restored-after-abort", { traceId, elapsed: elapsed() });
return false;
} else if (options?.appendResult && current.result) {
// A failed background segment must not replace the visible result or
// silently invalidate pending edits. The next explicit refresh can retry.
queryExecutionLog("warn", "append-result:preserved-after-error", { traceId, elapsed: elapsed() });
return false;
}
const errorResult = toErrorResult(e);
annotateSingleStatementErrorResult(errorResult, queryBaseSql, errorLocateContext?.databaseType, errorLocateContext?.sourceOffset, errorLocateContext?.parameterOptions, errorLocateContext?.executedSql);
const activeGroupIndex = current.activeResultIndex;
const activeGroupResults = current.results;
const shouldReplaceActiveResultInGroup = options?.replaceActiveResultInGroup === true && Array.isArray(activeGroupResults) && typeof activeGroupIndex === "number" && activeGroupIndex >= 0 && activeGroupIndex < activeGroupResults.length;
if (shouldReplaceActiveResultInGroup) {
current.results = activeGroupResults.slice();
current.results[activeGroupIndex] = errorResult;
current.result = errorResult;
} else {
current.result = errorResult;
current.results = undefined;
current.activeResultIndex = undefined;
}
current.queryAnalysis = undefined;
current.querySourceColumns = undefined;
current.queryWriteTargets = undefined;
current.resultColumnComments = undefined;
current.queryDisplaySourceColumns = undefined;
current.queryEditabilityReason = undefined;
current.mongoEditTarget = undefined;
if (current.mode !== "data") current.tableMeta = undefined;
current.resultBaseSql = shouldReplaceActiveResultInGroup ? (current.resultBaseSql ?? queryBaseSql) : queryBaseSql;
current.resultSortedSql = resultSortedSql;
current.resultPageSql = pageSql;
current.resultPageLimit = typeof pageLimit === "number" ? (requestedPageLimit ?? pageLimit) : undefined;
current.resultPageOffset = pageOffset;
current.resultCountSql = countSql;
current.resultSessionId = undefined;
current.resultClientSessionId = undefined;
current.resultTotalRowCount = undefined;
current.resultTotalRowCountLoading = false;
touchResult(current);
producedResult = true;
// An error result replaces the dataset the view snapshot was taken on.
publishResultGeneration(current, "execute");
// When a pinned result requires a new run, errors must use that same
// run instead of being replaced by the retained pinned result below.
syncDisplayedResultRun(current, queryBaseSql, captureResultRun);
}
} finally {
if (tableDataNativeSelectionBlockOwner) finishDataGridNativeSelectionBlock(tableDataNativeSelectionBlockOwner);
const current = findExecutionTab(id);
if (current?.executionId === executionId) {
const liveBatch = liveBatchSqlExecutions.get(current);
if (liveBatch?.executionId === executionId) current.batchSqlExecution = liveBatch;
finishBatchSqlExecution(current, executionId, current.isCancelling === true);
if (current.activeResultRunId && current.result) syncActiveResultRunFromDisplayed(current);
if (captureResultRun && !current.activeResultRunId) {
restorePendingResultRun(current, executionId);
} else {
pendingResultRunRestores.delete(executionId);
}
current.isExecuting = false;
current.isCancelling = false;
current.queryExecutionStartedAt = undefined;
current.executionId = undefined;
current.executingResultRunId = undefined;
clearLiveBatchSqlExecution(current, executionId);
queryExecutionLog("info", "finish", { traceId, elapsed: elapsed() });
} else {
pendingResultRunRestores.delete(executionId);
if (current) clearLiveBatchSqlExecution(current, executionId);
queryExecutionLog("warn", "finish-stale", {
traceId,
currentExecutionId: current?.executionId,
elapsed: elapsed(),
});
}
}
scheduleResultCacheTrim();
return producedResult;
}
function dismissBatchSqlRecovery(id: string) {
const tab = findExecutionTab(id);
if (!tab?.batchSqlExecution || !batchSqlRecoveryState(tab)) return false;
tab.batchSqlExecution.recoveryDismissed = true;
return true;
}
async function resumeBatchSql(id: string, action: BatchSqlRecoveryAction) {
const tab = findExecutionTab(id);
const recovery = tab ? batchSqlRecoveryState(tab) : undefined;
const batch = tab?.batchSqlExecution;
if (!tab || !batch || !recovery) return false;
const startStatementIndex = action === "retry" ? recovery.failedStatementIndex : recovery.failedStatementIndex + 1;
const resumed = batchSqlRecoverySql(batch, startStatementIndex);
if (!resumed) return false;
const previousResults = tab.results?.slice() ?? (tab.result ? [tab.result] : []);
return await executeTabSql(id, resumed.sql, {
sourceOffset: resumed.sourceOffset,
preserveResultDuringExecution: true,
batchResume: {
batch: cloneBatchSqlExecution(batch)!,
previousResults,
startStatementIndex,
continueOnError: action === "skip-all",
},
});
}
async function explainTabSql(id: string, sql: string, databaseType?: DatabaseType, explainMode?: string) {
assertUpdateAllowsInteraction();
const tab = tabs.value.find((t) => t.id === id);
if (!tab) return { ok: false as const, reason: "empty" as const };
const conn = useConnectionStore().getConfig(tab.connectionId);
const queryTimeoutSecs = queryTimeoutSecsForConnection(conn, settingsStore.editorSettings.globalQueryTimeoutSecs);
const executionId = uuid();
tab.isExplaining = true;
tab.explainExecutionId = executionId;
tab.explainPlan = undefined;
tab.explainTableResult = undefined;
tab.explainError = undefined;
tab.explainTableError = undefined;
tab.explainSql = undefined;
tab.explainTableSql = undefined;
tab.lastExplainedSql = sql;
try {
await waitForTabSessionReset(id);
} catch (e: any) {
// Do not start an explain with a session whose schema reset did not complete.
tab.isExplaining = false;
tab.explainExecutionId = undefined;
tab.explainError = String(e?.message || e);
return { ok: false as const, reason: tab.explainError };
}
// DM and Oracle agents expose native text plans. DM also supports autotrace.
if (databaseType === "dameng" || databaseType === "oracle") {
let explainSql = sql;
if (databaseType === "oracle") {
const built = await buildExplainSql(databaseType, sql);
if (!built.ok) {
tab.isExplaining = false;
tab.explainExecutionId = undefined;
tab.explainPlan = undefined;
tab.explainError = built.reason;
return built;
}
explainSql = built.sql;
}
// Autotrace executes the SQL, so keep its stricter safety check.
if (databaseType === "dameng" && explainMode === "autotrace") {
const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i;
const cleaned = sql
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/--.*$/gm, " ")
.replace(/#.*$/gm, " ");
if (cleaned.split(";").some((stmt) => DANGER_RE.test(stmt))) {
tab.isExplaining = false;
tab.explainExecutionId = undefined;
return { ok: false as const, reason: "unsafe" as const };
}
}
try {
const mode = databaseType === "dameng" && explainMode === "autotrace" ? "autotrace" : "explain";
const planText = (await api.getExplainInfo(tab.connectionId, tab.database, tab.schema, sql, mode)) as string | undefined;
const current = tabs.value.find((t) => t.id === id);
if (current?.explainExecutionId === executionId) {
if (planText && planText.length > 0) {
current.explainPlan = databaseType === "oracle" ? parseOracleExplainText(planText) : parseDamengExplainText(planText);
current.explainSql = explainSql;
current.explainError = undefined;
} else {
current.explainPlan = undefined;
current.explainError = "No explain plan returned";
}
}
} catch (e: any) {
const current = tabs.value.find((t) => t.id === id);
if (current?.explainExecutionId === executionId) {
current.explainPlan = undefined;
// Backend rejections contain the real ORA/Agent diagnostic; only successful empty responses use the generic empty-plan message.
current.explainError = formatError(e);
}
} finally {
const current = tabs.value.find((t) => t.id === id);
if (current?.explainExecutionId === executionId) {
current.isExplaining = false;
current.explainExecutionId = undefined;
}
}
return { ok: true as const, sql: explainSql };
}
if (databaseType === "mysql") {
let tableBuilt: BuildExplainSqlResult;
let jsonBuilt: BuildExplainSqlResult;
try {
[tableBuilt, jsonBuilt] = await Promise.all([buildExplainSql(databaseType, sql, "standard"), buildExplainSql(databaseType, sql, "json")]);
} catch (e: any) {
const current = tabs.value.find((t) => t.id === id);
if (current?.explainExecutionId === executionId) {
current.isExplaining = false;
current.explainExecutionId = undefined;
current.explainError = String(e?.message || e);
}
return { ok: true as const, sql: "" };
}
if (tabs.value.find((t) => t.id === id)?.explainExecutionId !== executionId) {
return { ok: true as const, sql: jsonBuilt.ok ? jsonBuilt.sql : "" };
}
if (!tableBuilt.ok || !jsonBuilt.ok) {
const failed = !tableBuilt.ok ? tableBuilt : jsonBuilt;
const reason = !tableBuilt.ok ? tableBuilt.reason : !jsonBuilt.ok ? jsonBuilt.reason : "unsupported";
const current = tabs.value.find((t) => t.id === id);
if (current?.explainExecutionId === executionId) {
current.isExplaining = false;
current.explainExecutionId = undefined;
current.explainError = reason;
}
return failed;
}
let tableSql = tableBuilt.sql;
let jsonSupportedByServer: boolean | undefined;
tab.explainTableSql = tableSql;
tab.explainSql = undefined;
// Keep the two EXPLAIN statements on the same one-connection MySQL session.
const clientSessionId = `${tabClientSessionId(tab, "explain")}:${executionId}`;
tab.explainClientSessionId = clientSessionId;
try {
let tableResult: QueryResult | undefined;
let tableError: unknown;
try {
tableResult = await api.executeQuery(tab.connectionId, tab.database, tableSql, tab.schema, executionId, {
clientSessionId,
catalog: tab.catalog,
timeoutSecs: queryTimeoutSecs,
});
} catch (error: unknown) {
const compatibility = mysqlExplainCompatibilityHint(error, tableSql);
jsonSupportedByServer = compatibility?.supportsJson;
if (compatibility?.fallbackSql && tabs.value.find((t) => t.id === id)?.explainExecutionId === executionId) {
tableSql = compatibility.fallbackSql;
tab.explainTableSql = tableSql;
try {
tableResult = await api.executeQuery(tab.connectionId, tab.database, tableSql, tab.schema, executionId, {
clientSessionId,
catalog: tab.catalog,
timeoutSecs: queryTimeoutSecs,
});
} catch (fallbackError: unknown) {
tableError = fallbackError;
}
} else {
tableError = error;
}
}
const current = tabs.value.find((t) => t.id === id);
if (current?.explainExecutionId === executionId) {
if (tableResult) {
current.explainTableResult = markQueryResultRowsRaw(tableResult);
current.explainTableError = undefined;
} else if (tableError !== undefined) {
current.explainTableResult = undefined;
current.explainTableError = formatError(tableError);
}
}
// A canceled or superseded standard request must not start a fallback or JSON request.
if (tabs.value.find((t) => t.id === id)?.explainExecutionId !== executionId) {
return { ok: true as const, sql: tableSql };
}
// ADB MySQL advertises its accepted formats in the first error; avoid a second known-invalid request.
if (jsonSupportedByServer === false) {
const latest = tabs.value.find((t) => t.id === id);
if (latest?.explainExecutionId === executionId) {
latest.explainPlan = undefined;
latest.explainError = latest.explainTableResult ? undefined : latest.explainTableError;
}
return { ok: true as const, sql: tableSql };
}
try {
const latest = tabs.value.find((t) => t.id === id);
if (latest?.explainExecutionId === executionId) latest.explainSql = jsonBuilt.sql;
const jsonResult = await api.executeQuery(tab.connectionId, tab.database, jsonBuilt.sql, tab.schema, executionId, {
clientSessionId,
catalog: tab.catalog,
timeoutSecs: queryTimeoutSecs,
});
const current = tabs.value.find((t) => t.id === id);
if (current?.explainExecutionId === executionId) {
current.explainPlan = parseExplainResult("mysql", jsonResult);
current.explainError = undefined;
}
} catch (e: any) {
const latest = tabs.value.find((t) => t.id === id);
if (latest?.explainExecutionId === executionId) {
latest.explainPlan = undefined;
// Keep a usable tabular plan visible when the server explicitly rejects JSON.
const compatibility = mysqlExplainCompatibilityHint(e, jsonBuilt.sql);
latest.explainError = compatibility?.supportsJson === false && latest.explainTableResult ? undefined : formatError(e);
}
}
} finally {
const current = tabs.value.find((t) => t.id === id);
if (current?.explainExecutionId === executionId) {
current.isExplaining = false;
current.explainExecutionId = undefined;
}
if (current?.explainClientSessionId === clientSessionId) current.explainClientSessionId = undefined;
void closeClientSessionId(tab.connectionId, tab.database, clientSessionId, tab.catalog, { tabId: tab.id, explainExecutionId: executionId });
}
return { ok: true as const, sql: tab.explainSql ?? tableSql };
}
if (databaseType === "sqlserver") {
// SQL Server reuses the autotrace toggle to ask for the actual execution plan:
// STATISTICS XML runs the statement and adds runtime counters to the same
// ShowPlanXML document, while SHOWPLAN_XML only estimates.
const actualPlan = explainMode === "autotrace";
const planCaptureOn = actualPlan ? "SET STATISTICS XML ON;" : "SET SHOWPLAN_XML ON;";
const planCaptureOff = actualPlan ? "SET STATISTICS XML OFF;" : "SET SHOWPLAN_XML OFF;";
let built: BuildExplainSqlResult;
try {
built = actualPlan ? await buildExplainSql(databaseType, sql, "json", true) : await buildExplainSql(databaseType, sql);
} catch (e: any) {
tab.isExplaining = false;
tab.explainExecutionId = undefined;
tab.explainError = String(e?.message || e);
return { ok: true as const, sql: "" };
}
if (!built.ok) {
tab.isExplaining = false;
tab.explainExecutionId = undefined;
tab.explainError = built.reason;
return built;
}
tab.explainSql = built.sql;
const clientSessionId = `${tabClientSessionId(tab, "explain")}:${executionId}`;
tab.explainClientSessionId = clientSessionId;
let planCaptureEnabled = false;
try {
await api.executeQuery(tab.connectionId, tab.database, planCaptureOn, tab.schema, executionId, {
clientSessionId,
timeoutSecs: queryTimeoutSecs,
executionMode: "simple",
});
planCaptureEnabled = true;
if (tabs.value.find((t) => t.id === id)?.explainExecutionId !== executionId) {
return { ok: true as const, sql: built.sql };
}
const results = await api.executeMulti(tab.connectionId, tab.database, sql, tab.schema, executionId, {
clientSessionId,
timeoutSecs: queryTimeoutSecs,
executionMode: "simple",
});
const current = tabs.value.find((t) => t.id === id);
if (current?.explainExecutionId === executionId) {
const outcome = sqlServerExplainResult(results);
if (outcome.error !== undefined) {
current.explainPlan = undefined;
current.explainError = outcome.error;
} else if (outcome.result) {
current.explainPlan = parseExplainResult("sqlserver", outcome.result);
current.explainError = undefined;
} else {
current.explainPlan = undefined;
current.explainError = t("explain.empty");
}
}
} catch (e: any) {
const current = tabs.value.find((t) => t.id === id);
if (current?.explainExecutionId === executionId) {
current.explainPlan = undefined;
current.explainError = String(e?.message || e);
}
} finally {
if (planCaptureEnabled) {
try {
await api.executeQuery(tab.connectionId, tab.database, planCaptureOff, tab.schema, undefined, {
clientSessionId,
timeoutSecs: queryTimeoutSecs > 0 ? Math.min(queryTimeoutSecs, 5) : 5,
executionMode: "simple",
});
} catch (error) {
console.warn("[DBX][sqlserver-explain:cleanup:error]", { tabId: tab.id, error });
}
}
const current = tabs.value.find((t) => t.id === id);
if (current?.explainExecutionId === executionId) {
current.isExplaining = false;
current.explainExecutionId = undefined;
}
if (current?.explainClientSessionId === clientSessionId) current.explainClientSessionId = undefined;
await closeClientSessionId(tab.connectionId, tab.database, clientSessionId, tab.catalog, { tabId: tab.id, explainExecutionId: executionId });
}
return { ok: true as const, sql: built.sql };
}
const postgresAnalyze = databaseType === "postgres" && explainMode === "autotrace";
const built = postgresAnalyze ? await buildExplainSql(databaseType, sql, "json", true) : await buildExplainSql(databaseType, sql);
if (!built.ok) {
tab.explainPlan = undefined;
tab.explainError = built.reason;
tab.isExplaining = false;
tab.explainExecutionId = undefined;
return built;
}
tab.explainSql = built.sql;
const clientSessionId = postgresAnalyze ? `${tabClientSessionId(tab, "explain")}:${executionId}` : tabClientSessionId(tab, "explain");
if (postgresAnalyze) tab.explainClientSessionId = clientSessionId;
try {
const result = await api.executeQuery(tab.connectionId, tab.database, built.sql, tab.schema, executionId, {
clientSessionId,
catalog: tab.catalog,
timeoutSecs: queryTimeoutSecs,
executionMode: postgresAnalyze ? "postgres_read_only_transaction" : undefined,
});
const current = tabs.value.find((t) => t.id === id);
if (current?.explainExecutionId === executionId) {
current.explainPlan = parseExplainResult(databaseType as ExplainPlanDatabaseType, result);
current.explainError = undefined;
}
} catch (e: any) {
const current = tabs.value.find((t) => t.id === id);
if (current?.explainExecutionId === executionId) {
current.explainPlan = undefined;
current.explainError = String(e?.message || e);
}
} finally {
const current = tabs.value.find((t) => t.id === id);
if (current?.explainExecutionId === executionId) {
current.isExplaining = false;
current.explainExecutionId = undefined;
}
if (current?.explainClientSessionId === clientSessionId) current.explainClientSessionId = undefined;
const closePromise = closeClientSessionId(tab.connectionId, tab.database, clientSessionId, tab.catalog, { tabId: tab.id, explainExecutionId: executionId });
if (postgresAnalyze) await closePromise;
else void closePromise;
}
return { ok: true as const, sql: built.sql };
}
async function cancelTabExecution(id: string) {
const tab = findExecutionTab(id);
if (!tab || !canCancelQueryExecution(tab)) return false;
const executionId = tab.executionId;
if (!executionId) return false;
const stopMonitor = redisMonitors.get(executionId);
if (stopMonitor) {
tab.isCancelling = true;
tab.cancelRequestCount = (tab.cancelRequestCount ?? 0) + 1;
stopMonitor();
return true;
}
tab.isCancelling = true;
// 单调递增、不随取消结果回退:导航流程据此判断"执行期间用户请求过停止"
// (isCancelling 在取消失败或查询先完成时会被清掉,无法承担这个语义)
tab.cancelRequestCount = (tab.cancelRequestCount ?? 0) + 1;
if (pendingResultRunPreparations.get(tab) === executionId) {
// No SQL was dispatched; invalidate the disk read without a backend cancel.
pendingResultRunPreparations.delete(tab);
tab.isExecuting = false;
tab.isCancelling = false;
tab.executionId = undefined;
tab.executingResultRunId = undefined;
tab.queryExecutionStartedAt = undefined;
return true;
}
const cancellationStartedAt = performance.now();
try {
const canceled = await withCancelQueryTimeout(api.cancelQuery(executionId));
if (canceled) {
clearAcknowledgedCancelIfStillRunning(id, executionId);
}
if (!canceled) {
const current = findExecutionTab(id);
if (current && current.executionId === executionId) {
finishBatchSqlExecution(current, executionId, false);
restorePendingResultRun(current, executionId);
current.isExecuting = false;
current.isCancelling = false;
current.executionId = undefined;
current.executingResultRunId = undefined;
current.queryExecutionStartedAt = undefined;
clearLiveBatchSqlExecution(current, executionId);
}
}
return canceled;
} catch (e: any) {
// Sync connection state if the error indicates a lost connection
if (tab) useConnectionStore().recordConnectionLostError(tab.connectionId, e);
const current = findExecutionTab(id);
if (current && current.executionId === executionId) {
failBatchSqlExecution(current, executionId, e, false);
finishBatchSqlExecution(current, executionId, false);
if (restorePendingResultRun(current, executionId)) {
current.isExecuting = false;
current.isCancelling = false;
current.queryExecutionStartedAt = undefined;
current.executionId = undefined;
current.executingResultRunId = undefined;
} else {
// 复用 setErrorResult 的完整清理:分组结果不清空的话,错误结果不会展示,
// 估算值也会继续按旧的 results 计算
setErrorResult(id, e);
}
clearLiveBatchSqlExecution(current, executionId);
}
return false;
} finally {
recordQueryCancellationLatency(performance.now() - cancellationStartedAt);
}
}
async function cancelTabExplain(id: string) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab?.isExplaining || !tab.explainExecutionId) return false;
const executionId = tab.explainExecutionId;
// Invalidate locally before the remote cancellation call so no later stage can start.
tab.isExplaining = false;
tab.explainExecutionId = undefined;
try {
return await api.cancelQuery(executionId);
} catch {
return false;
}
}
function setActiveResultIndex(id: string, index: number) {
const tab = findExecutionTab(id);
if (!tab?.results || index < 0 || index >= tab.results.length) return;
tab.activeResultIndex = index;
tab.result = tab.results[index];
tab.resultLocalSortOriginalRows = undefined;
tab.resultLocalSortOriginalLargeValueCells = undefined;
tab.resultLocalSortOriginalMongoDocuments = undefined;
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
tab.resultSortColumn = undefined;
tab.resultSortColumnIndex = undefined;
tab.resultSortDirection = undefined;
tab.resultSortMode = undefined;
tab.resultSortedSql = undefined;
tab.resultTotalRowCount = undefined;
tab.resultTotalRowCountLoading = false;
// results 数组未变,估算值与当前激活的 result 无关,可直接复用
touchResult(tab, Date.now(), { reuseEstimatedBytes: true });
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.queryWriteTargets = undefined;
tab.resultColumnComments = undefined;
tab.queryDisplaySourceColumns = undefined;
tab.queryEditabilityReason = undefined;
tab.mongoEditTarget = undefined;
syncActiveResultRunFromDisplayed(tab);
const sourceStatement = tab.result?.sourceStatement;
if (tab.mode === "query" && sourceStatement && splitMongoCommandRanges(sourceStatement).length === 0) {
const metadataStartedAt = performance.now();
const connection = useConnectionStore().getConfig(tab.connectionId);
const executionDatabase = tab.database;
analyzeQueryMetadataInBackground(id, sourceStatement, tab.result, executionDatabase, uuid().slice(0, 8), () => `${Math.round(performance.now() - metadataStartedAt)}ms`, effectiveDatabaseTypeForConnection(connection), [], connection);
}
}
function notifyConnectionMayBeLost() {
const stuck = tabs.value.filter((t) => t.isExecuting);
if (stuck.length > 0) {
const connStore = useConnectionStore();
stuck.forEach((tab) => {
const error = new Error(t("editor.connectionMayBeLost"));
setErrorResult(tab.id, error);
connStore.markConnectionLost(tab.connectionId, error);
});
}
}
async function trimResultCache() {
const inactive = tabs.value.filter((t) => t.id !== activeTabId.value && (t.result || t.results));
const evictionIds = new Set(
selectInactiveResultEvictions(
inactive.map((tab) => ({
id: tab.id,
estimatedBytes: tab.resultEstimatedBytes ?? estimateQueryResultsBytes(tab.result, tab.results),
accessedAt: tab.resultAccessedAt ?? 0,
})),
MAX_CACHED_RESULT_BYTES,
MAX_CACHED_RESULTS,
),
);
const toEvict = inactive.filter((tab) => evictionIds.has(tab.id));
if (toEvict.length > 0) {
await Promise.all(toEvict.map((t) => evictCachedResult(t)));
}
}
function scheduleResultCacheTrim() {
resultCacheTrimRequested = true;
if (resultCacheTrimScheduled || resultCacheTrimRunning) return;
resultCacheTrimScheduled = true;
const run = () => {
resultCacheTrimScheduled = false;
void runRequestedResultCacheTrim();
};
// Eviction serializes large result payloads; schedule it after the result
// assignment so the grid can paint before cache maintenance starts.
if (typeof window !== "undefined" && "requestIdleCallback" in window) {
window.requestIdleCallback(run, { timeout: 1500 });
} else {
setTimeout(run, 0);
}
}
async function runRequestedResultCacheTrim() {
if (resultCacheTrimRunning) return;
resultCacheTrimRunning = true;
try {
while (resultCacheTrimRequested) {
resultCacheTrimRequested = false;
await trimResultCache();
}
} finally {
resultCacheTrimRunning = false;
if (resultCacheTrimRequested) scheduleResultCacheTrim();
}
}
function rememberActiveTab(id: string | null) {
if (!id || !tabs.value.some((tab) => tab.id === id)) return;
activeTabHistory.value = [...activeTabHistory.value.filter((tabId) => tabId !== id), id];
}
watch(
activeTabId,
(id) => {
rememberActiveTab(id);
touchResult(
tabs.value.find((tab) => tab.id === id),
Date.now(),
{ reuseEstimatedBytes: true },
);
},
{ flush: "sync" },
);
watch(
activeTabId,
(id) => {
if (tabs.value.length > 0 && (!id || !tabs.value.some((tab) => tab.id === id))) {
activeTabId.value = tabs.value[0].id;
}
},
{ flush: "sync" },
);
function restoreCachedResultPayload(tab: QueryTab, snapshot: Awaited<ReturnType<typeof readTabResultSnapshot>>) {
if (!snapshot) return false;
const results = snapshot.results ? markQueryResultsRowsRaw(snapshot.results) : undefined;
const activeIndex = snapshot.activeResultIndex ?? 0;
tab.results = results;
tab.activeResultIndex = snapshot.activeResultIndex;
tab.resultEditorFingerprint = snapshot.resultEditorFingerprint;
tab.result = snapshot.result ? markQueryResultRowsRaw(snapshot.result) : results?.[activeIndex] ? markQueryResultRowsRaw(results[activeIndex]) : undefined;
tab.resultLocalSortOriginalRows = snapshot.resultLocalSortOriginalRows ? markRaw(snapshot.resultLocalSortOriginalRows) : undefined;
tab.resultLocalSortOriginalLargeValueCells = snapshot.resultLocalSortOriginalLargeValueCells?.map((cell) => ({ ...cell }));
tab.resultLocalSortOriginalMongoDocuments = snapshot.resultLocalSortOriginalMongoDocuments ? markRaw(snapshot.resultLocalSortOriginalMongoDocuments) : undefined;
tab.resultLocalSortOriginalMongoCopyDocuments = snapshot.resultLocalSortOriginalMongoCopyDocuments ? markRaw(snapshot.resultLocalSortOriginalMongoCopyDocuments) : undefined;
// 快照编解码会重建负载,落盘前的各 run 估算值不再对应恢复后的对象,
// 置空让 projectResultRun 按需重算
tab.resultRuns = snapshot.resultRuns ? markQueryResultRunsRowsRaw(snapshot.resultRuns).map((run) => ({ ...run, resultEstimatedBytes: undefined })) : tab.resultRuns;
tab.activeResultRunId = snapshot.activeResultRunId ?? tab.activeResultRunId;
// Disk restore is the same logical result: keep the captured view identity.
tab.resultViewGeneration = snapshot.resultViewGeneration ?? tab.resultViewGeneration;
if (!tab.result && !tab.results && !tab.resultRuns) return false;
tab.queryAnalysis = snapshot.queryAnalysis;
tab.querySourceColumns = snapshot.querySourceColumns;
tab.queryWriteTargets = snapshot.queryWriteTargets;
tab.resultColumnComments = snapshot.resultColumnComments;
tab.queryDisplaySourceColumns = snapshot.queryDisplaySourceColumns;
tab.queryEditabilityReason = snapshot.queryEditabilityReason;
tab.mongoEditTarget = snapshot.mongoEditTarget;
// Data tab 的结果快照可能早于最近一次结构变更。已持有真实元数据时,
// 不允许旧快照回滚列名或主键;若恢复后仍没有真实列,重新挂起编辑门控。
if (tab.mode === "data" && tab.tableMeta?.columns.length) {
// 保留当前真实元数据
} else {
tab.tableMeta = snapshot.tableMeta;
}
if (tab.mode === "data" && !tab.tableMeta?.columns.length) {
tab.tableMetaPending = true;
}
tab.resultPageSql = snapshot.resultPageSql;
// Cached rows and their pagination metadata describe the same result page.
// Restoring this state must not change the global page-size defaults.
tab.resultPageLimit = snapshot.resultPageLimit;
tab.resultPageOffset = snapshot.resultPageOffset;
tab.resultCountSql = snapshot.resultCountSql;
tab.resultTotalRowCount = snapshot.resultTotalRowCount;
tab.resultTotalRowCountLoading = false;
tab.resultSessionId = undefined;
tab.resultClientSessionId = undefined;
tab.resultEvicted = undefined;
tab.resultCacheState = "memory";
touchResult(tab);
return true;
}
async function hydrateResultRunsForArchive(tab: QueryTab, snapshot: NonNullable<ReturnType<typeof buildTabResultSnapshot>>) {
if (!snapshot.resultRuns?.length) return snapshot;
const resultRuns = await Promise.all(
snapshot.resultRuns.map(async (run) => {
if (resultRunHasPayload(run)) return run;
const cacheKey = run.resultCacheKey ?? tab.resultRuns?.find((item) => item.id === run.id)?.resultCacheKey;
if (!cacheKey) return run;
const cached = await readTabResultSnapshot(cacheKey);
return cached?.resultRuns?.find((item) => item.id === run.id) ?? run;
}),
);
return { ...snapshot, resultRuns };
}
async function resultArchiveSnapshotForTab(tab: QueryTab) {
let snapshot = buildTabResultSnapshot(tab);
if (tab.resultCacheKey && (!snapshot || tab.resultEvicted || !resultSnapshotHasPayload(snapshot))) {
snapshot = (await readTabResultSnapshot(tab.resultCacheKey)) ?? snapshot;
}
if (snapshot) snapshot = await hydrateResultRunsForArchive(tab, snapshot);
return snapshot && resultSnapshotHasPayload(snapshot) ? snapshot : undefined;
}
async function exportResultArchive(id: string): Promise<Uint8Array | undefined> {
const tab = tabs.value.find((t) => t.id === id);
if (!tab || tab.mode !== "query") return undefined;
const snapshot = await resultArchiveSnapshotForTab(tab);
if (!snapshot) return undefined;
return encodeQueryResultArchive(tab, snapshot);
}
function openResultArchiveTab(archive: DecodedQueryResultArchive): string | undefined {
const id = uuid();
const title = archive.tab.title.trim() || t("tabs.importedResultArchive");
const dbType = useConnectionStore().getConfig(archive.tab.connectionId)?.db_type;
const tab: QueryTab = {
id,
title,
customTitle: true,
connectionId: archive.tab.connectionId,
database: archive.tab.database,
schema: archive.tab.schema,
sql: archive.tab.sql,
originalSql: archive.tab.sql,
lastExecutedSql: archive.tab.lastExecutedSql,
resultBaseSql: archive.tab.resultBaseSql,
resultSortedSql: archive.tab.resultSortedSql,
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "query",
autoCommit: defaultAutoCommitForDbTypeWithSetting(dbType),
};
if (!restoreCachedResultPayload(tab, archive.snapshot)) return undefined;
const activeRun = tab.resultRuns?.find((run) => run.id === tab.activeResultRunId) ?? tab.resultRuns?.[0];
if (activeRun) projectResultRun(tab, activeRun);
return registerOpenTab(tab);
}
async function importResultArchive(bytes: Uint8Array | ArrayBuffer): Promise<string | undefined> {
const archive = await decodeQueryResultArchive(bytes);
if (!archive) return undefined;
return openResultArchiveTab(archive);
}
async function reloadEvictedTab(id: string, { reexecuteOnMissing = false }: { reexecuteOnMissing?: boolean } = {}) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab || !tab.resultEvicted) return;
if (tab.resultCacheKey) {
const restored = restoreCachedResultPayload(tab, await readTabResultSnapshot(tab.resultCacheKey));
if (restored) return;
tab.resultCacheState = "missing";
if (!reexecuteOnMissing) return;
}
tab.resultEvicted = false;
const sql = tab.lastExecutedSql ?? tab.sql;
if (!sql?.trim()) return;
await executeTabSql(tab.id, sql, {
resultBaseSql: tab.resultBaseSql ?? sql,
resultSortedSql: tab.resultSortedSql,
pagination:
tab.mode === "data"
? {
limit: tab.resultPageLimit ?? tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize),
offset: tab.resultPageOffset ?? 0,
}
: undefined,
});
}
async function fetchTabResultForExport(id: string, onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void): Promise<QueryResult | undefined> {
const tab = tabs.value.find((t) => t.id === id);
if (!tab?.result) return undefined;
if (tab.mode === "data") {
const connStore = useConnectionStore();
await connStore.ensureConnected(tab.connectionId);
const conn = connStore.getConfig(tab.connectionId);
const tableMeta = tableMetaForDataTab(tab);
if (!tableMeta?.tableName) return tab.result;
// Use the already-computed total row count as a progress estimate so the
// export dialog shows a moving bar instead of a stuck 0 while paginating.
const totalRows = typeof tab.resultTotalRowCount === "number" ? tab.resultTotalRowCount : null;
const pageLimit = TABLE_DATA_EXPORT_PAGE_SIZE;
const effectiveDbType = effectiveDatabaseTypeForConnection(conn);
const identifierQuote = connStore.connectionIdentifierQuote?.(tab.connectionId);
const primaryKeys = tab.tableMeta ? tab.tableMeta.primaryKeys : tableMeta.primaryKeys;
const sortOrder = tab.resultSortColumn && tab.resultSortDirection ? `${quoteTableDataIdentifier(effectiveDbType, tab.resultSortColumn, identifierQuote)} ${tab.resultSortDirection.toUpperCase()}` : undefined;
const orderBy = tab.orderByInput?.trim() || sortOrder;
const queryTimeoutSecs = queryTimeoutSecsForConnection(conn, settingsStore.editorSettings.globalQueryTimeoutSecs);
const executionDatabase = tab.database;
const rows: QueryResult["rows"] = [];
let columns: string[] = [];
let executionTimeMs = 0;
let offset = 0;
const clientSessionId = tabClientSessionId(tab, "export");
const exportExecutionId = uuid();
const useAgentCursor = conn?.db_type === "sqlserver" && conn?.driver_profile?.trim().toLowerCase() === "sqlserver-legacy";
let sessionId: string | undefined;
try {
while (true) {
const sql = await api.buildTableSelectSql({
databaseType: effectiveDbType,
driverProfile: conn?.driver_profile,
identifierQuote,
database: tableMeta.database,
schema: tableMeta.schema,
tableName: tableMeta.tableName,
tableType: tableMeta.tableType,
catalog: tableMeta.catalog,
columns: tableMeta.columns.map((column) => column.name),
primaryKeys,
whereInput: tab.whereInput,
orderBy,
limit: pageLimit,
offset,
});
const results = await api.executeMulti(
tab.connectionId,
executionDatabase,
sql,
undefined,
exportExecutionId,
useAgentCursor
? {
maxRows: 2_147_483_647,
fetchSize: pageLimit,
pageSize: pageLimit,
resultSessionId: sessionId,
clientSessionId,
catalog: tableMeta.catalog,
timeoutSecs: queryTimeoutSecs,
}
: {
maxRows: pageLimit,
fetchSize: pageLimit,
clientSessionId,
catalog: tableMeta.catalog,
timeoutSecs: queryTimeoutSecs,
},
);
const result = results[0];
if (!result) break;
if (columns.length === 0) columns = result.columns;
rows.push(...result.rows);
executionTimeMs += result.execution_time_ms ?? 0;
onProgress?.({ rowsExported: rows.length, totalRows });
sessionId = result.session_id ?? undefined;
const shouldFetchNextPage = useAgentCursor ? result.has_more === true : result.rows.length >= pageLimit;
if (!shouldFetchNextPage) break;
offset += result.rows.length;
}
} finally {
if (sessionId) void api.closeQuerySession(tab.connectionId, executionDatabase, sessionId, clientSessionId, tableMeta.catalog);
void closeClientSessionId(tab.connectionId, executionDatabase, clientSessionId, tableMeta.catalog, { tabId: tab.id });
}
return {
columns: columns.length ? columns : tab.result.columns,
rows,
affected_rows: 0,
execution_time_ms: executionTimeMs,
truncated: false,
has_more: false,
};
}
if (tab.mode !== "query") return tab.result;
const sql = queryResultExecutionSql(tab);
if (!sql.trim()) return tab.result;
const location = queryResultExecutionLocation(tab);
const connStore = useConnectionStore();
await connStore.ensureConnected(location.connectionId);
const conn = connStore.getConfig(location.connectionId);
const effectiveDbType = effectiveDatabaseTypeForConnection(conn);
const executableSql = effectiveDbType === "mysql" ? stripMysqlClientDisplayCommand(sql) : sql;
const executionDatabase = location.database;
// main 引入全局查询超时:queryTimeoutSecsForConnection 现需传入全局默认值;
// settingsStore 取 defineStore 顶层声明的实例(本函数无局部覆盖)。
const queryTimeoutSecs = queryTimeoutSecsForConnection(conn, settingsStore.editorSettings.globalQueryTimeoutSecs);
const useAgentCursor = usesAgentCursorForQuery(conn?.db_type, conn?.driver_profile);
const queryBaseSql = effectiveDbType === "mysql" ? stripMysqlClientDisplayCommand(queryResultBaseSql(tab)) : queryResultBaseSql(tab);
const exportSettings = useSettingsStore().editorSettings;
const exportRowLimit = exportSettings.exportRowLimitEnabled ? exportSettings.exportRowLimit : Number.POSITIVE_INFINITY;
if (effectiveDbType === "mongodb") {
let mongoCommand;
let exportDatabase = location.database;
try {
mongoCommand = await api.mongoParseShellCommand(sql);
} catch {
throw new Error(QUERY_RESULT_EXPORT_UNSUPPORTED_ERROR);
}
if (mongoCommand.kind === "inDatabase") {
// `db.getSiblingDB("x").c.find()` exports against the wrapped database.
exportDatabase = mongoCommand.database;
mongoCommand = mongoCommand.command;
}
if (mongoCommand.kind !== "find") throw new Error(QUERY_RESULT_EXPORT_UNSUPPORTED_ERROR);
const pageLimit = Math.max(1, Math.trunc(exportSettings.exportBatchSize));
const documents: unknown[] = [];
let copyDocuments: unknown[] | undefined = [];
let pageOffset = 0;
let totalRows = typeof tab.resultTotalRowCount === "number" ? Math.min(tab.resultTotalRowCount, exportRowLimit) : null;
const exportStartedAt = performance.now();
const exportExecutionId = uuid();
// Pagination planning parses `db.<collection>.find(...)` directly; strip a
// `db.getSiblingDB("x")` wrapper the same way the execution loop does.
const exportSiblingPrefix = splitSiblingDbPrefix(sql);
const exportPaginationSource = exportSiblingPrefix ? `db${exportSiblingPrefix.rest}` : sql;
while (documents.length < exportRowLimit) {
const remaining = exportRowLimit - documents.length;
const plan = planMongoFindPagination(exportPaginationSource, mongoCommand, pageOffset, Math.min(pageLimit, remaining));
if (!plan) throw new Error(QUERY_RESULT_EXPORT_UNSUPPORTED_ERROR);
if (plan.requestLimit === 0) break;
const result = await api.mongoFindDocuments(location.connectionId, exportDatabase, mongoCommand.collection, plan.requestSkip, plan.requestLimit, mongoCommand.filter, mongoCommand.projection, mongoCommand.sort, mongoCommand.collation, exportExecutionId);
const pageDocuments = result.documents.slice(0, plan.requestLimit);
documents.push(...pageDocuments);
if (copyDocuments) {
if (result.extended_documents?.length === result.documents.length) {
copyDocuments.push(...result.extended_documents.slice(0, pageDocuments.length));
} else {
copyDocuments = undefined;
}
}
if (result.total_is_exact !== false) {
totalRows = Math.min(mongoFindLogicalTotal(result.total, plan), exportRowLimit);
}
onProgress?.({ rowsExported: documents.length, totalRows });
pageOffset += pageDocuments.length;
const reachedLogicalLimit = plan.logicalLimit !== undefined && pageOffset >= plan.logicalLimit;
const reachedExactTotal = result.total_is_exact !== false && pageOffset >= mongoFindLogicalTotal(result.total, plan);
if (pageDocuments.length === 0 || pageDocuments.length < plan.requestLimit || reachedLogicalLimit || reachedExactTotal) break;
}
const result = mongoDocumentsToQueryResult(documents, performance.now() - exportStartedAt, totalRows ?? documents.length, copyDocuments, totalRows !== null);
if (result.columns.length === 0) {
result.columns = tab.result.columns;
result.column_types = tab.result.column_types;
}
result.affected_rows = documents.length;
result.truncated = false;
result.has_more = false;
return result;
}
const agentExportMaxRows = exportSettings.exportRowLimitEnabled ? exportSettings.exportRowLimit : 2_147_483_647;
// Use the already-computed total row count as a progress estimate so the
// export dialog shows a moving bar instead of a stuck 0 while paginating.
const totalRows = typeof tab.resultTotalRowCount === "number" ? Math.min(tab.resultTotalRowCount, exportRowLimit) : null;
const pageLimit = Math.max(tab.resultPageLimit ?? 0, TABLE_DATA_EXPORT_PAGE_SIZE);
const rows: QueryResult["rows"] = [];
let columns: string[] = [];
let executionTimeMs = 0;
let offset = 0;
let sessionId: string | undefined;
const clientSessionId = tabClientSessionId(tab, "export");
const exportExecutionId = uuid();
try {
while (rows.length < exportRowLimit) {
const remaining = exportRowLimit - rows.length;
const effectivePageLimit = Math.min(pageLimit, remaining);
const plan = await api.prepareQueryPaginationExecutionPlan({
sql: executableSql,
queryBaseSql,
databaseType: effectiveDbType,
pagination: { limit: effectivePageLimit, offset, sessionId },
useAgentCursor,
firstPageUsesActualSql: true,
});
if (typeof plan.pageLimit !== "number" || typeof plan.pageOffset !== "number") return tab.result;
const executionOptions = plan.useAgentResultSession
? {
maxRows: agentExportMaxRows,
fetchSize: plan.pageLimit,
pageSize: plan.pageLimit,
resultSessionId: sessionId,
clientSessionId,
catalog: location.catalog,
timeoutSecs: queryTimeoutSecs,
}
: { maxRows: plan.pageLimit, fetchSize: plan.pageLimit, clientSessionId, catalog: location.catalog, timeoutSecs: queryTimeoutSecs };
const results = await api.executeMulti(location.connectionId, executionDatabase, plan.sqlToExecute, location.schema, exportExecutionId, executionOptions);
if (!results[0]) break;
const result = stripPaginationRowNumber(results[0], plan.paginationRowNumberColumn);
if (columns.length === 0) columns = result.columns;
rows.push(...result.rows);
executionTimeMs += result.execution_time_ms ?? 0;
onProgress?.({ rowsExported: rows.length, totalRows });
sessionId = result.session_id ?? undefined;
const shouldFetchNextPage = plan.useAgentResultSession ? result.has_more === true : result.rows.length >= plan.pageLimit;
if (!shouldFetchNextPage || rows.length >= exportRowLimit) break;
offset += result.rows.length;
}
} finally {
if (sessionId) void api.closeQuerySession(location.connectionId, location.database, sessionId, clientSessionId, location.catalog);
void closeClientSessionId(location.connectionId, location.database, clientSessionId, location.catalog, { tabId: tab.id });
}
return {
columns: columns.length ? columns : tab.result.columns,
rows,
affected_rows: 0,
execution_time_ms: executionTimeMs,
truncated: false,
has_more: false,
};
}
async function buildQueryResultExportRequest(id: string, options: BuildQueryResultExportRequestOptions) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab?.result || tab.mode !== "query") return undefined;
const sql = queryResultExecutionSql(tab);
if (!sql.trim()) return undefined;
const location = queryResultExecutionLocation(tab);
const connStore = useConnectionStore();
await connStore.ensureConnected(location.connectionId);
const conn = connStore.getConfig(location.connectionId);
const settings = useSettingsStore().editorSettings;
const effectiveDbType = effectiveDatabaseTypeForConnection(conn);
if (!effectiveDbType) return undefined;
if (effectiveDbType === "mongodb") return undefined;
const useAgentCursor = usesAgentCursorForQuery(conn?.db_type, conn?.driver_profile);
const executableSql = effectiveDbType === "mysql" ? stripMysqlClientDisplayCommand(sql) : sql;
const queryBaseSql = effectiveDbType === "mysql" ? stripMysqlClientDisplayCommand(queryResultBaseSql(tab)) : queryResultBaseSql(tab);
const resultStatementIndex = tab.result.statement_index;
const batchSql = tab.resultBaseSql ?? tab.lastExecutedSql ?? tab.sql;
const batchStatements = effectiveDbType === "postgres" && tab.result.truncated === true && Number.isInteger(resultStatementIndex) && resultStatementIndex! > 0 ? splitSqlStatementRanges(batchSql, effectiveDbType) : [];
const setupSql = batchStatements[resultStatementIndex!]?.sql === tab.result.sourceStatement ? batchStatements.slice(0, resultStatementIndex).map((statement) => statement.sql) : undefined;
const rowLimit = settings.exportRowLimitEnabled ? settings.exportRowLimit : null;
const totalRows = typeof tab.resultTotalRowCount === "number" ? (rowLimit === null ? tab.resultTotalRowCount : Math.min(tab.resultTotalRowCount, rowLimit)) : null;
const clientSessionId = `${tabClientSessionId(tab, "export")}:${options.exportId}`;
return {
exportId: options.exportId,
connectionId: location.connectionId,
database: location.database,
schema: location.schema,
catalog: location.catalog,
sql: executableSql,
queryBaseSql,
setupSql,
databaseType: effectiveDbType,
useAgentCursor,
filePath: options.filePath,
format: options.format,
...(options.format === "sql" && options.insertMode ? { insertMode: options.insertMode } : {}),
includeSqlSheet: options.format === "xlsx" && options.includeSqlSheet === true,
pageSize: settings.exportBatchSize,
rowLimit,
totalRows,
timeoutSecs: queryTimeoutSecsForConnection(conn, settingsStore.editorSettings.globalQueryTimeoutSecs),
keysetOptimizationEnabled: settings.queryExportKeysetOptimizationEnabled,
csvQuoteMode: settings.csvQuoteMode,
clientSessionId,
executionId: uuid(),
exportTableName: options.exportTableName,
exportColumnTypes: options.exportColumnTypes,
exportColumnExtras: options.exportColumnExtras,
numericColumnRightAlign: settings.numericColumnRightAlign,
identifierQuote: connStore.connectionIdentifierQuote(location.connectionId),
};
}
async function exportQuerySqlDirect(id: string, sql: string, format: "csv" | "xlsx" | "txt", filePath: string, columnComments?: (string | null)[]) {
const tab = tabs.value.find((item) => item.id === id);
if (!tab || tab.mode !== "query" || !sql.trim()) return;
const connStore = useConnectionStore();
await connStore.ensureConnected(tab.connectionId);
const conn = connStore.getConfig(tab.connectionId);
const settings = useSettingsStore().editorSettings;
const effectiveDbType = effectiveDatabaseTypeForConnection(conn);
if (!effectiveDbType) return;
const exportId = uuid();
const executableSql = effectiveDbType === "mysql" ? stripMysqlClientDisplayCommand(sql) : sql;
const request: api.QueryResultExportRequest = {
exportId,
connectionId: tab.connectionId,
database: tab.database,
schema: tab.schema,
sql: executableSql,
queryBaseSql: executableSql,
databaseType: effectiveDbType,
useAgentCursor: usesAgentCursorForQuery(conn?.db_type, conn?.driver_profile),
filePath,
format,
pageSize: settings.exportBatchSize,
rowLimit: settings.exportRowLimitEnabled ? settings.exportRowLimit : null,
totalRows: null,
timeoutSecs: queryTimeoutSecsForConnection(conn, settingsStore.editorSettings.globalQueryTimeoutSecs),
keysetOptimizationEnabled: settings.queryExportKeysetOptimizationEnabled,
csvQuoteMode: settings.csvQuoteMode,
clientSessionId: `${tabClientSessionId(tab, "export")}:${exportId}`,
executionId: uuid(),
numericColumnRightAlign: settings.numericColumnRightAlign,
columnComments,
identifierQuote: connStore.connectionIdentifierQuote(tab.connectionId),
};
const tracker = useExportTracker();
tracker.addTask("Query Result", format, filePath, request.exportId);
tracker.registerTaskCancelHandler(request.exportId, () => api.cancelQueryResultExport(request.exportId, request.executionId));
void (async () => {
try {
await api.startQueryResultExport(request, (progress) => tracker.updateTableExportTask(request.exportId, progress));
} catch (error: any) {
const task = tracker.tasks.value.find((item) => item.exportId === request.exportId);
if (task) {
task.status = "Error";
task.errorMessage = error?.message || String(error);
}
} finally {
tracker.unregisterTaskCancelHandler(request.exportId);
}
})();
}
return {
tabs,
activeTabId,
groups,
focusedGroupId,
orientation,
sizes,
isOpenTabsLoaded,
recentTabIds,
initOpenTabs,
prepareDetachedTab,
adoptDetachedTab,
removeTabAfterDetachedReady,
flushDetachedTabPersistence,
showCloseConfirm,
pendingCloseTabId,
closeConfirmContext,
closeConfirmDirtyTabIds,
hasDirtyTabs,
requiresAppCloseDraftPersist,
isConfirmingAppClose,
createTab,
openObjectSourceTab,
openObjectSourceTabPending,
retryObjectSourceTab,
refreshObjectSourceTab,
loadDdlViewerTab,
retryDdlViewerTab,
showExecutedQueryResults,
focusGroup,
activateTab,
activateTabInGroup,
splitTabRight,
splitTabDown,
setOrientation,
moveTabToGroup,
unsplitTab,
switchTab,
closeTab,
forceClosePendingTab,
forceCloseAllPendingTabs,
cancelClosePendingTab,
flushPendingPersist,
saveAndClosePendingTab,
suspendCloseConfirm,
resumeCloseConfirm,
completePendingCloseAfterSaveAll,
isTabDirty,
markTabClean,
applyExternalSqlFileSnapshot,
markExternalSqlFileSaved,
updateExternalSqlFileVersion,
ignoreExternalSqlFileVersion,
acknowledgeExternalSqlFileMissing,
relocateExternalSqlFilePath,
markExternalSqlFileMissingForPath,
discardTabChanges,
requestAppCloseConfirmation,
closeOtherTabs,
closeOtherTabsInGroup,
closeAllTabsInGroup,
closeTabsByIds,
closeRightTabs,
closeOtherRegularTabs,
closeRegularTabs,
closeOtherFixedTabs,
closeFixedTabs,
closeAllTabs,
duplicateTab,
closeConnectionTabs,
closeDatabaseTabs,
closeDroppedTableObjectTabs,
refreshDataTab,
refreshDataTabsForTable,
releaseConnectionTabs,
releaseDatabaseTabs,
detachConnectionTabsForDelete,
rebindDetachedTabs,
staleConnectionDataTabMetadata,
isDatabaseOpen,
openDatabaseKeys,
rollbackConnectionTransactions,
rollbackDatabaseTransactions,
updateSql,
updateDataGridLocalColumnFilters,
updateDataGridHiddenColumnKeys,
updateEditorViewport,
updateTabUiState,
updateTabPageUiState,
updateTabPageResult,
updateEditorSelection,
flushEditorState,
updateObjectBrowserViewport,
updateObjectBrowserSearch,
updateObjectBrowserFilter,
updateNacosConfigEditorViewport,
setAutoCommit,
markManualTransactionDirty,
commitTransaction,
rollbackTransaction,
ensureManualTransactionSession,
renameTab,
openDatabaseBrowser,
openDriverProfileWorkspace,
openObjectBrowser,
openMongoGridFs,
openMongoBucket,
openUserAdmin,
openProcessList,
openSqlServerActivityTrace,
openMysqlDashboard,
openPostgresDashboard,
openXuguDashboard,
openNacosDashboard,
openSolrAdmin,
openDamengUsers,
openDamengRoles,
openDamengJobAdmin,
openMqAdmin,
openMqttAdmin,
openNacosAdmin,
openPluginWorkbench,
localizePluginTabTitles,
openPluginFilesystem,
reconnectRestoredPluginTabs,
openPluginConnection,
clearNacosNavigationTarget,
openTableStructure,
linkSavedSql,
linkExternalSqlPath,
openExternalSqlFile,
openSavedSql,
hydrateSavedSqlTabs,
togglePinnedTab,
reorderTab,
createExecutionTargetGuard,
updateDatabase,
updateCatalog,
updateSchema,
updateConnection,
setTableMeta,
clearInvalidDataTabSort,
invalidateTableStructure,
tableStructureRefreshVersion,
setObjectSource,
setExecuting,
setExecutingWithId,
setErrorResult,
invalidateResultEstimateForPayload,
toggleResultAutoSave,
setActiveResultRun,
toggleResultRunPinned,
renameResultRun,
unpinAllResultRuns,
closeOtherResultRuns,
closeResultRunsToLeft,
closeResultRunsToRight,
removeResultRun,
closeQueryResult,
clearQueryResults,
setActiveResultIndex,
executeCurrentTab,
executeCurrentSql,
executeTabSql,
dismissBatchSqlRecovery,
resumeBatchSql,
activeResultExecutionTarget,
getExecutionTab,
createMultiDbExecutionWorker,
captureMultiDbExecutionWorkerResult,
removeMultiDbExecutionWorker,
cancelMultiDbExecutionScope,
sortTabResultLocally,
explainTabSql,
cancelTabExecution,
cancelTabExplain,
reloadEvictedTab,
exportResultArchive,
importResultArchive,
fetchTabResultForExport,
countTabResultRows,
buildQueryResultExportRequest,
exportQuerySqlDirect,
applyQueryMetadataPatch,
getResourceLifecycleDiagnostics: () => resourceLifecycleDiagnostics(tabs.value),
notifyConnectionMayBeLost,
};
});